testsAndMisc/python_pkg/app_icons/generate.py
Krzysztof kuhy Rudnicki 1c50cfb028
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Python tests / test (push) Has been cancelled
app_icons: tutor gets a lightbulb; generate --web writes PWA icons
New `lightbulb` glyph (filled bulb over a two-bar screw base) in a new
_glyph_art_extra.py module, registered for ~/src/tutor/app. `generate
--web` overwrites a Flutter app's web/favicon.png and web/icons/Icon-*.png
from the same registry entry; the maskable variants reuse the full render
because SAFE_BOX fits inside the 40% maskable safe circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YADHKXzZhhePzQ2rNoXFcK
2026-10-12 17:18:05 +02:00

176 lines
6.2 KiB
Python

"""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 re
import subprocess
from typing import TYPE_CHECKING, Final
from python_pkg.app_icons import glyphs, render, style
# Matches a `flutter_launcher_icons:` block's `adaptive_icon_background:` line
# so it can be kept in sync with style.BACKGROUND without a full YAML
# round-trip (which would reformat the rest of the file).
_ADAPTIVE_BG_RE: Final[re.Pattern[str]] = re.compile(
r'^(\s*adaptive_icon_background:\s*)"#[0-9A-Fa-f]{6}"(\s*)$', re.MULTILINE
)
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),
)
# (path under web/, size) for the files `flutter create` puts there.
_WEB_ICONS: Final[tuple[tuple[str, int], ...]] = (
("favicon.png", 32),
("icons/Icon-192.png", 192),
("icons/Icon-512.png", 512),
("icons/Icon-maskable-192.png", 192),
("icons/Icon-maskable-512.png", 512),
)
# Radius of the web maskable-icon safe zone, as a fraction of the icon size.
MASKABLE_SAFE_RADIUS: Final[float] = 0.4
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 write_web_icons(app: AppIcon) -> list[Path]:
"""Overwrite the Flutter web template's favicon and PWA manifest icons.
The maskable variants are the same full-bleed render as the plain ones:
every glyph is kept inside ``style.SAFE_BOX``, whose corners sit inside
the maskable safe zone (the inner circle of radius 40% of the icon), so a
circle mask never clips the glyph.
Parameters:
app (AppIcon): App whose ``web/`` directory should be written.
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 relative, size in _WEB_ICONS:
png_path = app.web_dir / relative
render.rasterise(svg_text, png_path, size)
written.append(png_path)
return written
def sync_pubspec_background(app: AppIcon) -> bool:
"""Rewrite pubspec.yaml's ``adaptive_icon_background`` to match style.BACKGROUND.
flutter_launcher_icons reads the adaptive-icon background color from
pubspec.yaml, not from this package -- a value that has to be kept in
sync by hand has drifted before (six apps kept `#1B1D21` in pubspec.yaml
after style.BACKGROUND moved to `#211D1B`, so the on-device icon stayed
on the old color even though every generated asset was correct). This
makes that class of drift impossible instead of relying on remembering
to update it.
Parameters:
app (AppIcon): App whose pubspec.yaml should be synced.
Returns:
bool: True if the file was changed, False if it already matched.
"""
pubspec_path = app.repo / "pubspec.yaml"
original = pubspec_path.read_text(encoding="utf-8")
updated = _ADAPTIVE_BG_RE.sub(rf'\1"{style.BACKGROUND}"\2', original, count=1)
if updated == original:
return False
pubspec_path.write_text(updated, encoding="utf-8")
return True
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. Syncs pubspec.yaml's
adaptive_icon_background first so the tool never reads a stale value.
Parameters:
app (AppIcon): App whose Flutter project should be processed.
"""
sync_pubspec_background(app)
subprocess.run(
[render.resolve_tool("dart"), "run", "flutter_launcher_icons"],
cwd=app.repo,
check=True,
capture_output=True,
)