halkyon/tool/app_icons.py

280 lines
11 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Makes the apps' icons, one family (ADR 0031): each app's Lucide glyph on a white
square (TILE; D10, back from D8's dark one), fading from cobalt through the colours
APPS names, top left to bottom right across Lucide's 24-unit box, the stops evenly
spaced, at Lucide's stroke width (2),
1024 x 1024 pixels (<app>/<flutter package>/assets/icon/<icon>); the launchers and
the launch screens round its corners themselves. Then, for each app:
- Lucide's licence (ISC) beside the icon (assets/icon/LICENSE-lucide.txt), which the
app registers in its licence list;
- the snap's icon where the app has a snap (Learn's snap/gui/halkyon-learn.png at the
repository's root, Todo's apps/todo/snap/gui/halkyon-todo.png; 512 x 512 pixels, the
Snap Store's size), the icon with rounded corners, as other apps' tiles
have (radius 142.222 of 512, about 28 %);
- through flutter_launcher_icons, configured in the app's pubspec: Android's adaptive
icon with its monochrome layer, which themed icons tint (D9), and its older square
one, iOS's with its dark and tinted variants (D9), macOS's (the rounded tile on
Apple's grid, 824 of 1024 pixels) and Windows' (the rounded tile), from the images
derived in the app's build/icons/, which git ignores;
- Windows' .ico once more, with every size Windows shows (16 to 256 pixels): the tool
writes only the 256 one and leaves Windows to scale it down for its small views;
- the iOS project file as it was: the tool sets every build setting with
ASSETCATALOG in its name to the icon set's name, Xcode's yes-or-no
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS too, which breaks the
project (flutter_launcher_icons issue 634); the project names its icon set already.
The glyphs come from lucide-static at a pinned release (LUCIDE below), fetched from
jsDelivr, and are drawn by librsvg, as they have arcs and curves (bike's wheels).
Run from the repository's root with Python 3, Pillow, PyGObject with librsvg and
pycairo (Ubuntu's python3-pil, python3-gi, gir1.2-rsvg-2.0, python3-cairo), the apps'
dependencies fetched, naming the apps to draw, or none for all of them:
python3 -I tool/app_icons.py train
"""
import subprocess
import sys
import urllib.request
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
import cairo
import gi
gi.require_version("Rsvg", "2.0")
from gi.repository import GLib, Rsvg # noqa: E402 (after the version is required)
from PIL import Image, ImageDraw # noqa: E402
LUCIDE = "1.50.0"
CDN = f"https://cdn.jsdelivr.net/npm/lucide-static@{LUCIDE}"
COBALT = "#3C57E5"
VIOLET = "#8E4FD6"
STRAWBERRY = "#EC3E7C"
CARROT = "#F78400"
PACIFIC = "#179FD9"
FERN = "#3CBB3A"
REEF = "#1DA583"
ENZIAN = "#5252CC"
PURPLE = "#8080FF"
# ADR 0031, D10: the tile is white again (D1), the gradient as APPS gives it. iOS's
# dark icon (D9) sits on iOS's own dark background, so there every stop is mixed this
# share towards white (D8's lift), keeping cobalt above 4.5 : 1 against it.
TILE = "#FFFFFF"
DARK_LIFT = 0.3
@dataclass(frozen=True)
class AppIcon:
"""An app's icon: where it goes, its glyph and its gradient's colours."""
folder: str
package: str
icon: str
glyph: str
stops: tuple[str, ...]
snap: str | None = None
feat(meditate): the app: Today, Explore, Profile, the sitting and its bells halkyon_meditate_flutter on halkyon_app and halkyon_ui (ADR 0038): the first start, sign-in and account as every Halkyon app's, a bar below medium windows and a rail with the icon from medium on, fern washes, Settings in cards (Sync, the operator's, Sitting, Practice, the account, What's new, About, the version), English and German. - Today: a greeting by the hour, Sit now at 5, 10, 20 minutes and the timer's own length, the practice of the day, the week with the days in a row, favourites, recently sat (D9). - Explore: six practices of Halkyon's own, written for the app, found by a search without case or accents, by goal and by kind; each page with how it goes, its lengths, its heart and Start (D4, D10). - The sitting, over the tabs: its time from the clock, a ring counting down, the cue of the moment, Pause and End, the bells at the start, between and at the end, the screen kept on; ended early, kept from a minute on where the person says so (D5, D6). - Profile: every Halkyon app's, then the person's practice: minutes, sittings, days, the week and every sitting kept, to delete (D8). - Settings and sittings sealed with the person's key and synced (D7). The bells are synthesised by apps/meditate/tool/bells.py, the icon is Lucide's sprout from cobalt through pacific into fern (tool/app_icons.py). Releases and the deploy are a later step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 09:25:30 +01:00
# ADR 0031, D5: each from cobalt into its app's backdrop.
APPS = {
"learn": AppIcon(
"apps/learn",
"halkyon_learn_flutter",
"halkyon_learn_icon.png",
"graduation-cap",
(COBALT, ENZIAN, PURPLE),
snap="snap/gui/halkyon-learn.png",
),
"todo": AppIcon(
"apps/todo",
"halkyon_todo_flutter",
"halkyon_todo_icon.png",
"list-todo",
(COBALT, VIOLET, STRAWBERRY),
snap="apps/todo/snap/gui/halkyon-todo.png",
),
"train": AppIcon(
"apps/train",
"halkyon_train_flutter",
"halkyon_train_icon.png",
"dumbbell",
(COBALT, VIOLET, STRAWBERRY, CARROT),
snap="apps/train/snap/gui/halkyon-train.png",
),
feat(meditate): the app: Today, Explore, Profile, the sitting and its bells halkyon_meditate_flutter on halkyon_app and halkyon_ui (ADR 0038): the first start, sign-in and account as every Halkyon app's, a bar below medium windows and a rail with the icon from medium on, fern washes, Settings in cards (Sync, the operator's, Sitting, Practice, the account, What's new, About, the version), English and German. - Today: a greeting by the hour, Sit now at 5, 10, 20 minutes and the timer's own length, the practice of the day, the week with the days in a row, favourites, recently sat (D9). - Explore: six practices of Halkyon's own, written for the app, found by a search without case or accents, by goal and by kind; each page with how it goes, its lengths, its heart and Start (D4, D10). - The sitting, over the tabs: its time from the clock, a ring counting down, the cue of the moment, Pause and End, the bells at the start, between and at the end, the screen kept on; ended early, kept from a minute on where the person says so (D5, D6). - Profile: every Halkyon app's, then the person's practice: minutes, sittings, days, the week and every sitting kept, to delete (D8). - Settings and sittings sealed with the person's key and synced (D7). The bells are synthesised by apps/meditate/tool/bells.py, the icon is Lucide's sprout from cobalt through pacific into fern (tool/app_icons.py). Releases and the deploy are a later step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 09:25:30 +01:00
"meditate": AppIcon(
"apps/meditate",
"halkyon_meditate_flutter",
"halkyon_meditate_icon.png",
"sprout",
(COBALT, PACIFIC, FERN),
),
}
SIZE = 1024
# The 24-unit box Lucide draws in takes this share of the square, centred: well
# inside the circle Android's adaptive icons keep (66 %).
GLYPH = 0.6
# The corners the launch screen and launchers round to.
RADIUS = 142.222 / 512
SUPERSAMPLING = 4
SNAP_SIZE = 512
# Apple's grid: the tile is 824 of the icon's 1024 pixels, centred.
MACOS_CANVAS = 1024
MACOS_TILE = 824
WINDOWS_SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256]
def fetch(name: str) -> str:
with urllib.request.urlopen(f"{CDN}/{name}", timeout=30) as response:
return response.read().decode("utf-8")
def rgb(colour: str) -> tuple[float, float, float]:
return tuple(int(colour[i : i + 2], 16) / 255 for i in (1, 3, 5))
def lifted(colour: str, lift: float) -> tuple[float, float, float]:
"""[colour] mixed [lift] towards white."""
return tuple(c + (1 - c) * lift for c in rgb(colour))
def glyph_mask(glyph: str, svg: str) -> cairo.ImageSurface:
"""[glyph]'s strokes alone, SIZE pixels square, where every icon draws it."""
if f"lucide-static v{LUCIDE}" not in svg:
raise SystemExit(f"Not lucide-static {LUCIDE}'s {glyph}.")
if 'viewBox="0 0 24 24"' not in svg or 'stroke-width="2"' not in svg:
raise SystemExit(f"{glyph} isn't drawn in Lucide's 24 units at width 2.")
handle = Rsvg.Handle.new_from_data(svg.replace("currentColor", "#000").encode())
box = SIZE * GLYPH
offset = (SIZE - box) / 2
mask = cairo.ImageSurface(cairo.FORMAT_ARGB32, SIZE, SIZE)
viewport = Rsvg.Rectangle()
viewport.x, viewport.y, viewport.width, viewport.height = offset, offset, box, box
try:
handle.render_document(cairo.Context(mask), viewport)
except GLib.Error as error:
raise SystemExit(f"librsvg couldn't draw {glyph}: {error.message}")
return mask
def gradient_of(stops: tuple[str, ...], lift: float = 0) -> cairo.LinearGradient:
"""The gradient of [stops], each mixed [lift] towards white, across the glyph's
box, top left to bottom right."""
box = SIZE * GLYPH
offset = (SIZE - box) / 2
gradient = cairo.LinearGradient(offset, offset, offset + box, offset + box)
for i, colour in enumerate(stops):
gradient.add_color_stop_rgb(i / (len(stops) - 1), *lifted(colour, lift))
return gradient
def drawn(
mask: cairo.ImageSurface,
source: cairo.Pattern,
background: tuple[float, float, float] | None,
) -> Image.Image:
"""The glyph's [mask] filled with [source], on [background] or, without one,
on transparency."""
surface = cairo.ImageSurface(
cairo.FORMAT_ARGB32 if background is None else cairo.FORMAT_RGB24, SIZE, SIZE
)
context = cairo.Context(surface)
if background is not None:
context.set_source_rgb(*background)
context.paint()
context.set_source(source)
context.mask_surface(mask)
png = BytesIO()
surface.write_to_png(png)
png.seek(0)
return Image.open(png).convert("RGB" if background is not None else "RGBA")
def icon(mask: cairo.ImageSurface, stops: tuple[str, ...]) -> Image.Image:
"""The glyph's [mask] on TILE in the gradient of [stops]."""
return drawn(mask, gradient_of(stops), rgb(TILE))
def themed(mask: cairo.ImageSurface, stops: tuple[str, ...]) -> dict[str, Image.Image]:
"""The variants the systems colour themselves (ADR 0031, D9), by their file
names in build/icons/: Android's monochrome layer (the glyph in white on
transparency, which the launcher tints from the wallpaper), iOS's dark icon
(the glyph in its gradient, lifted by DARK_LIFT, on transparency, which iOS sets
on its own dark background) and iOS's tinted one (the glyph in white on black, a grayscale
image iOS tints)."""
white = cairo.SolidPattern(1, 1, 1)
return {
"monochrome.png": drawn(mask, white, None),
"ios_dark.png": drawn(mask, gradient_of(stops, DARK_LIFT), None),
"ios_tinted.png": drawn(mask, white, (0, 0, 0)),
}
def rounded(source: Image.Image, size: int) -> Image.Image:
"""[source] at [size] pixels, its corners rounded off to transparency."""
picture = source.convert("RGBA").resize((size, size), Image.LANCZOS)
large = size * SUPERSAMPLING
mask = Image.new("L", (large, large), 0)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, large - 1, large - 1), radius=RADIUS * large, fill=255
)
picture.putalpha(mask.resize((size, size), Image.LANCZOS))
return picture
def make(app: AppIcon, license_text: str) -> None:
package = Path(app.folder) / app.package
mask = glyph_mask(app.glyph, fetch(f"icons/{app.glyph}.svg"))
source = icon(mask, app.stops)
path = package / "assets/icon" / app.icon
path.parent.mkdir(parents=True, exist_ok=True)
source.save(path, optimize=True)
print(f"Wrote {path}.")
license_path = package / "assets/icon/LICENSE-lucide.txt"
license_path.write_text(license_text, encoding="utf-8")
print(f"Wrote {license_path}.")
if app.snap:
rounded(source, SNAP_SIZE).save(app.snap, optimize=True)
print(f"Wrote {app.snap}.")
derived = package / "build/icons"
derived.mkdir(parents=True, exist_ok=True)
rounded(source, 1024).save(derived / "rounded.png")
macos = Image.new("RGBA", (MACOS_CANVAS, MACOS_CANVAS), (0, 0, 0, 0))
margin = (MACOS_CANVAS - MACOS_TILE) // 2
macos.alpha_composite(rounded(source, MACOS_TILE), (margin, margin))
macos.save(derived / "macos.png")
for name, picture in themed(mask, app.stops).items():
picture.save(derived / name)
ios_project = package / "ios/Runner.xcodeproj/project.pbxproj"
kept = ios_project.read_bytes()
subprocess.run(["dart", "run", "flutter_launcher_icons"], cwd=package, check=True)
ios_project.write_bytes(kept)
windows_icon = package / "windows/runner/resources/app_icon.ico"
rounded(source, 256).save(
windows_icon, format="ICO", sizes=[(size, size) for size in WINDOWS_SIZES]
)
print(f"Wrote {windows_icon} with {len(WINDOWS_SIZES)} sizes.")
def main() -> None:
names = sys.argv[1:] or list(APPS)
unknown = [name for name in names if name not in APPS]
if unknown:
raise SystemExit(f"No icon for {', '.join(unknown)}: {', '.join(APPS)}.")
if not Path("tool/app_icons.py").is_file():
raise SystemExit("Run this from the repository's root.")
license_text = fetch("LICENSE")
for name in names:
make(APPS[name], license_text)
if __name__ == "__main__":
main()