mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:47:03 +01:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
071daea982
commit
27f44cad4b
@ -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 <staged>; 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."
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
67
python_pkg/app_icons/README.md
Normal file
67
python_pkg/app_icons/README.md
Normal file
@ -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 `<repo>/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.
|
||||
10
python_pkg/app_icons/__init__.py
Normal file
10
python_pkg/app_icons/__init__.py
Normal file
@ -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
|
||||
14
python_pkg/app_icons/__main__.py
Normal file
14
python_pkg/app_icons/__main__.py
Normal file
@ -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())
|
||||
111
python_pkg/app_icons/apps.py
Normal file
111
python_pkg/app_icons/apps.py
Normal file
@ -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
|
||||
191
python_pkg/app_icons/cli.py
Normal file
191
python_pkg/app_icons/cli.py
Normal file
@ -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,
|
||||
)
|
||||
102
python_pkg/app_icons/generate.py
Normal file
102
python_pkg/app_icons/generate.py
Normal file
@ -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 ``<size>/<icon_name>.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,
|
||||
)
|
||||
112
python_pkg/app_icons/glyphs.py
Normal file
112
python_pkg/app_icons/glyphs.py
Normal file
@ -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 = """\
|
||||
<g transform="translate(296,196) scale(18)" stroke-width="4">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 \
|
||||
2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 \
|
||||
0-2.64-2.05-4.78-4.65-4.96z"/>
|
||||
</g>
|
||||
<path d="M 512 628 L 512 744"/>
|
||||
<path d="M 444 676 L 512 744 L 580 676"/>"""
|
||||
|
||||
# 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 = """\
|
||||
<path d="M 268 512 L 756 512"/>
|
||||
<path d="M 364 356 L 364 668"/>
|
||||
<path d="M 660 356 L 660 668"/>
|
||||
<path d="M 268 432 L 268 592"/>
|
||||
<path d="M 756 432 L 756 592"/>"""
|
||||
|
||||
# Plain clock face. Deliberately no bell "ears": at launcher size they read as
|
||||
# noise rather than as an alarm clock.
|
||||
_CLOCK = """\
|
||||
<circle cx="512" cy="512" r="244"/>
|
||||
<path d="M 512 512 L 512 350"/>
|
||||
<path d="M 512 512 L 636 512"/>"""
|
||||
|
||||
# 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 = """\
|
||||
<path d="M 512 275 L 752 366 L 752 544 C 752 655 646 723 512 753 \
|
||||
C 378 723 272 655 272 544 L 272 366 Z"/>
|
||||
<g transform="translate(356,362) scale(13)" fill="{{ACCENT}}" stroke="none">
|
||||
<path d="M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03\
|
||||
C11.34 12.84 13 11.12 13 9V2h-2v7z"/>
|
||||
<path d="M16 12h2.5v10H21V2c-2.76 0-5 2.24-5 5v5z"/>
|
||||
</g>"""
|
||||
|
||||
# Two ticked-off list rows. More specific to a notes/todo app than a lone
|
||||
# checkmark, and still legible at 48dp.
|
||||
_CHECKLIST = """\
|
||||
<path d="M 272 372 L 346 446 L 478 314"/>
|
||||
<path d="M 594 400 L 752 400"/>
|
||||
<path d="M 272 620 L 346 694 L 478 562"/>
|
||||
<path d="M 594 648 L 752 648"/>"""
|
||||
|
||||
|
||||
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
|
||||
158
python_pkg/app_icons/preview.py
Normal file
158
python_pkg/app_icons/preview.py
Normal file
@ -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
|
||||
203
python_pkg/app_icons/render.py
Normal file
203
python_pkg/app_icons/render.py
Normal file
@ -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 <rect width="{style.CANVAS}" height="{style.CANVAS}" '
|
||||
f'fill="{style.BACKGROUND}"/>'
|
||||
if with_background
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{style.CANVAS}" '
|
||||
f'height="{style.CANVAS}" viewBox="0 0 {style.CANVAS} {style.CANVAS}">'
|
||||
f"{field}\n"
|
||||
f' <g fill="none" stroke="{accent}" stroke-width="{style.STROKE_WIDTH}" '
|
||||
f'stroke-linecap="round" stroke-linejoin="round" '
|
||||
f'transform="translate({dx:g},{dy:g})">\n'
|
||||
f"{body}\n"
|
||||
f" </g>\n"
|
||||
f"</svg>\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)
|
||||
39
python_pkg/app_icons/style.py
Normal file
39
python_pkg/app_icons/style.py
Normal file
@ -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)
|
||||
3
python_pkg/app_icons/tests/__init__.py
Normal file
3
python_pkg/app_icons/tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Tests for the shared app-icon generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
107
python_pkg/app_icons/tests/test_cli.py
Normal file
107
python_pkg/app_icons/tests/test_cli.py
Normal file
@ -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([])
|
||||
89
python_pkg/app_icons/tests/test_generate.py
Normal file
89
python_pkg/app_icons/tests/test_generate.py
Normal file
@ -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
|
||||
34
python_pkg/app_icons/tests/test_main_entry.py
Normal file
34
python_pkg/app_icons/tests/test_main_entry.py
Normal file
@ -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()
|
||||
51
python_pkg/app_icons/tests/test_preview.py
Normal file
51
python_pkg/app_icons/tests/test_preview.py
Normal file
@ -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]
|
||||
49
python_pkg/app_icons/tests/test_registry.py
Normal file
49
python_pkg/app_icons/tests/test_registry.py
Normal file
@ -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
|
||||
109
python_pkg/app_icons/tests/test_render.py
Normal file
109
python_pkg/app_icons/tests/test_render.py
Normal file
@ -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", ' <path d="M 100 100 L 200 200"/>')
|
||||
FILLED = Glyph("filled", "a filled shape", ' <rect fill="{{ACCENT}}" />')
|
||||
|
||||
|
||||
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("<svg/>", 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"<svg/>"
|
||||
|
||||
|
||||
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)
|
||||
Loading…
Reference in New Issue
Block a user