D10 returns every icon to D1's white tile, D8's dark one undone, the gradients as D5 gives them (cobalt holds about 5.7 : 1 against white). D9 adds the variants the systems colour by their theme, all from the same glyph at the same place, drawn by tool/app_icons.py and written into the projects by flutter_launcher_icons: Android's monochrome layer, which themed icons tint (Android 13 and later), and iOS's dark and tinted icons (iOS 18 and later), the dark one keeping D8's lift of 30 % towards white on iOS's own dark background. macOS needs an Icon Composer file made on a Mac and keeps the plain icon until then. Whats-New: The app's icon is white again, and takes the system's colours where Android or iOS themes its icons. Whats-New-De: Das App-Symbol ist wieder weiß und nimmt die Farben des Systems an, wo Android oder iOS seine Symbole einfärbt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
280 lines
11 KiB
Python
280 lines
11 KiB
Python
#!/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
|
|
|
|
|
|
# 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",
|
|
),
|
|
"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()
|