Replace the default Flutter icon with a stylised cloud
The app shipped the Flutter template's blue F, which said nothing about what it does and did not match the interface. The launcher icon is now a white cloud on the same seed blue the app themes itself with, so the two read as one product. The icon is drawn rather than hand-edited: tool/generate_icon.py renders it from signed distance fields — a union of three circles and a rounded box — using only the standard library, so regenerating it needs no image toolchain. The shape's framing is derived from its own bounding box, which is what keeps it centred; maintaining the bounds separately from the shape had it drifting off-centre. Adds the adaptive icon and the Android 13 monochrome layer, which were missing entirely. The generator frames the cloud against the 72dp the launcher mask always keeps and adaptive_icon_foreground_inset is 0, so the layer is not shrunk twice and the icon reads at the same size as its neighbours on a home screen. Verified on the emulator: the cloud appears in the app drawer and holds its shape down to 48px. docs/architecture.md records how to regenerate it, including the flutter_launcher_icons 0.14.4 bug that writes a string into a boolean Xcode setting and needs reverting by hand afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""Draw the Nuvolari launcher icon: a stylised cloud.
|
||||
|
||||
Produces two 1024x1024 sources, which `flutter_launcher_icons` then fans out to
|
||||
every Android density, the Android adaptive icon and the iOS set:
|
||||
|
||||
app/assets/icon/icon.png white cloud on the app blue, full bleed
|
||||
app/assets/icon/icon_foreground.png the cloud alone, transparent
|
||||
|
||||
The foreground is drawn smaller on purpose. An Android adaptive icon layer is
|
||||
108dp of which only the central 72dp is guaranteed to survive the launcher's
|
||||
mask, so a cloud drawn edge to edge would lose its outer puffs to a circle.
|
||||
This file owns that framing: flutter_launcher_icons is configured with
|
||||
`adaptive_icon_foreground_inset: 0` so it does not shrink the layer a second
|
||||
time on top of what is drawn here.
|
||||
|
||||
Shapes are rendered from signed distance fields rather than by supersampling: a
|
||||
cloud is a union of circles and one rounded box, the distance to that union is
|
||||
exact, and turning distance into coverage gives clean antialiasing at one
|
||||
sample per pixel. That keeps this to the standard library — no Pillow, no
|
||||
cairo, nothing for anyone to install.
|
||||
|
||||
Usage:
|
||||
python tool/generate_icon.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import pathlib
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
SIZE = 1024
|
||||
|
||||
OUT_DIR = pathlib.Path(__file__).resolve().parent.parent / "app" / "assets" / "icon"
|
||||
|
||||
# The app's seed colour, so the icon and the interface are recognisably the same
|
||||
# product. Keep these in step with ColorScheme.fromSeed in lib/main.dart.
|
||||
BACKGROUND = (0x1F, 0x6F, 0xB2)
|
||||
CLOUD = (0xFF, 0xFF, 0xFF)
|
||||
|
||||
# How much of the canvas width the cloud spans.
|
||||
#
|
||||
# The full icon can run closer to the edges: iOS and the legacy Android icon are
|
||||
# square and unmasked.
|
||||
FULL_WIDTH = 0.72
|
||||
|
||||
# The adaptive layer is measured against the 72dp the mask always keeps, not
|
||||
# against the 108dp canvas, so the two icons read at the same size on a home
|
||||
# screen. 0.72 of that safe zone leaves the widest points of the cloud at 0.82
|
||||
# of the circle's radius — clear of a circular mask, and of the rounder masks
|
||||
# some launchers use.
|
||||
SAFE_ZONE = 72 / 108
|
||||
ADAPTIVE_WIDTH = 0.72 * SAFE_ZONE
|
||||
|
||||
|
||||
# --- the cloud ---------------------------------------------------------------
|
||||
#
|
||||
# Defined in its own arbitrary coordinate space. The bounding box is computed
|
||||
# from these numbers rather than written alongside them, so the shape can be
|
||||
# tuned without the framing silently going wrong — which is exactly what
|
||||
# happened when the two were maintained separately.
|
||||
|
||||
# (centre x, centre y, half width, half height, corner radius)
|
||||
BODY = (0.500, 0.560, 0.255, 0.075, 0.075)
|
||||
|
||||
# (centre x, centre y, radius)
|
||||
#
|
||||
# The trailing puff rests on the base line: its lowest point is exactly the
|
||||
# bottom of the body, so the outline runs from the arc into the flat base
|
||||
# without turning back on itself. Left higher, the union re-enters and leaves a
|
||||
# visible notch at the bottom left.
|
||||
PUFFS = (
|
||||
(0.415, 0.470, 0.140), # main puff, left of centre and largest
|
||||
(0.585, 0.495, 0.110), # shoulder puff, higher and smaller
|
||||
(0.290, BODY[1] + BODY[3] - 0.088, 0.088), # trailing puff, on the base
|
||||
)
|
||||
|
||||
|
||||
def cloud_bounds() -> tuple[float, float, float, float]:
|
||||
"""Axis-aligned bounds of the whole cloud: (min x, min y, max x, max y)."""
|
||||
cx, cy, hw, hh, _ = BODY
|
||||
min_x, min_y = cx - hw, cy - hh
|
||||
max_x, max_y = cx + hw, cy + hh
|
||||
|
||||
for px, py, r in PUFFS:
|
||||
min_x = min(min_x, px - r)
|
||||
min_y = min(min_y, py - r)
|
||||
max_x = max(max_x, px + r)
|
||||
max_y = max(max_y, py + r)
|
||||
|
||||
return min_x, min_y, max_x, max_y
|
||||
|
||||
|
||||
# --- signed distance fields --------------------------------------------------
|
||||
|
||||
|
||||
def circle_sdf(x: float, y: float, cx: float, cy: float, r: float) -> float:
|
||||
return math.hypot(x - cx, y - cy) - r
|
||||
|
||||
|
||||
def rounded_box_sdf(
|
||||
x: float,
|
||||
y: float,
|
||||
cx: float,
|
||||
cy: float,
|
||||
half_w: float,
|
||||
half_h: float,
|
||||
radius: float,
|
||||
) -> float:
|
||||
dx = abs(x - cx) - (half_w - radius)
|
||||
dy = abs(y - cy) - (half_h - radius)
|
||||
outside = math.hypot(max(dx, 0.0), max(dy, 0.0))
|
||||
inside = min(max(dx, dy), 0.0)
|
||||
return outside + inside - radius
|
||||
|
||||
|
||||
def cloud_sdf(x: float, y: float) -> float:
|
||||
"""Distance to the cloud: the union of the body and the puffs.
|
||||
|
||||
The asymmetry is deliberate — a cloud built from equal circles reads as a
|
||||
flower.
|
||||
"""
|
||||
distance = rounded_box_sdf(x, y, *BODY)
|
||||
for puff in PUFFS:
|
||||
distance = min(distance, circle_sdf(x, y, *puff))
|
||||
return distance
|
||||
|
||||
|
||||
# --- rendering ---------------------------------------------------------------
|
||||
|
||||
|
||||
def coverage(distance_px: float) -> float:
|
||||
"""Turns a signed distance in pixels into pixel coverage.
|
||||
|
||||
A half-pixel band across the boundary is all the antialiasing a shape this
|
||||
smooth needs.
|
||||
"""
|
||||
return min(max(0.5 - distance_px, 0.0), 1.0)
|
||||
|
||||
|
||||
def render(size: int, width_fraction: float, opaque_background: bool) -> list[bytes]:
|
||||
"""Renders the icon, returning one RGBA bytes row per scanline.
|
||||
|
||||
[width_fraction] is how much of the canvas width the cloud spans. The cloud
|
||||
is centred on its own bounding box, so changing the shape above cannot leave
|
||||
it drifting off-centre.
|
||||
"""
|
||||
min_x, min_y, max_x, max_y = cloud_bounds()
|
||||
cloud_width = max_x - min_x
|
||||
cloud_cx = (min_x + max_x) / 2
|
||||
cloud_cy = (min_y + max_y) / 2
|
||||
|
||||
# Canvas pixel -> cloud space.
|
||||
pixels_per_unit = size * width_fraction / cloud_width
|
||||
rows: list[bytes] = []
|
||||
|
||||
for py in range(size):
|
||||
row = bytearray()
|
||||
sy = (py + 0.5 - size / 2) / pixels_per_unit + cloud_cy
|
||||
|
||||
for px in range(size):
|
||||
sx = (px + 0.5 - size / 2) / pixels_per_unit + cloud_cx
|
||||
|
||||
# Distance in cloud units, converted to pixels so the antialiasing
|
||||
# band stays one pixel wide at any output size.
|
||||
alpha = coverage(cloud_sdf(sx, sy) * pixels_per_unit)
|
||||
|
||||
if opaque_background:
|
||||
r = round(BACKGROUND[0] + (CLOUD[0] - BACKGROUND[0]) * alpha)
|
||||
g = round(BACKGROUND[1] + (CLOUD[1] - BACKGROUND[1]) * alpha)
|
||||
b = round(BACKGROUND[2] + (CLOUD[2] - BACKGROUND[2]) * alpha)
|
||||
row += bytes((r, g, b, 255))
|
||||
else:
|
||||
row += bytes((CLOUD[0], CLOUD[1], CLOUD[2], round(alpha * 255)))
|
||||
|
||||
rows.append(bytes(row))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def write_png(path: pathlib.Path, size: int, rows: list[bytes]) -> None:
|
||||
def chunk(kind: bytes, data: bytes) -> bytes:
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ kind
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
raw = b"".join(b"\x00" + row for row in rows) # filter type 0 per scanline
|
||||
png = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
path.write_bytes(png)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
full = OUT_DIR / "icon.png"
|
||||
write_png(full, SIZE, render(SIZE, FULL_WIDTH, opaque_background=True))
|
||||
print(f"{full.name}: {full.stat().st_size / 1024:.0f} KB")
|
||||
|
||||
foreground = OUT_DIR / "icon_foreground.png"
|
||||
write_png(
|
||||
foreground,
|
||||
SIZE,
|
||||
render(SIZE, ADAPTIVE_WIDTH, opaque_background=False),
|
||||
)
|
||||
print(f"{foreground.name}: {foreground.stat().st_size / 1024:.0f} KB")
|
||||
|
||||
print(
|
||||
"\nbackground #%02X%02X%02X cloud #%02X%02X%02X"
|
||||
% (*BACKGROUND, *CLOUD)
|
||||
)
|
||||
print("now run: flutter pub run flutter_launcher_icons")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user