From 56535695c6defba5b9e1a2ad977c21191c74d4f2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 19 Jul 2026 17:16:10 +0200 Subject: [PATCH] Add change explanation: shared-app-icon-generator Walkthrough of the app_icons package with an interactive micro-world that steps the centring pipeline (draw, measure ink, compute offset, translate) on the real glyph data, including the oversized cloud that failed the safe-box check. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...explanation-shared-app-icon-generator.html | 843 ++++++++++++++++++ docs/explanations/index.html | 6 +- 2 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 docs/explanations/2026-07-19-explanation-shared-app-icon-generator.html diff --git a/docs/explanations/2026-07-19-explanation-shared-app-icon-generator.html b/docs/explanations/2026-07-19-explanation-shared-app-icon-generator.html new file mode 100644 index 00000000..b675268e --- /dev/null +++ b/docs/explanations/2026-07-19-explanation-shared-app-icon-generator.html @@ -0,0 +1,843 @@ + + + + + +A shared launcher-icon generator for the Flutter apps + + + + +

A shared launcher-icon generator for the Flutter apps

+

How five apps stopped shipping the stock Flutter icon — and why the +interesting part is a measurement, not a drawing.

+

+testsAndMisc 27f44ca · adoption commits in dufs-cloud 18a18a4, +screen-locker 135545a, wake-alarm 64c25e6, +diet-guard f6abf80, todo 0be0706 +

+ + + +

1. Background — what was there before

+ +

Five Flutter apps live across these repos: a file-server client +(dufs_client), a gym tracker (workout_app), an alarm companion +(wake_alarm_sync), a diet logger (diet_guard_app) and a notes app +(todo). Each was scaffolded with flutter create, and none of them had +ever been given an icon. That means every one of them shipped the identical stock +Flutter artwork — five indistinguishable tiles on the same home screen.

+ +

What a Flutter project gives you by default

+ +

A fresh Android project contains only legacy icons: one PNG per density +bucket, at android/app/src/main/res/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png. +Nothing else. In particular there was no +mipmap-anydpi-v26/ic_launcher.xml in any of the five repos — verified before +starting.

+ +

That file matters more than its obscure name suggests. Since Android 8, launchers +prefer an adaptive icon: instead of one flat bitmap, you supply a +background layer and a foreground layer, and the launcher composites them and applies +its own mask — a circle on one device, a squircle on another, a teardrop on a third. +Without that XML the system falls back to the legacy PNG and just crops it into a +shape, which is why untouched Flutter apps look slightly wrong on modern launchers. +Android 13 added a third optional layer, <monochrome>, which the system +recolours to match the user's wallpaper-derived theme.

+ +

The constraint nobody tells you about

+ +

An adaptive icon's layers are larger than the visible result. The launcher is only +guaranteed to show roughly the inner 66% of the canvas; everything +outside that can be masked away, and how much is device-dependent. So art that looks +perfect at full size can lose a corner on a real phone.

+ +
+
+
+

Before

+

Five identical stock tiles; legacy + PNGs only; no adaptive descriptor.

+
+
+ +
stock ×5
+
+
+
+

After

+

One family: shared charcoal field, + per-app accent glyph, full adaptive layers.

+
+
+
+
The five apps before and after. Each accent is that app's own theme seed + colour, so the icon matches the first screen you see when the app opens.
+
+ +

Why not just draw five icons?

+ +

Because "five icons" is a snapshot, and the sixth app is always coming. Hand-drawn +one-offs drift: a slightly different stroke weight here, a different background there, +and within a year there is no family left. The change therefore defines the family +once, in code, and lets each app contribute only two things: an accent colour +and a glyph.

+ +

2. Intuition — measure, then translate

+ +

The design rule is simple to state:

