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>
226 lines
7.5 KiB
Python
226 lines
7.5 KiB
Python
"""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())
|