From 27f44cad4b716754f88dac0943e8e657ec184c59 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 19 Jul 2026 16:55:48 +0200 Subject: [PATCH] Add shared launcher-icon generator for the Flutter apps Every Flutter app still shipped the stock Flutter "beach" launcher icon, and none had an adaptive-icon descriptor at all. Rather than hand-drawing five one-off icons, define the family once and let each app contribute only an accent colour and a glyph. The family: a charcoal #1B1D21 field carrying one glyph in that app's own theme seed colour, at a single stroke weight. Glyphs are optically centred automatically - render.centre_offset rasterises a glyph alone, measures its ink box, and translates (never scales) the fragment, so a row of icons lines up while every icon keeps the exact family stroke weight. safe_box_overflow warns when a glyph exceeds the 560px adaptive-icon safe box; it caught three real overflows that a launcher mask would have clipped. The registry points dufs_client at ~/dufs-cloud/app: ~/dufs_client was a stale, remote-less checkout with an identical package name, since deleted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app-icons-generator-2026-07-19.json | 18 ++ .../app-icons-generator-2026-07-19.json | 53 +++++ python_pkg/app_icons/README.md | 67 ++++++ python_pkg/app_icons/__init__.py | 10 + python_pkg/app_icons/__main__.py | 14 ++ python_pkg/app_icons/apps.py | 111 ++++++++++ python_pkg/app_icons/cli.py | 191 ++++++++++++++++ python_pkg/app_icons/generate.py | 102 +++++++++ python_pkg/app_icons/glyphs.py | 112 ++++++++++ python_pkg/app_icons/preview.py | 158 ++++++++++++++ python_pkg/app_icons/render.py | 203 ++++++++++++++++++ python_pkg/app_icons/style.py | 39 ++++ python_pkg/app_icons/tests/__init__.py | 3 + python_pkg/app_icons/tests/test_cli.py | 107 +++++++++ python_pkg/app_icons/tests/test_generate.py | 89 ++++++++ python_pkg/app_icons/tests/test_main_entry.py | 34 +++ python_pkg/app_icons/tests/test_preview.py | 51 +++++ python_pkg/app_icons/tests/test_registry.py | 49 +++++ python_pkg/app_icons/tests/test_render.py | 109 ++++++++++ 19 files changed, 1520 insertions(+) create mode 100644 docs/superpowers/contracts/app-icons-generator-2026-07-19.json create mode 100644 docs/superpowers/evidence/app-icons-generator-2026-07-19.json create mode 100644 python_pkg/app_icons/README.md create mode 100644 python_pkg/app_icons/__init__.py create mode 100644 python_pkg/app_icons/__main__.py create mode 100644 python_pkg/app_icons/apps.py create mode 100644 python_pkg/app_icons/cli.py create mode 100644 python_pkg/app_icons/generate.py create mode 100644 python_pkg/app_icons/glyphs.py create mode 100644 python_pkg/app_icons/preview.py create mode 100644 python_pkg/app_icons/render.py create mode 100644 python_pkg/app_icons/style.py create mode 100644 python_pkg/app_icons/tests/__init__.py create mode 100644 python_pkg/app_icons/tests/test_cli.py create mode 100644 python_pkg/app_icons/tests/test_generate.py create mode 100644 python_pkg/app_icons/tests/test_main_entry.py create mode 100644 python_pkg/app_icons/tests/test_preview.py create mode 100644 python_pkg/app_icons/tests/test_registry.py create mode 100644 python_pkg/app_icons/tests/test_render.py diff --git a/docs/superpowers/contracts/app-icons-generator-2026-07-19.json b/docs/superpowers/contracts/app-icons-generator-2026-07-19.json new file mode 100644 index 00000000..39d89796 --- /dev/null +++ b/docs/superpowers/contracts/app-icons-generator-2026-07-19.json @@ -0,0 +1,18 @@ +{ + "title": "Shared launcher-icon generator for the Flutter apps", + "objective": "Replace the stock Flutter 'beach' launcher icon on every Flutter/Dart app with an icon from one consistent family, and make the family reproducible so future apps inherit it instead of drifting. The family is a charcoal #1B1D21 field carrying a single glyph in that app's own theme accent, drawn at one stroke weight and optically centred so a row of the icons lines up. Success means the phone launcher shows five distinct, on-style icons and a new app can be given a matching icon by adding one glyph plus one registry entry.", + "acceptance_criteria": [ + "python_pkg/app_icons exposes a CLI with list, preview and generate subcommands, runnable as PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons.", + "Glyphs are optically centred automatically: the renderer measures rendered ink and translates (never scales) the fragment, so stroke weight stays identical across the family.", + "generate warns when a glyph's ink exceeds the 560px adaptive-icon safe box, so no icon ships at risk of being clipped by a launcher mask.", + "Each app gets three layers - base icon, adaptive foreground, and Android 13 monochrome - as both SVG source and 1024px PNG.", + "The package passes ruff, mypy, pylint and bandit, and holds 100% line and branch coverage with all external tools mocked.", + "All five apps show their new icon in the phone app drawer, installed with adb install -r and with no app data wiped." + ], + "out_of_scope": [ + "The per-app repositories (dufs-cloud, screen-locker, wake-alarm, diet-guard, todo) - their generated assets are committed separately in their own repos.", + "iOS, web, Windows and macOS icon targets - none of these apps configure those platforms.", + "Changing any app's android:label or in-app theme colours; this is icons only." + ], + "verifier": "pre-commit run --files ; python3 -m pytest python_pkg/app_icons --cov=python_pkg.app_icons --cov-report=term-missing (100% required); plus on-device verification: adb install -r each app then screenshot the launcher." +} diff --git a/docs/superpowers/evidence/app-icons-generator-2026-07-19.json b/docs/superpowers/evidence/app-icons-generator-2026-07-19.json new file mode 100644 index 00000000..ff566b74 --- /dev/null +++ b/docs/superpowers/evidence/app-icons-generator-2026-07-19.json @@ -0,0 +1,53 @@ +{ + "intent": "Give every Flutter/Dart app a launcher icon from one shared family (charcoal field + the app's own theme accent as a glyph) instead of the stock Flutter 'beach' icon, and make future apps inherit that style automatically.", + "scope": [ + "python_pkg/app_icons/ (new package: style, glyphs, app registry, renderer, preview, CLI)", + "python_pkg/app_icons/tests/ (new test suite)", + "Non-goal: the per-app repositories themselves (dufs-cloud, screen-locker, wake-alarm, diet-guard, todo) are committed separately", + "Non-goal: iOS/web/Windows/macOS targets - none are configured on these apps" + ], + "changes": [ + "style.py defines the family once (canvas 1024, charcoal #1B1D21, stroke 72, 560px adaptive safe box); every app contributes only an accent colour and a glyph.", + "render.centre_offset rasterises a glyph alone, measures its ink bounding box with ImageMagick, and translates (never scales) the fragment so all icons share one optical centre - this is what makes a row of icons line up.", + "render.safe_box_overflow warns when a glyph exceeds the adaptive-icon safe box; it caught three real overflows (cloud 646px tall, shield, checklist) that a launcher mask would have clipped.", + "generate.py writes three layers per app (icon, adaptive foreground, Android 13 monochrome) as SVG plus 1024px PNG, then drives flutter_launcher_icons for the Android mipmaps and mipmap-anydpi-v26 descriptor.", + "apps.py registry points dufs_client at ~/dufs-cloud/app: ~/dufs_client was a stale, remote-less checkout with the same package name that has since been deleted." + ], + "verification": [ + { + "command": "ruff check python_pkg/app_icons", + "result": "pass", + "evidence": "All checks passed!" + }, + { + "command": "mypy (pre-commit hook flags) python_pkg/app_icons", + "result": "pass", + "evidence": "exit 0, no diagnostics" + }, + { + "command": "pylint python_pkg/app_icons", + "result": "pass", + "evidence": "Your code has been rated at 10.00/10" + }, + { + "command": "python3 -m pytest python_pkg/app_icons --cov=python_pkg.app_icons --cov-report=term-missing", + "result": "pass", + "evidence": "39 passed; every app_icons module at 100% line and branch coverage. External tools (rsvg-convert, magick, dart) are mocked, so the suite needs none installed." + }, + { + "command": "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --android (all 5 apps) then adb install -r + launcher screenshot", + "result": "pass", + "evidence": "All five apps show their new icon in the phone app drawer; no stock icon remains. Each install reported plain 'Success' with no 'Uninstalling old version' line, so no app data was wiped." + } + ], + "risks": [ + "Editing style.py restyles every app at once - that is intended, but a careless change silently affects all five.", + "Glyph negative space below half the stroke weight merges into a blob at launcher size; the fork hit this and had to be redrawn as a filled fork-and-knife silhouette.", + "The generator writes into sibling repositories by absolute path, so a moved or renamed repo makes generate fail (or, worse, target a stale checkout - exactly what happened with dufs_client)." + ], + "rollback": [ + "git rm -r python_pkg/app_icons and revert this evidence file; nothing else in testsAndMisc depends on the package.", + "Per app repository, git checkout the mipmap PNGs and pubspec.yaml, and delete assets/icon plus the generated drawable-*/mipmap-anydpi-v26 directories.", + "After rollback, rebuild and adb install -r each app and confirm the launcher shows the previous icon." + ] +} diff --git a/python_pkg/app_icons/README.md b/python_pkg/app_icons/README.md new file mode 100644 index 00000000..61acd889 --- /dev/null +++ b/python_pkg/app_icons/README.md @@ -0,0 +1,67 @@ +# app_icons — shared launcher icons for the Flutter apps + +One icon family across every Flutter/Dart app: a charcoal `#1B1D21` field with +a single glyph in that app's own theme accent, drawn at one stroke weight and +optically centred so a row of them lines up. + +## Commands + +```bash +# what is registered +PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons list + +# contact sheet for visual review: full size / circle-masked / 48dp +PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons preview -o /tmp/sheet.png + +# write assets into every app repo and rebuild the Android mipmaps +PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --android + +# one app, plus the hicolor PNGs for a Linux desktop target +PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons \ + generate --app todo --android --linux-out ~/todo/linux/icons +``` + +## What gets written + +Per app, into `/assets/icon/`: + +| Layer | Purpose | +| --- | --- | +| `icon` | Charcoal field + glyph. Legacy square launcher icon and Linux icon. | +| `icon_foreground` | Glyph only, transparent. Adaptive-icon foreground layer. | +| `icon_monochrome` | Glyph only, white. Android 13+ themed icons. | + +Both an `.svg` source and a 1024px `.png` are written for each, so a build +never needs `rsvg-convert`. `flutter_launcher_icons` then turns the PNGs into +the density buckets and `mipmap-anydpi-v26/ic_launcher.xml`. + +## Design constraints that bite + +- **Negative space ≥ half the stroke weight.** Strokes closer than that merge + into a solid blob at launcher size. A fork whose tines sat exactly one stroke + width apart rendered as a tulip. +- **Ink must stay inside the 560px safe box.** Android masks adaptive icons to + roughly the inner 66% of the canvas. `generate` warns when a glyph overflows. +- **Stroked outlines can read wrong.** A stroked three-tine fork reads as a + trident; a filled fork-and-knife pair reads as cutlery. Prefer a filled + silhouette when the outline is ambiguous. +- Glyph positioning inside the fragment is irrelevant — `render.centre_offset` + measures the rendered ink and recentres it. Only the extents matter. + +## Adding an app + +See the `app-icon` skill (`~/.claude/skills/app-icon/SKILL.md`) for the full +procedure. In short: add a glyph to `glyphs.py`, register the app in +`apps.py`, wire `flutter_launcher_icons` into its `pubspec.yaml`, generate, +review the contact sheet, then verify on the phone. + +## Tests + +```bash +cd ~/testsAndMisc +python3 -m pytest python_pkg/app_icons --cov=python_pkg.app_icons \ + --cov-report=term-missing +``` + +External tools (`rsvg-convert`, `magick`, `dart`) are mocked; the suite needs +none of them installed. diff --git a/python_pkg/app_icons/__init__.py b/python_pkg/app_icons/__init__.py new file mode 100644 index 00000000..db4344d7 --- /dev/null +++ b/python_pkg/app_icons/__init__.py @@ -0,0 +1,10 @@ +"""Shared launcher-icon generator for kuhy's Flutter/Dart apps. + +Every app icon in the family is built from the same template: a charcoal +rounded field with a single accent-coloured glyph, optically centred on the +canvas so a row of icons lines up. See :mod:`python_pkg.app_icons.style` for +the numbers that define the family and :mod:`python_pkg.app_icons.glyphs` for +the per-app artwork. +""" + +from __future__ import annotations diff --git a/python_pkg/app_icons/__main__.py b/python_pkg/app_icons/__main__.py new file mode 100644 index 00000000..683518e8 --- /dev/null +++ b/python_pkg/app_icons/__main__.py @@ -0,0 +1,14 @@ +"""Module entry point for the shared app-icon generator. + +Usage: + PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons --help +""" + +from __future__ import annotations + +import sys + +from python_pkg.app_icons.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python_pkg/app_icons/apps.py b/python_pkg/app_icons/apps.py new file mode 100644 index 00000000..4aef3dbb --- /dev/null +++ b/python_pkg/app_icons/apps.py @@ -0,0 +1,111 @@ +"""Registry of the Flutter apps that use the shared icon family. + +Adding a new app here (plus a glyph in :mod:`python_pkg.app_icons.glyphs`) is +all that is needed for it to get an on-style icon. The accent is deliberately +taken from each app's own theme seed so the launcher icon matches the app's +first screen. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Final + + +@dataclass(frozen=True) +class AppIcon: + """One app's icon configuration. + + Parameters: + key (str): Short identifier used on the command line. + repo (Path): Flutter project root, i.e. the directory holding pubspec.yaml. + accent (str): Hex accent colour, matching the app's theme seed. + glyph (str): Glyph name from :data:`~python_pkg.app_icons.glyphs.GLYPHS`. + icon_name (str): Basename for Linux hicolor/desktop installation. + linux (bool): Whether the app has a Linux desktop target. + """ + + key: str + repo: Path + accent: str + glyph: str + icon_name: str + linux: bool + + @property + def asset_dir(self) -> Path: + """Return the directory holding this app's icon source assets.""" + return self.repo / "assets" / "icon" + + +_HOME = Path.home() + +APPS: Final[dict[str, AppIcon]] = { + app.key: app + for app in ( + AppIcon( + key="dufs_client", + # The live app: ~/dufs_client is a stale, remote-less checkout that + # was superseded when dufs-cloud absorbed it (see testsAndMisc + # CLAUDE.md). Same package name, so it is easy to icon the wrong one. + repo=_HOME / "dufs-cloud" / "app", + accent="#5C6BC0", + glyph="cloud-down", + icon_name="dufs-client", + linux=False, + ), + AppIcon( + key="workout_app", + repo=_HOME / "screen-locker" / "stronglift_replacement" / "workout_app", + accent="#FF4444", + glyph="barbell", + icon_name="workout-app", + linux=False, + ), + AppIcon( + key="wake_alarm_sync", + repo=_HOME / "wake-alarm" / "phone_app", + accent="#FFA726", + glyph="clock", + icon_name="wake-alarm-sync", + linux=False, + ), + AppIcon( + key="diet_guard_app", + repo=_HOME / "diet-guard" / "app", + accent="#26A69A", + glyph="shield-cutlery", + icon_name="diet-guard-app", + linux=True, + ), + AppIcon( + key="todo", + repo=_HOME / "todo", + accent="#7E57C2", + glyph="checklist", + icon_name="todo", + linux=True, + ), + ) +} + + +def get_app(key: str) -> AppIcon: + """Look up an app by key. + + Parameters: + key (str): App identifier, e.g. ``"todo"``. + + Returns: + AppIcon: The matching configuration. + + Raises: + KeyError: If no app with that key is registered. + """ + try: + return APPS[key] + except KeyError: + available = ", ".join(sorted(APPS)) + msg = f"unknown app {key!r}; available: {available}" + raise KeyError(msg) from None diff --git a/python_pkg/app_icons/cli.py b/python_pkg/app_icons/cli.py new file mode 100644 index 00000000..2cf66e31 --- /dev/null +++ b/python_pkg/app_icons/cli.py @@ -0,0 +1,191 @@ +"""Command-line entry point for the shared app-icon generator. + +Usage: + PYTHONPATH=~/testsAndMisc python -m python_pkg.app_icons list + PYTHONPATH=~/testsAndMisc python -m python_pkg.app_icons preview -o sheet.png + PYTHONPATH=~/testsAndMisc python -m python_pkg.app_icons generate --app todo +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from python_pkg.app_icons import apps, generate, glyphs, preview, render, style + + +def _emit(message: str) -> None: + """Write a line to stdout. + + Parameters: + message (str): Line to write, without a trailing newline. + """ + sys.stdout.write(f"{message}\n") + + +def _add_app_argument(parser: argparse.ArgumentParser) -> None: + """Attach the repeatable ``--app`` selector to a subparser. + + Parameters: + parser (argparse.ArgumentParser): Subparser to extend. + """ + parser.add_argument( + "--app", + action="append", + dest="app_keys", + choices=sorted(apps.APPS), + help="app to act on; repeatable. Defaults to every registered app.", + ) + + +def build_parser() -> argparse.ArgumentParser: + """Construct the argument parser. + + Returns: + argparse.ArgumentParser: Parser with the ``list``, ``preview`` and + ``generate`` subcommands. + """ + parser = argparse.ArgumentParser( + prog="python -m python_pkg.app_icons", + description="Generate on-style launcher icons for kuhy's Flutter apps.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("list", help="show registered apps and available glyphs") + + preview_parser = subparsers.add_parser( + "preview", help="render a contact sheet for visual review" + ) + _add_app_argument(preview_parser) + preview_parser.add_argument( + "-o", + "--output", + type=Path, + default=Path("icon_review_sheet.png"), + help="where to write the contact sheet", + ) + + generate_parser = subparsers.add_parser( + "generate", help="write icon assets into the app repositories" + ) + _add_app_argument(generate_parser) + generate_parser.add_argument( + "--android", + action="store_true", + help="also run flutter_launcher_icons to rebuild the Android mipmaps", + ) + generate_parser.add_argument( + "--linux-out", + type=Path, + default=None, + help=( + "directory to write hicolor PNGs into for apps with a Linux target; " + "skipped when omitted" + ), + ) + return parser + + +def _selected(app_keys: list[str] | None) -> list[str]: + """Resolve the ``--app`` selection to a concrete ordered list. + + Parameters: + app_keys (list[str] | None): Keys given on the command line, if any. + + Returns: + list[str]: Keys to act on, in registry order. + """ + if app_keys: + return [key for key in apps.APPS if key in set(app_keys)] + return list(apps.APPS) + + +def _cmd_list() -> int: + """Print the app registry and glyph library. + + Returns: + int: Process exit status. + """ + _emit("Apps:") + for app in apps.APPS.values(): + linux = " +linux" if app.linux else "" + _emit(f" {app.key:16} {app.accent} {app.glyph}{linux} {app.repo}") + _emit("\nGlyphs:") + for glyph in glyphs.GLYPHS.values(): + _emit(f" {glyph.name:16} {glyph.description}") + return 0 + + +def _cmd_preview(app_keys: list[str] | None, output: Path) -> int: + """Render the review contact sheet. + + Parameters: + app_keys (list[str] | None): Selection from the command line. + output (Path): Where to write the sheet. + + Returns: + int: Process exit status. + """ + sheet = preview.build_contact_sheet(_selected(app_keys), output) + _emit(f"wrote {sheet}") + return 0 + + +def _cmd_generate( + app_keys: list[str] | None, + *, + android: bool, + linux_out: Path | None, +) -> int: + """Write icon assets, optionally driving the platform generators. + + Parameters: + app_keys (list[str] | None): Selection from the command line. + android (bool): Run flutter_launcher_icons afterwards. + linux_out (Path | None): Directory for hicolor PNGs, or None to skip. + + Returns: + int: Process exit status. + """ + for key in _selected(app_keys): + app = apps.get_app(key) + written = generate.write_assets(app) + _emit(f"{app.key}: wrote {len(written)} asset files to {app.asset_dir}") + + overflow = render.safe_box_overflow(glyphs.get_glyph(app.glyph), app.accent) + if any(overflow): + _emit( + f" warning: glyph exceeds the {style.SAFE_BOX}px safe box " + f"by {overflow[0]}x{overflow[1]}px; a launcher mask may clip it" + ) + + if linux_out is not None and app.linux: + icons = generate.write_linux_icons(app, linux_out) + _emit(f" wrote {len(icons)} hicolor PNGs to {linux_out}") + + if android: + generate.run_flutter_launcher_icons(app) + _emit(" regenerated Android mipmaps") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Run the command-line interface. + + Parameters: + argv (list[str] | None): Arguments to parse; defaults to ``sys.argv[1:]``. + + Returns: + int: Process exit status. + """ + args = build_parser().parse_args(argv) + if args.command == "list": + return _cmd_list() + if args.command == "preview": + return _cmd_preview(args.app_keys, args.output) + return _cmd_generate( + args.app_keys, + android=args.android, + linux_out=args.linux_out, + ) diff --git a/python_pkg/app_icons/generate.py b/python_pkg/app_icons/generate.py new file mode 100644 index 00000000..7f0978d4 --- /dev/null +++ b/python_pkg/app_icons/generate.py @@ -0,0 +1,102 @@ +"""Write an app's icon assets and drive flutter_launcher_icons. + +Three layers are produced per app: + +``icon`` + Charcoal field plus accent glyph. Used as the legacy square launcher icon, + as the Linux desktop icon, and as the master for previews. +``icon_foreground`` + Glyph only, transparent. The adaptive-icon foreground layer; the background + layer is the flat charcoal declared in pubspec.yaml. +``icon_monochrome`` + Glyph only in white. Android 13+ recolours this for themed icons. + +Both the SVG source and a 1024px PNG are written for each layer so a build never +needs rsvg-convert installed. +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING, Final + +from python_pkg.app_icons import glyphs, render, style + +if TYPE_CHECKING: + from pathlib import Path + + from python_pkg.app_icons.apps import AppIcon + +# (filename stem, draw the charcoal field, colour override) +_LAYERS: Final[tuple[tuple[str, bool, str | None], ...]] = ( + ("icon", True, None), + ("icon_foreground", False, None), + ("icon_monochrome", False, style.MONOCHROME), +) + + +def write_assets(app: AppIcon) -> list[Path]: + """Write every icon layer for one app as SVG plus 1024px PNG. + + Parameters: + app (AppIcon): App to generate assets for. + + Returns: + list[Path]: Every file written, in creation order. + """ + glyph = glyphs.get_glyph(app.glyph) + offset = render.centre_offset(glyph, app.accent) + written: list[Path] = [] + for stem, with_background, colour in _LAYERS: + svg_text = render.build_svg( + glyph, + colour or app.accent, + with_background=with_background, + offset=offset, + ) + svg_path = app.asset_dir / f"{stem}.svg" + svg_path.parent.mkdir(parents=True, exist_ok=True) + svg_path.write_text(svg_text, encoding="utf-8") + png_path = app.asset_dir / f"{stem}.png" + render.rasterise(svg_text, png_path, style.CANVAS) + written.extend((svg_path, png_path)) + return written + + +def write_linux_icons(app: AppIcon, destination: Path) -> list[Path]: + """Render the hicolor icon-theme PNGs for a Linux desktop target. + + Parameters: + app (AppIcon): App to generate icons for; must have ``linux`` set. + destination (Path): Directory to write ``/.png`` into. + + Returns: + list[Path]: Every PNG written. + """ + glyph = glyphs.get_glyph(app.glyph) + offset = render.centre_offset(glyph, app.accent) + svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset) + written: list[Path] = [] + for size in style.LINUX_ICON_SIZES: + png_path = destination / str(size) / f"{app.icon_name}.png" + render.rasterise(svg_text, png_path, size) + written.append(png_path) + return written + + +def run_flutter_launcher_icons(app: AppIcon) -> None: + """Generate the Android mipmap set from the written assets. + + flutter_launcher_icons owns the error-prone part: five density buckets, the + round variant, and the ``mipmap-anydpi-v26/ic_launcher.xml`` adaptive + descriptor that none of these repos had before. + + Parameters: + app (AppIcon): App whose Flutter project should be processed. + """ + subprocess.run( + [render.resolve_tool("dart"), "run", "flutter_launcher_icons"], + cwd=app.repo, + check=True, + capture_output=True, + ) diff --git a/python_pkg/app_icons/glyphs.py b/python_pkg/app_icons/glyphs.py new file mode 100644 index 00000000..4be2dcb4 --- /dev/null +++ b/python_pkg/app_icons/glyphs.py @@ -0,0 +1,112 @@ +"""Per-app glyph artwork for the shared icon family. + +A glyph is a raw SVG fragment drawn in the 1024x1024 master canvas. It does not +need to be perfectly positioned: :mod:`python_pkg.app_icons.render` measures the +rendered ink and recentres the fragment, so every icon in the family sits at the +same optical height. + +Fragments inherit ``fill="none"``, ``stroke``, ``stroke-width`` and round caps +from the wrapping group. A fragment that needs a filled silhouette instead +overrides those locally and uses :data:`~python_pkg.app_icons.style.ACCENT_MARKER` +for its colour. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True) +class Glyph: + """One app's artwork. + + Parameters: + name (str): Stable identifier, used as the ``--glyph`` CLI value. + description (str): What the glyph depicts, for ``--list`` output. + body (str): SVG fragment drawn in the 1024x1024 master canvas. + """ + + name: str + description: str + body: str + + +# A cloud silhouette outline with a download arrow underneath: a client that +# pulls files off a remote dufs server. +_CLOUD_DOWN = """\ + + + + + """ + +# Bar, two plates and two collars. Plate spacing is well above the minimum +# negative space so the bar does not fill in at launcher size. +_BARBELL = """\ + + + + + """ + +# Plain clock face. Deliberately no bell "ears": at launcher size they read as +# noise rather than as an alarm clock. +_CLOCK = """\ + + + """ + +# Shield outline guarding filled cutlery. The fork is paired with a knife on +# purpose: a lone three-tine fork reads as a trident at small sizes. +_SHIELD_CUTLERY = """\ + + + + + """ + +# Two ticked-off list rows. More specific to a notes/todo app than a lone +# checkmark, and still legible at 48dp. +_CHECKLIST = """\ + + + + """ + + +GLYPHS: Final[dict[str, Glyph]] = { + glyph.name: glyph + for glyph in ( + Glyph("cloud-down", "Cloud with a download arrow", _CLOUD_DOWN), + Glyph("barbell", "Barbell with plates and collars", _BARBELL), + Glyph("clock", "Clock face with hands", _CLOCK), + Glyph("shield-cutlery", "Shield guarding a fork and knife", _SHIELD_CUTLERY), + Glyph("checklist", "Two ticked-off list rows", _CHECKLIST), + ) +} + + +def get_glyph(name: str) -> Glyph: + """Look up a glyph by name. + + Parameters: + name (str): Glyph identifier, e.g. ``"barbell"``. + + Returns: + Glyph: The matching glyph. + + Raises: + KeyError: If no glyph with that name exists. The message lists the + available names, since this is usually a CLI typo. + """ + try: + return GLYPHS[name] + except KeyError: + available = ", ".join(sorted(GLYPHS)) + msg = f"unknown glyph {name!r}; available: {available}" + raise KeyError(msg) from None diff --git a/python_pkg/app_icons/preview.py b/python_pkg/app_icons/preview.py new file mode 100644 index 00000000..aa3aa898 --- /dev/null +++ b/python_pkg/app_icons/preview.py @@ -0,0 +1,158 @@ +"""Build a contact sheet of the whole icon family for eyeballing. + +Three rows are produced: full size, circle-masked (what a round launcher mask +does to the art), and a 48dp downscale (legibility at real home-screen size). +Reviewing all three together is the cheapest way to catch a glyph that only +works at poster size. +""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import tempfile + +from python_pkg.app_icons import apps, glyphs, render, style + +# Size each icon is drawn at in the contact sheet. +_TILE = 512 +# Roughly a 48dp launcher icon on a high-density screen, then blown back up +# with nearest-neighbour so the pixel grid stays visible. +_SMALL = 96 +_SMALL_ZOOM = 200 + + +def _row(tiles: list[Path], destination: Path) -> None: + """Montage a list of PNGs into a single horizontal strip. + + Parameters: + tiles (list[Path]): Images to place, left to right. + destination (Path): PNG path to write. + """ + subprocess.run( + [ + render.resolve_tool("magick"), + "montage", + *[str(tile) for tile in tiles], + "-tile", + f"{len(tiles)}x1", + "-geometry", + "+14+14", + "-background", + "#3a3a3a", + str(destination), + ], + check=True, + capture_output=True, + ) + + +def _circle_mask(source: Path, destination: Path) -> None: + """Apply a round launcher mask to an icon. + + Parameters: + source (Path): Full-size icon PNG. + destination (Path): PNG path to write. + """ + radius = _TILE // 2 + subprocess.run( + [ + render.resolve_tool("magick"), + str(source), + "(", + "-size", + f"{_TILE}x{_TILE}", + "xc:none", + "-fill", + "white", + "-draw", + f"circle {radius},{radius} {radius},0", + ")", + "-alpha", + "set", + "-compose", + "DstIn", + "-composite", + str(destination), + ], + check=True, + capture_output=True, + ) + + +def _downscale(source: Path, destination: Path) -> None: + """Shrink an icon to launcher size and blow it back up for inspection. + + Parameters: + source (Path): Full-size icon PNG. + destination (Path): PNG path to write. + """ + subprocess.run( + [ + render.resolve_tool("magick"), + str(source), + "-resize", + f"{_SMALL}x{_SMALL}", + "-filter", + "point", + "-resize", + f"{_SMALL_ZOOM}x{_SMALL_ZOOM}", + str(destination), + ], + check=True, + capture_output=True, + ) + + +def build_contact_sheet(keys: list[str], destination: Path) -> Path: + """Render a three-row contact sheet for the given apps. + + Parameters: + keys (list[str]): App keys to include, in display order. + destination (Path): PNG path to write the sheet to. + + Returns: + Path: The written sheet, for convenience. + """ + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + full: list[Path] = [] + masked: list[Path] = [] + small: list[Path] = [] + for key in keys: + app = apps.get_app(key) + glyph = glyphs.get_glyph(app.glyph) + offset = render.centre_offset(glyph, app.accent) + svg_text = render.build_svg( + glyph, app.accent, with_background=True, offset=offset + ) + tile = work / f"{key}.png" + render.rasterise(svg_text, tile, _TILE) + full.append(tile) + masked_tile = work / f"{key}_masked.png" + _circle_mask(tile, masked_tile) + masked.append(masked_tile) + small_tile = work / f"{key}_small.png" + _downscale(tile, small_tile) + small.append(small_tile) + + rows = [work / "row_full.png", work / "row_masked.png", work / "row_small.png"] + for tiles, row_path in zip((full, masked, small), rows, strict=True): + _row(tiles, row_path) + + destination.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + render.resolve_tool("magick"), + *[str(row) for row in rows], + "-background", + style.BACKGROUND, + "-gravity", + "center", + "-append", + str(destination), + ], + check=True, + capture_output=True, + ) + return destination diff --git a/python_pkg/app_icons/render.py b/python_pkg/app_icons/render.py new file mode 100644 index 00000000..9d48025a --- /dev/null +++ b/python_pkg/app_icons/render.py @@ -0,0 +1,203 @@ +"""SVG assembly and rasterisation for the shared icon family. + +The interesting part here is :func:`centre_offset`. Glyphs are hand-authored, so +their ink never lands exactly on the canvas centre; a barbell is wide and short, +a cloud sits high. Rather than nudging coordinates by hand, the glyph is +rendered once on its own, its ink bounding box is measured, and the whole +fragment is translated so that box is centred. That is what makes a row of +icons line up at the same optical height. + +Only the fragment is translated -- never scaled -- so every icon keeps exactly +the family stroke weight. +""" + +from __future__ import annotations + +from pathlib import Path +import shutil +import subprocess +import tempfile +from typing import TYPE_CHECKING + +from python_pkg.app_icons import style + +if TYPE_CHECKING: + from python_pkg.app_icons.glyphs import Glyph + + +class ToolMissingError(RuntimeError): + """Raised when a required external rasterisation tool is not installed.""" + + def __init__(self, tool: str) -> None: + """Record which tool was missing. + + Parameters: + tool (str): Executable name that could not be found on PATH. + """ + super().__init__(f"required tool {tool!r} is not installed") + self.tool = tool + + +class EmptyGlyphError(ValueError): + """Raised when a glyph renders to nothing, so it cannot be centred.""" + + def __init__(self, name: str) -> None: + """Record which glyph produced no ink. + + Parameters: + name (str): Glyph identifier that rendered empty. + """ + super().__init__(f"glyph {name!r} rendered no visible ink") + self.name = name + + +def resolve_tool(name: str) -> str: + """Resolve an external tool to an absolute path. + + Parameters: + name (str): Executable name, e.g. ``"rsvg-convert"``. + + Returns: + str: Absolute path to the executable. + + Raises: + ToolMissingError: If the executable is not on PATH. + """ + resolved = shutil.which(name) + if resolved is None: + raise ToolMissingError(name) + return resolved + + +def build_svg( + glyph: Glyph, + accent: str, + *, + with_background: bool, + offset: tuple[float, float] = (0.0, 0.0), +) -> str: + """Assemble a complete SVG document for one icon layer. + + Parameters: + glyph (Glyph): Artwork to draw. + accent (str): Hex colour for the glyph, e.g. ``"#26A69A"``. + with_background (bool): Draw the shared charcoal field behind the glyph. + False produces the transparent adaptive-icon layers. + offset (tuple[float, float]): ``(dx, dy)`` translation applied to the + fragment, normally the result of :func:`centre_offset`. + + Returns: + str: A standalone SVG document. + """ + body = glyph.body.replace(style.ACCENT_MARKER, accent) + dx, dy = offset + field = ( + f'\n ' + if with_background + else "" + ) + return ( + f'' + f"{field}\n" + f' \n' + f"{body}\n" + f" \n" + f"\n" + ) + + +def rasterise(svg_text: str, destination: Path, size: int) -> None: + """Render an SVG document to a square PNG. + + Parameters: + svg_text (str): Complete SVG document. + destination (Path): PNG path to write; parent directories are created. + size (int): Output width and height in pixels. + """ + destination.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + resolve_tool("rsvg-convert"), + "-w", + str(size), + "-h", + str(size), + "-o", + str(destination), + ], + input=svg_text.encode(), + check=True, + capture_output=True, + ) + + +def _ink_bbox(png: Path) -> tuple[int, int, int, int]: + """Measure the non-transparent bounding box of a PNG. + + Parameters: + png (Path): Image to measure. + + Returns: + tuple[int, int, int, int]: ``(width, height, x, y)`` of the ink box. + """ + result = subprocess.run( + [resolve_tool("magick"), str(png), "-trim", "-format", "%w %h %X %Y", "info:"], + check=True, + capture_output=True, + text=True, + ) + width, height, x, y = result.stdout.split() + return int(width), int(height), int(x), int(y) + + +def centre_offset(glyph: Glyph, accent: str) -> tuple[float, float]: + """Compute the translation that optically centres a glyph. + + The glyph is rendered on a transparent canvas at full resolution, its ink + box is measured, and the offset from that box's centre to the canvas centre + is returned. + + Parameters: + glyph (Glyph): Artwork to measure. + accent (str): Hex colour to render with. Colour does not affect the box, + but rsvg-convert needs a concrete value. + + Returns: + tuple[float, float]: ``(dx, dy)`` to pass to :func:`build_svg`. + + Raises: + EmptyGlyphError: If the glyph renders no visible ink. + """ + probe = build_svg(glyph, accent, with_background=False) + with tempfile.TemporaryDirectory() as tmp: + png = Path(tmp) / "probe.png" + rasterise(probe, png, style.CANVAS) + width, height, x, y = _ink_bbox(png) + if width == 0 or height == 0: + raise EmptyGlyphError(glyph.name) + return style.CENTRE - (x + width / 2), style.CENTRE - (y + height / 2) + + +def safe_box_overflow(glyph: Glyph, accent: str) -> tuple[int, int]: + """Measure how far a centred glyph exceeds the adaptive-icon safe box. + + Anything above zero risks being clipped by a launcher mask. + + Parameters: + glyph (Glyph): Artwork to check. + accent (str): Hex colour to render with. + + Returns: + tuple[int, int]: Horizontal and vertical overflow in pixels, clamped at + zero. + """ + probe = build_svg(glyph, accent, with_background=False) + with tempfile.TemporaryDirectory() as tmp: + png = Path(tmp) / "probe.png" + rasterise(probe, png, style.CANVAS) + width, height, _x, _y = _ink_bbox(png) + return max(0, width - style.SAFE_BOX), max(0, height - style.SAFE_BOX) diff --git a/python_pkg/app_icons/style.py b/python_pkg/app_icons/style.py new file mode 100644 index 00000000..e2d314ee --- /dev/null +++ b/python_pkg/app_icons/style.py @@ -0,0 +1,39 @@ +"""The numbers that define the shared icon family. + +Changing anything here changes *every* app's icon, which is the point: the +family is defined once and each app only contributes an accent colour and a +glyph. Regenerate all apps after editing this module. +""" + +from __future__ import annotations + +from typing import Final + +# Master canvas. 1024 is what flutter_launcher_icons wants as a source image. +CANVAS: Final[int] = 1024 +CENTRE: Final[float] = CANVAS / 2 + +# Charcoal field shared by all icons. Also used as adaptive_icon_background so +# the adaptive foreground and background layers stay seamless. +BACKGROUND: Final[str] = "#1B1D21" + +# Android masks an adaptive icon down to roughly the inner 66% of the canvas. +# Keeping every glyph inside this box means no launcher mask (circle, squircle, +# teardrop, ...) can clip it. +SAFE_BOX: Final[int] = 560 + +# Single stroke weight for the whole family. Glyph negative space must stay at +# or above STROKE_WIDTH / 2, otherwise adjacent strokes merge into a blob when +# the icon is scaled down to launcher size. +STROKE_WIDTH: Final[int] = 72 +MIN_NEGATIVE_SPACE: Final[float] = STROKE_WIDTH / 2 + +# Colour used for the Android 13 themed ("monochrome") layer, which the system +# recolours itself, so the glyph must be drawn in flat white. +MONOCHROME: Final[str] = "#FFFFFF" + +# Placeholder replaced with an app's accent colour inside a glyph fragment. +ACCENT_MARKER: Final[str] = "{{ACCENT}}" + +# Sizes installed into the hicolor icon theme for Linux desktop targets. +LINUX_ICON_SIZES: Final[tuple[int, ...]] = (16, 24, 32, 48, 64, 128, 256, 512) diff --git a/python_pkg/app_icons/tests/__init__.py b/python_pkg/app_icons/tests/__init__.py new file mode 100644 index 00000000..2f769271 --- /dev/null +++ b/python_pkg/app_icons/tests/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the shared app-icon generator.""" + +from __future__ import annotations diff --git a/python_pkg/app_icons/tests/test_cli.py b/python_pkg/app_icons/tests/test_cli.py new file mode 100644 index 00000000..e3d63fdb --- /dev/null +++ b/python_pkg/app_icons/tests/test_cli.py @@ -0,0 +1,107 @@ +"""Tests for app_icons.cli.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.app_icons import apps, cli + +MOD = "python_pkg.app_icons.cli" + + +class TestSelected: + def test_defaults_to_every_app(self) -> None: + assert cli._selected(None) == list(apps.APPS) + + def test_keeps_registry_order(self) -> None: + assert cli._selected(["todo", "dufs_client"]) == ["dufs_client", "todo"] + + +class TestListCommand: + def test_prints_apps_and_glyphs(self, capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["list"]) == 0 + out = capsys.readouterr().out + assert "todo" in out + assert "checklist" in out + + +class TestPreviewCommand: + @patch(f"{MOD}.preview.build_contact_sheet") + def test_passes_output_path( + self, sheet: MagicMock, capsys: pytest.CaptureFixture[str], tmp_path: Path + ) -> None: + target = tmp_path / "sheet.png" + sheet.return_value = target + assert cli.main(["preview", "--app", "todo", "-o", str(target)]) == 0 + assert sheet.call_args.args == (["todo"], target) + assert str(target) in capsys.readouterr().out + + +class TestGenerateCommand: + @patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0)) + @patch(f"{MOD}.generate.write_assets", return_value=[Path("a"), Path("b")]) + def test_writes_assets_only_by_default( + self, + write: MagicMock, + mock_overflow: MagicMock, + capsys: pytest.CaptureFixture[str], + ) -> None: + assert cli.main(["generate", "--app", "todo"]) == 0 + assert write.call_count == 1 + assert "wrote 2 asset files" in capsys.readouterr().out + + @patch(f"{MOD}.render.safe_box_overflow", return_value=(12, 0)) + @patch(f"{MOD}.generate.write_assets", return_value=[]) + def test_warns_on_safe_box_overflow( + self, + mock_write: MagicMock, + mock_overflow: MagicMock, + capsys: pytest.CaptureFixture[str], + ) -> None: + cli.main(["generate", "--app", "todo"]) + assert "safe box" in capsys.readouterr().out + + @patch(f"{MOD}.generate.run_flutter_launcher_icons") + @patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0)) + @patch(f"{MOD}.generate.write_assets", return_value=[]) + def test_android_flag_runs_generator( + self, + mock_write: MagicMock, + mock_overflow: MagicMock, + launcher: MagicMock, + ) -> None: + cli.main(["generate", "--app", "todo", "--android"]) + assert launcher.call_count == 1 + + @patch(f"{MOD}.generate.write_linux_icons", return_value=[Path("x")]) + @patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0)) + @patch(f"{MOD}.generate.write_assets", return_value=[]) + def test_linux_out_only_for_linux_apps( + self, + mock_write: MagicMock, + mock_overflow: MagicMock, + linux: MagicMock, + tmp_path: Path, + ) -> None: + cli.main( + [ + "generate", + "--app", + "todo", + "--app", + "dufs_client", + "--linux-out", + str(tmp_path), + ] + ) + # The todo app has a Linux desktop target; dufs_client does not. + assert linux.call_count == 1 + + +class TestParser: + def test_requires_a_subcommand(self) -> None: + with pytest.raises(SystemExit): + cli.main([]) diff --git a/python_pkg/app_icons/tests/test_generate.py b/python_pkg/app_icons/tests/test_generate.py new file mode 100644 index 00000000..49fc1a1e --- /dev/null +++ b/python_pkg/app_icons/tests/test_generate.py @@ -0,0 +1,89 @@ +"""Tests for app_icons.generate.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.app_icons import generate, style +from python_pkg.app_icons.apps import AppIcon + +if TYPE_CHECKING: + from pathlib import Path + +MOD = "python_pkg.app_icons.generate" + + +def _app(tmp_path: Path, *, linux: bool = False) -> AppIcon: + return AppIcon( + key="demo", + repo=tmp_path / "demo", + accent="#26A69A", + glyph="barbell", + icon_name="demo", + linux=linux, + ) + + +class TestWriteAssets: + @patch(f"{MOD}.render.rasterise") + @patch(f"{MOD}.render.centre_offset", return_value=(1.0, 2.0)) + def test_writes_three_svg_png_pairs( + self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path + ) -> None: + written = generate.write_assets(_app(tmp_path)) + stems = sorted({path.stem for path in written}) + assert stems == ["icon", "icon_foreground", "icon_monochrome"] + assert len(written) == 6 + assert raster.call_count == 3 + + @patch(f"{MOD}.render.rasterise") + @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + def test_only_the_base_layer_has_the_field( + self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path + ) -> None: + app = _app(tmp_path) + generate.write_assets(app) + base = (app.asset_dir / "icon.svg").read_text(encoding="utf-8") + foreground = (app.asset_dir / "icon_foreground.svg").read_text(encoding="utf-8") + assert style.BACKGROUND in base + assert style.BACKGROUND not in foreground + + @patch(f"{MOD}.render.rasterise") + @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + def test_monochrome_layer_is_white( + self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path + ) -> None: + app = _app(tmp_path) + generate.write_assets(app) + mono = (app.asset_dir / "icon_monochrome.svg").read_text(encoding="utf-8") + assert f'stroke="{style.MONOCHROME}"' in mono + assert app.accent not in mono + + +class TestWriteLinuxIcons: + @patch(f"{MOD}.render.rasterise") + @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + def test_one_png_per_hicolor_size( + self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path + ) -> None: + written = generate.write_linux_icons(_app(tmp_path, linux=True), tmp_path / "h") + assert len(written) == len(style.LINUX_ICON_SIZES) + assert raster.call_count == len(style.LINUX_ICON_SIZES) + assert written[0] == tmp_path / "h" / "16" / "demo.png" + + +class TestRunFlutterLauncherIcons: + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/dart") + def test_invokes_dart_in_the_repo( + self, mock_tool: MagicMock, run: MagicMock, tmp_path: Path + ) -> None: + app = _app(tmp_path) + generate.run_flutter_launcher_icons(app) + assert run.call_args.args[0] == [ + "/usr/bin/dart", + "run", + "flutter_launcher_icons", + ] + assert run.call_args.kwargs["cwd"] == app.repo diff --git a/python_pkg/app_icons/tests/test_main_entry.py b/python_pkg/app_icons/tests/test_main_entry.py new file mode 100644 index 00000000..8796ffdf --- /dev/null +++ b/python_pkg/app_icons/tests/test_main_entry.py @@ -0,0 +1,34 @@ +"""Tests for app_icons.__main__ module.""" + +from __future__ import annotations + +import importlib +import runpy +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +class TestMain: + def test_main_called_when_run_as_module(self) -> None: + """python3 -m python_pkg.app_icons runs main() and exits with its status.""" + mock_main = MagicMock(return_value=0) + # runpy warns if __main__ is already imported, and the suite runs tests + # in a random order, so another test may have imported it first. + sys.modules.pop("python_pkg.app_icons.__main__", None) + with ( + patch("python_pkg.app_icons.cli.main", mock_main), + pytest.raises(SystemExit) as excinfo, + ): + runpy.run_module("python_pkg.app_icons", run_name="__main__") + assert excinfo.value.code == 0 + mock_main.assert_called_once() + + def test_main_not_called_on_plain_import(self) -> None: + """Importing __main__ must not generate icons as a side effect.""" + mock_main = MagicMock() + with patch("python_pkg.app_icons.cli.main", mock_main): + sys.modules.pop("python_pkg.app_icons.__main__", None) + importlib.import_module("python_pkg.app_icons.__main__") + mock_main.assert_not_called() diff --git a/python_pkg/app_icons/tests/test_preview.py b/python_pkg/app_icons/tests/test_preview.py new file mode 100644 index 00000000..f5a822b3 --- /dev/null +++ b/python_pkg/app_icons/tests/test_preview.py @@ -0,0 +1,51 @@ +"""Tests for app_icons.preview.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.app_icons import preview + +if TYPE_CHECKING: + from pathlib import Path + +MOD = "python_pkg.app_icons.preview" + + +class TestBuildContactSheet: + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.render.rasterise") + @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + @patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick") + def test_renders_one_tile_per_app_and_three_rows( + self, + mock_tool: MagicMock, + mock_offset: MagicMock, + raster: MagicMock, + run: MagicMock, + tmp_path: Path, + ) -> None: + destination = tmp_path / "out" / "sheet.png" + assert preview.build_contact_sheet(["todo", "workout_app"], destination) == ( + destination + ) + assert raster.call_count == 2 + # per app: circle mask + downscale; then 3 row montages + 1 append. + assert run.call_count == 2 * 2 + 3 + 1 + assert destination.parent.is_dir() + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.render.rasterise") + @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + @patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick") + def test_final_append_is_vertical( + self, + mock_tool: MagicMock, + mock_offset: MagicMock, + mock_raster: MagicMock, + run: MagicMock, + tmp_path: Path, + ) -> None: + preview.build_contact_sheet(["todo"], tmp_path / "sheet.png") + assert "-append" in run.call_args.args[0] diff --git a/python_pkg/app_icons/tests/test_registry.py b/python_pkg/app_icons/tests/test_registry.py new file mode 100644 index 00000000..21abe5c3 --- /dev/null +++ b/python_pkg/app_icons/tests/test_registry.py @@ -0,0 +1,49 @@ +"""Tests for the app registry, glyph library and family style invariants.""" + +from __future__ import annotations + +import pytest + +from python_pkg.app_icons import apps, glyphs, style + + +class TestGlyphLookup: + def test_known_glyph(self) -> None: + assert glyphs.get_glyph("barbell").name == "barbell" + + def test_unknown_glyph_lists_alternatives(self) -> None: + with pytest.raises(KeyError, match="barbell"): + glyphs.get_glyph("nope") + + +class TestAppLookup: + def test_known_app(self) -> None: + assert apps.get_app("todo").glyph == "checklist" + + def test_unknown_app_lists_alternatives(self) -> None: + with pytest.raises(KeyError, match="todo"): + apps.get_app("nope") + + def test_asset_dir_is_under_repo(self) -> None: + app = apps.get_app("todo") + assert app.asset_dir == app.repo / "assets" / "icon" + + +class TestFamilyInvariants: + def test_every_app_references_a_real_glyph(self) -> None: + for app in apps.APPS.values(): + assert app.glyph in glyphs.GLYPHS + + def test_accents_are_unique(self) -> None: + accents = [app.accent for app in apps.APPS.values()] + assert len(set(accents)) == len(accents) + + def test_accents_are_hex_triplets(self) -> None: + for app in apps.APPS.values(): + assert app.accent.startswith("#") + assert len(app.accent) == 7 + int(app.accent[1:], 16) + + def test_safe_box_fits_inside_canvas(self) -> None: + assert style.SAFE_BOX < style.CANVAS + assert style.MIN_NEGATIVE_SPACE == style.STROKE_WIDTH / 2 diff --git a/python_pkg/app_icons/tests/test_render.py b/python_pkg/app_icons/tests/test_render.py new file mode 100644 index 00000000..4c943d02 --- /dev/null +++ b/python_pkg/app_icons/tests/test_render.py @@ -0,0 +1,109 @@ +"""Tests for app_icons.render.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.app_icons import render, style +from python_pkg.app_icons.glyphs import Glyph + +MOD = "python_pkg.app_icons.render" + +DOT = Glyph("dot", "a dot", ' ') +FILLED = Glyph("filled", "a filled shape", ' ') + + +class TestResolveTool: + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/magick") + def test_found(self, mock_which: MagicMock) -> None: + assert render.resolve_tool("magick") == "/usr/bin/magick" + + @patch(f"{MOD}.shutil.which", return_value=None) + def test_missing(self, mock_which: MagicMock) -> None: + with pytest.raises(render.ToolMissingError) as excinfo: + render.resolve_tool("nope") + assert excinfo.value.tool == "nope" + + +class TestBuildSvg: + def test_background_layer_has_field(self) -> None: + svg = render.build_svg(DOT, "#123456", with_background=True) + assert style.BACKGROUND in svg + assert 'stroke="#123456"' in svg + assert f'stroke-width="{style.STROKE_WIDTH}"' in svg + + def test_transparent_layer_has_no_field(self) -> None: + svg = render.build_svg(DOT, "#123456", with_background=False) + assert style.BACKGROUND not in svg + + def test_offset_is_applied(self) -> None: + svg = render.build_svg(DOT, "#123456", with_background=False, offset=(3.5, -7)) + assert 'transform="translate(3.5,-7)"' in svg + + def test_accent_token_is_substituted(self) -> None: + svg = render.build_svg(FILLED, "#ABCDEF", with_background=False) + assert style.ACCENT_MARKER not in svg + assert 'fill="#ABCDEF"' in svg + + +class TestRasterise: + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.resolve_tool", return_value="/usr/bin/rsvg-convert") + def test_invokes_rsvg_and_creates_parent( + self, mock_tool: MagicMock, run: MagicMock, tmp_path: Path + ) -> None: + destination = tmp_path / "nested" / "out.png" + render.rasterise("", destination, 256) + assert destination.parent.is_dir() + argv = run.call_args.args[0] + assert argv[0] == "/usr/bin/rsvg-convert" + assert "256" in argv + assert run.call_args.kwargs["input"] == b"" + + +class TestInkBbox: + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.resolve_tool", return_value="/usr/bin/magick") + def test_parses_identify_output(self, mock_tool: MagicMock, run: MagicMock) -> None: + run.return_value = subprocess.CompletedProcess([], 0, stdout="400 300 +100 +50") + assert render._ink_bbox(Path("x.png")) == (400, 300, 100, 50) + + +class TestCentreOffset: + @patch(f"{MOD}._ink_bbox", return_value=(400, 300, 112, 262)) + @patch(f"{MOD}.rasterise") + def test_centres_ink_box( + self, mock_raster: MagicMock, mock_bbox: MagicMock + ) -> None: + dx, dy = render.centre_offset(DOT, "#FFFFFF") + # ink centre is (312, 412); canvas centre is 512. + assert (dx, dy) == (200, 100) + + @patch(f"{MOD}._ink_bbox", return_value=(0, 0, 0, 0)) + @patch(f"{MOD}.rasterise") + def test_empty_glyph_raises( + self, mock_raster: MagicMock, mock_bbox: MagicMock + ) -> None: + with pytest.raises(render.EmptyGlyphError) as excinfo: + render.centre_offset(DOT, "#FFFFFF") + assert excinfo.value.name == "dot" + + +class TestSafeBoxOverflow: + @patch(f"{MOD}._ink_bbox", return_value=(600, 500, 0, 0)) + @patch(f"{MOD}.rasterise") + def test_reports_horizontal_overflow( + self, mock_raster: MagicMock, mock_bbox: MagicMock + ) -> None: + assert render.safe_box_overflow(DOT, "#FFFFFF") == (600 - style.SAFE_BOX, 0) + + @patch(f"{MOD}._ink_bbox", return_value=(100, 100, 0, 0)) + @patch(f"{MOD}.rasterise") + def test_no_overflow_is_zero( + self, mock_raster: MagicMock, mock_bbox: MagicMock + ) -> None: + assert render.safe_box_overflow(DOT, "#FFFFFF") == (0, 0)