+ +
+ One charcoal field (#1B1D21), one stroke weight (72px on a + 1024px canvas), one glyph per app in that app's own theme accent. +
+ +

Stating it is easy. Making it true is where the interesting problem lives, +and it is not a drawing problem — it is a measurement problem.

+ +

The problem: glyphs are not the same shape

+ +

Consider two glyphs drawn by hand on the same 1024×1024 canvas. A barbell is wide +and short. A cloud-with-arrow is narrow and tall, and the author naturally drew the +cloud near the top because that is where clouds go. Both are "roughly centred" in the +sense that the author eyeballed them — but their ink sits at different heights. +Put the finished icons in a launcher row and they visibly fail to line up.

+ +

The naive fix is to nudge coordinates by hand until it looks right. That works for +five glyphs and collapses at the sixth, and it silently rots the moment anyone edits a +path.

+ +

The fix: let the renderer find the ink

+ +

Instead, the glyph is rendered on its own on a transparent canvas, and the +bounding box of the actual non-transparent pixels is measured. The offset from that +box's centre to the canvas centre is then applied as a translation. The author never +positions anything; they only decide the shape.

+ +

Two details make this work rather than merely sound clever:

+ + + +

Play with it

+ +

The widget below runs that pipeline step by step on the real glyph data from +glyphs.py. Pick a glyph, then step through: draw it as authored, measure +its ink box, compute the offset, apply it. Watch the safe-box verdict change as you go.

+ +
+
+
+
+ + + + safe box 560 + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + +
+

+
+
+
+
+ +

Three things are worth noticing while you play:

+ + + +

Three layers out, not one

+ +

Once a glyph is centred, three separate images are written from the same fragment. +They differ only in what is drawn behind and in what colour:

+ +
+
+
background
flat colour
+
+
+
foreground
glyph, transparent
+
=
+
what you see
after masking
+
·
+
monochrome
Android 13 themed
+
+
The adaptive layers for diet_guard_app. The background is a + flat colour declared in pubspec.yaml, not an image, so it can never + mis-align with the foreground. The monochrome layer is the same glyph in white; the + system tints it.
+
+ +

And on Linux, none of this applies

+ +

Two of the apps also build for Linux desktop, and there the whole Android mechanism +is irrelevant — flutter_launcher_icons does not touch Linux at all. More +surprisingly, Flutter's GTK runner never sets a window icon of its own: the generated +my_application.cc sets a title and a default size and stops there, so the +window inherits a generic placeholder.

+ +

GTK's idiomatic answer is not "point at a PNG file" but "name an icon and let the +theme resolve it". So the runner asks for an icon by name, and a small install +script puts PNGs under that name into the user's hicolor icon theme:

+ +
+
+ + + + + + + + + + +
PieceRole
gtk_window_set_icon_name(window, "todo")The running window asks the theme for an icon called todo.
linux/icons/<size>/todo.pngPre-rendered and committed, so the installer needs no image tooling.
install_desktop_entry.shCopies those into ~/.local/share/icons/hicolor/ and writes a + .desktop entry.
StartupWMClass=dev.kuhy.todoLinks the running window back to the launcher entry — see the trap below.
+
+
+ +
+ A trap worth remembering. StartupWMClass must match the + window's actual WM_CLASS, and the natural guess — the binary name — is + wrong. Running the built app and inspecting it with + xprop -id <wid> WM_CLASS returned "dev.kuhy.todo": GTK + derives it from the GApplication id in linux/CMakeLists.txt, not + from the executable. Guessing here fails silently — the launcher entry looks right and + the taskbar still shows a generic icon. +
+ +

3. Code — a walkthrough by concern

+ +

The new package is python_pkg/app_icons/. Rather than walk the files +alphabetically, here are the four concerns it separates.

+ +

Concern 1: the family, defined once

+ +

style.py holds every number that makes the icons a set. Changing anything +here restyles all five apps at once, which is the point:

+ +
CANVAS: Final[int] = 1024
+BACKGROUND: Final[str] = "#1B1D21"
+
+# Android masks an adaptive icon down to roughly the inner 66% of the canvas.
+SAFE_BOX: Final[int] = 560
+
+# Glyph negative space must stay at or above STROKE_WIDTH / 2, otherwise
+# adjacent strokes merge into a blob when scaled down to launcher size.
+STROKE_WIDTH: Final[int] = 72
+MIN_NEGATIVE_SPACE: Final[float] = STROKE_WIDTH / 2
+ +

That last constant encodes a mistake made during the work. The first fork glyph had +its tines exactly 72px apart — one full stroke width — so the gaps between them closed +to zero and the shape rendered as a solid blob. The rule is now written down next to the +number that causes it.

+ +

Concern 2: measuring and drawing

+ +

render.py is where the centring lives. +build_svg assembles a document by wrapping the glyph fragment in a group +that carries the shared attributes plus the computed translation:

+ +
f'<g fill="none" stroke="{accent}" stroke-width="{style.STROKE_WIDTH}" '
+f'stroke-linecap="round" stroke-linejoin="round" '
+f'transform="translate({dx:g},{dy:g})">'
+ +

Because the shared attributes live on the wrapper, a glyph fragment is just paths — +it inherits colour and weight and cannot accidentally disagree with the family. A +fragment that genuinely needs something else (the filled cutlery silhouette) overrides +locally and uses an {{ACCENT}} placeholder for its colour.

+ +

Then the measurement itself. Note that it renders, measures, and returns a +translation — it never touches scale:

+ +
def centre_offset(glyph: Glyph, accent: str) -> tuple[float, float]:
+    probe = build_svg(glyph, accent, with_background=False)
+    with tempfile.TemporaryDirectory() as tmp:
+        png = Path(tmp) / "probe.png"
+        rasterise(probe, png, style.CANVAS)
+        width, height, x, y = _ink_bbox(png)
+    if width == 0 or height == 0:
+        raise EmptyGlyphError(glyph.name)
+    return style.CENTRE - (x + width / 2), style.CENTRE - (y + height / 2)
+ +

The probe is drawn without the background — a charcoal rectangle would fill +the canvas and every measurement would return the canvas itself. _ink_bbox +shells out to ImageMagick's -trim and parses the resulting geometry.

+ +

The sibling function safe_box_overflow reuses the same measurement for a +different question — not "where is the ink" but "is there too much of it":

+ +
return max(0, width - style.SAFE_BOX), max(0, height - style.SAFE_BOX)
+ +

This is what turned an invisible problem into a visible one. On the first real run it +reported three overflows, the worst being the cloud at 86px too tall. None of them were +apparent at full size; all three would have been clipped on a phone.

+ +

Concern 3: the registry, and a checkout that lied

+ +

apps.py maps each app to its repo, accent and glyph. The accents are not +invented — each is the app's own theme seed, so the launcher icon matches the app's UI:

+ +
+
+ + + + + + + +
AppAccentWhere it comes fromGlyph
dufs_client#5C6BC0colorSchemeSeed: Colors.indigocloud + arrow
workout_app#FF4444its own Color(0xFFFF4444)barbell
wake_alarm_sync#FFA726seedColor: Colors.orangeclock
diet_guard_app#26A69AcolorSchemeSeed: Colors.tealshield + cutlery
todo#7E57C2seedColor: Colors.deepPurplechecklist
+
+
One collision had to be resolved: dufs_client and + workout_app both seed from Colors.indigo. The gym app already + defined its own crimson accent, so it took crimson and indigo stayed unique.
+
+ +

The registry entry for dufs_client carries a warning comment, and it is +worth explaining why:

+ +
key="dufs_client",
+# The live app: ~/dufs_client is a stale, remote-less checkout that
+# was superseded when dufs-cloud absorbed it (see testsAndMisc
+# CLAUDE.md). Same package name, so it is easy to icon the wrong one.
+repo=_HOME / "dufs-cloud" / "app",
+ +

There were two checkouts with the same name:, the same +applicationId, and the same directory layout. Only one had a git remote and +recent commits. The first pass targeted the wrong one — and because it also built and +installed an APK from there, the phone briefly received a five-week-old build of a live +app. The stale checkout has since been deleted; the comment exists so the trap cannot be +re-entered.

+ +

Concern 4: handing off to the platform

+ +

generate.py writes the three layers, then delegates the fiddly part:

+ +
_LAYERS = (
+    ("icon", True, None),                    # field + glyph
+    ("icon_foreground", False, None),        # glyph only, transparent
+    ("icon_monochrome", False, style.MONOCHROME),  # glyph only, white
+)
+ +

Each layer is written as both SVG source and a 1024px PNG, so a build machine never +needs rsvg-convert. flutter_launcher_icons then expands the PNGs +into every density bucket and — the part worth outsourcing — writes the +mipmap-anydpi-v26/ic_launcher.xml descriptor with all three layers wired up. +Hand-rolling that for five apps is exactly the sort of repetitive work that goes wrong +quietly.

+ +
+ A verification note. Every external tool + (rsvg-convert, magick, dart) is mocked in the test + suite, so the package reaches 100% line and branch coverage without any of them + installed — but that also means the tests cannot tell you whether an icon + looks right. That is what the preview subcommand is for: it + renders a contact sheet with three rows — full size, circle-masked, and downscaled to + 48dp — and the last word belongs to a screenshot of the actual phone launcher. +
+ +

4. Quiz

+

Five questions on the reasoning behind the change. Click an option to check it. +Score: 0 / 0

+
+ + + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index b2444c40..7f29c990 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,8 +30,12 @@
testsAndMisc

Change explanations

-

12 explanations · interactive walkthroughs of notable changes, newest first.

+

13 explanations · interactive walkthroughs of notable changes, newest first.