214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
|
|
#!/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()
|