feat: the apps' icons as one family, drawn by one tool (ADR 0031)
tool/app_icons.py at the root replaces Learn's and Todo's scripts: each app's Lucide glyph (lucide-static 1.50.0) drawn by librsvg, fading from cobalt through the colours ADR 0031 names, then every platform's launcher icon through flutter_launcher_icons, Windows' .ico with every size, and Learn's snap icon. Learn's icon becomes the graduation cap, its window's on Linux too, with Lucide's notice in its licence list; the kingfisher stays the emblem on the rail and the launch screen. Todo's becomes list-todo, Train's bike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7b5fbee913
commit
ece9ce9697
1 changed files with 342 additions and 322 deletions
214
tool/app_icons.py
Normal file
214
tool/app_icons.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Makes the apps' icons, one family (ADR 0031): each app's Lucide glyph on a white
|
||||
square, 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, 512 x 512 pixels, the Snap Store's size), the icon with rounded
|
||||
corners, as other apps' white tiles have (radius 142.222 of 512, about 28 %);
|
||||
- through flutter_launcher_icons, configured in the app's pubspec: Android's adaptive
|
||||
icon and its older square one, iOS's, 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"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# ADR 0031, D2. Meditate's (circle-dot, cobalt, pacific, reef) comes with Meditate.
|
||||
APPS = {
|
||||
"learn": AppIcon(
|
||||
"apps/learn",
|
||||
"halkyon_learn_flutter",
|
||||
"halkyon_learn_icon.png",
|
||||
"graduation-cap",
|
||||
(COBALT, VIOLET, STRAWBERRY),
|
||||
snap="snap/gui/halkyon-learn.png",
|
||||
),
|
||||
"todo": AppIcon(
|
||||
"apps/todo",
|
||||
"halkyon_todo_flutter",
|
||||
"halkyon_todo_icon.png",
|
||||
"list-todo",
|
||||
(COBALT, PACIFIC, FERN),
|
||||
),
|
||||
"train": AppIcon(
|
||||
"apps/train",
|
||||
"halkyon_train_flutter",
|
||||
"halkyon_train_icon.png",
|
||||
"bike",
|
||||
(COBALT, VIOLET, STRAWBERRY, CARROT),
|
||||
),
|
||||
}
|
||||
|
||||
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 icon(glyph: str, stops: tuple[str, ...], svg: str) -> Image.Image:
|
||||
"""[glyph] on white in the gradient of [stops], SIZE pixels square."""
|
||||
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.")
|
||||
# The strokes alone, as a mask the gradient shows through.
|
||||
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}")
|
||||
|
||||
tile = cairo.ImageSurface(cairo.FORMAT_RGB24, SIZE, SIZE)
|
||||
context = cairo.Context(tile)
|
||||
context.set_source_rgb(1, 1, 1)
|
||||
context.paint()
|
||||
gradient = cairo.LinearGradient(offset, offset, offset + box, offset + box)
|
||||
for i, colour in enumerate(stops):
|
||||
gradient.add_color_stop_rgb(i / (len(stops) - 1), *rgb(colour))
|
||||
context.set_source(gradient)
|
||||
context.mask_surface(mask)
|
||||
|
||||
png = BytesIO()
|
||||
tile.write_to_png(png)
|
||||
png.seek(0)
|
||||
return Image.open(png).convert("RGB")
|
||||
|
||||
|
||||
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
|
||||
source = icon(app.glyph, app.stops, fetch(f"icons/{app.glyph}.svg"))
|
||||
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")
|
||||
|
||||
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()
|
||||
Loading…
Reference in a new issue