app_icons: fit every glyph to one ink circle, launcher-name rules
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Has been cancelled
Python tests / test (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / lib-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled

- fit.py: centre each glyph and scale it so its furthest ink sits at
  INK_RADIUS = 340px, compensating the unscaled stroke width
- style.py: INK_RADIUS plus INK_RADIUS_DP for vector-drawable consumers
- labels.py/_label_widths.py: LABELS registry; names start with a
  capital, ASCII letters only, <= 10 chars, <= 522 wide in the Pixel
  launcher font
- drop centre_offset, safe_box_overflow and the CLI safe-box warning
- add prompt-bubble (sufler) and camera (macro_cam) glyphs and apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kQ5PCKqBHAkAVkPSvp6dk
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 14:35:00 +02:00
parent 92aa2f971d
commit 35b497b409
22 changed files with 618 additions and 187 deletions

View File

@ -0,0 +1,15 @@
{
"title": "Shared icon generator fits every glyph to one ink circle and enforces launcher-name rules",
"objective": "python_pkg/app_icons renders every glyph centred with its furthest ink at INK_RADIUS = 340px (stroke width compensated), exposes the same radius in dp for vector consumers, holds a LABELS registry whose names meet kuhy's launcher-name rules, and registers the sufler (prompt-bubble) and macro_cam (camera) glyphs.",
"acceptance_criteria": [
"For every registered app, fit.placement yields a render whose ink box is centred and whose furthest inked pixel is INK_RADIUS from the centre within tolerance (test_fit.py)",
"Every LABELS entry starts with a capital, holds only ASCII letters, is <= 10 chars and <= 522 wide in the Pixel launcher font (test_labels.py, which also requires every registered app to have a unique label)",
"centre_offset, safe_box_overflow and the CLI safe-box warning are gone and nothing references them",
"pytest over python_pkg/app_icons reports 100% line and branch coverage for every module"
],
"out_of_scope": [
"Regenerated icon assets inside the app repos",
"repo_contract's android:label gate in kuhyx/utils"
],
"verifier": "python3 -m pytest -q -p no:cacheprovider python_pkg/app_icons --cov=python_pkg.app_icons --cov-branch; scripts/test_changed.sh; pre-commit run --files <changed files>; python3 -m python_pkg.app_icons preview"
}

View File

@ -0,0 +1,45 @@
{
"intent": "Every launcher icon in the shared family occupies the same circle (glyph auto-centred, furthest ink at INK_RADIUS = 340px, stroke width compensated) and every registered app carries a launcher name that the Pixel launcher never truncates.",
"scope": [
"python_pkg/app_icons only: fit.py, labels.py, _label_widths.py (new); render.py, style.py, cli.py, generate.py, preview.py, _app_icon.py, _apps_more.py, glyphs.py, _glyph_art_extra.py, README.md; their tests",
"Non-goal: regenerating assets inside the app repos (each app repo commits its own icons); repo_contract label gate in kuhyx/utils is committed separately"
],
"changes": [
"fit.py: render, measure ink, derive a Placement that centres the ink box and scales it so the furthest inked pixel sits at style.INK_RADIUS, iterating because stroke width does not scale",
"style.py: INK_RADIUS = 340px plus ADAPTIVE_CANVAS_DP / INK_RADIUS_DP for vector-drawable consumers (phone-focus-mode)",
"labels.py + _label_widths.py: LABELS registry and rules (capital first letter, ASCII letters only, <= 10 chars, <= 522 wide in the Pixel launcher font); AppIcon.label reads it",
"render.py/cli.py: centre_offset, safe_box_overflow and the CLI safe-box warning removed (superseded by the fit)",
"glyphs/_glyph_art_extra/_apps_more: prompt-bubble (sufler) and camera (macro_cam) glyphs and registry entries",
"_app_icon.py: labels import moved to module top level (no cycle: labels imports only _label_widths) to satisfy PLC0415/C0415"
],
"verification": [
{
"command": "python3 -m pytest -q -p no:cacheprovider python_pkg/app_icons --cov=python_pkg.app_icons --cov-branch --cov-report=term-missing --cov-fail-under=0",
"result": "pass",
"evidence": "126 passed in 7.24s; every python_pkg/app_icons/*.py file at 100% line+branch (fit.py 37 stmts/8 branches, labels.py 21/10, cli.py 66/20, render.py 36/2)"
},
{
"command": "scripts/test_changed.sh",
"result": "pass",
"evidence": "198 passed in 6.07s"
},
{
"command": "pre-commit run --files <the 20 app_icons files>",
"result": "pass",
"evidence": "first run: no-noqa, ruff PLC0415 and pylint C0415 failed on the lazy import in _app_icon.py; after moving it top-level, 38 hooks Passed, 0 failed"
},
{
"command": "python3 -m python_pkg.app_icons list && python3 -m python_pkg.app_icons preview -o /dev/shm/appicons_prev/sheet.png",
"result": "pass",
"evidence": "list shows sufler -> prompt-bubble +linux and macro_cam -> camera; preview wrote sheet.png with all 25 glyphs at visually equal size on the charcoal field"
}
],
"risks": [
"Glyph sizes change for every app, so each app repo shows an icon diff the next time it regenerates",
"Label width table is measured for the Pixel launcher font; other launchers may truncate differently"
],
"rollback": [
"git revert this commit",
"Rerun the app_icons pytest command above and the preview to confirm the old sizes return"
]
}

View File

@ -21,7 +21,8 @@ PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \
generate --app todo --android --linux-out ~/src/todo/linux/icons generate --app todo --android --linux-out ~/src/todo/linux/icons
# overwrite web/favicon.png + web/icons/Icon-*.png (apps with a web/ dir); # overwrite web/favicon.png + web/icons/Icon-*.png (apps with a web/ dir);
# the maskable ones are safe because SAFE_BOX fits the 40% maskable circle # the maskable ones are safe because INK_RADIUS (340px) sits inside the 40%
# (410px) maskable circle
PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \ PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \
generate --app tutor --android --web generate --app tutor --android --web
``` ```
@ -45,13 +46,18 @@ the density buckets and `mipmap-anydpi-v26/ic_launcher.xml`.
- **Negative space ≥ half the stroke weight.** Strokes closer than that merge - **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 into a solid blob at launcher size. A fork whose tines sat exactly one stroke
width apart rendered as a tulip. width apart rendered as a tulip.
- **Ink must stay inside the 560px safe box.** Android masks adaptive icons to - **Every glyph fills the same circle.** `fit.placement` centres the ink and
roughly the inner 66% of the canvas. `generate` warns when a glyph overflows. scales it so the furthest inked pixel sits `style.INK_RADIUS` (340px, 24.4dp)
from the centre; the stroke width is divided by the scale so strokes stay
72px. Check negative space at that final scale, since big drawings shrink.
- **Stroked outlines can read wrong.** A stroked three-tine fork reads as a - **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 trident; a filled fork-and-knife pair reads as cutlery. Prefer a filled
silhouette when the outline is ambiguous. silhouette when the outline is ambiguous.
- Glyph positioning inside the fragment is irrelevant — `render.centre_offset` - Glyph position and size inside the fragment are irrelevant. Only the shape
measures the rendered ink and recentres it. Only the extents matter. matters.
- **Launcher names** live in `labels.py`: a capital first letter, ASCII letters
only, ≤ 10 chars and ≤ 522 wide in the launcher font (`_label_widths.py`),
because the Pixel app drawer cuts wider names.
## Adding an app ## Adding an app

View File

@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.app_icons.labels import LABELS
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -39,3 +41,8 @@ class AppIcon:
def web_dir(self) -> Path: def web_dir(self) -> Path:
"""Return the Flutter ``web/`` directory holding favicon and PWA icons.""" """Return the Flutter ``web/`` directory holding favicon and PWA icons."""
return self.repo / "web" return self.repo / "web"
@property
def label(self) -> str:
"""Return the launcher name from :data:`~python_pkg.app_icons.labels.LABELS`."""
return LABELS[self.key]

View File

@ -33,4 +33,23 @@ MORE_APPS: Final[tuple[AppIcon, ...]] = (
# window (see the repo's SPEC.md), so it gets the hicolor set too. # window (see the repo's SPEC.md), so it gets the hicolor set too.
linux=True, linux=True,
), ),
AppIcon(
key="sufler",
repo=_HOME / "src/sufler",
accent="#B8862E",
glyph="prompt-bubble",
icon_name="sufler",
# packaging/sufler.desktop (Icon=sufler) installs the hicolor set
# from packaging/icons via the PKGBUILD.
linux=True,
),
AppIcon(
key="macro_cam",
repo=_HOME / "src/macro-cam",
accent="#B8862E",
glyph="camera",
icon_name="macro-cam",
# Android only: no linux/ target and no desktop entry.
linux=False,
),
) )

View File

@ -41,3 +41,42 @@ _HEADPHONES = """\
fill="{{ACCENT}}" stroke="none"/> fill="{{ACCENT}}" stroke="none"/>
<rect x="656" y="520" width="112" height="216" rx="40" \ <rect x="656" y="520" width="112" height="216" rx="40" \
fill="{{ACCENT}}" stroke="none"/>""" fill="{{ACCENT}}" stroke="none"/>"""
# A speech bubble with two text lines -- sufler, a prompter that reads the
# partner's lines aloud: the prompt being spoken. Ported verbatim from
# ~/src/sufler/assets/icon/icon_foreground.svg (commit 5d38160), drawn before
# the glyph was registered here; only its translate(0,-4) is dropped, since the
# renderer recentres every fragment anyway.
#
# Geometry, ink inside SAFE_BOX (x=260..764 = 504 wide, y=248..784 = 536 tall):
# bubble rounded rect x=296..728, y=284..636, corner r=64, with a tail from
# (480,636) down to a point at (360,748) and back up at x=384.
# lines x=408..616 at y=398 and a shorter x=408..520 at y=522. Gaps
# bubble->line1, line1->line2 and line2->bubble are 42 / 52 / 42px,
# clear of MIN_NEGATIVE_SPACE=36; the fit scales this glyph up
# (authored ink radius 326 < 340), which only widens them.
_PROMPT_BUBBLE = """\
<path d="M 360 284 L 664 284 A 64 64 0 0 1 728 348 L 728 572 \
A 64 64 0 0 1 664 636 L 480 636 L 360 748 L 384 636 L 360 636 \
A 64 64 0 0 1 296 572 L 296 348 A 64 64 0 0 1 360 284 Z"/>
<path d="M 408 398 L 616 398"/>
<path d="M 408 522 L 520 522"/>"""
# A camera -- macro-cam, which estimates a meal's macros from a photo: the
# photo being taken. Stroked body with a viewfinder hump over a stroked lens.
# The food itself is already taken (``shield-cutlery``, ``bowl-star``), so the
# camera carries the meaning.
#
# Geometry, ink x=220..804 (584 wide), y=236..788 (552 tall):
# body rounded rect x=256..768, y=336..752, corner r=64, with a hump
# rising to y=272 between x=416..528. The corner arcs set the ink
# radius (~360), so the fit shrinks the glyph to ~0.94.
# lens circle r=88 about (512,544). Centreline gaps lens->body are 120px
# above and below, so after the ~0.94 fit the ink gap is ~41px, and
# the lens hole is ~94px across: both clear of MIN_NEGATIVE_SPACE=36
# at the FINAL scale (tests/test_registry.py measures it).
_CAMERA = """\
<path d="M 320 336 L 384 336 L 416 272 L 528 272 L 560 336 L 704 336 \
A 64 64 0 0 1 768 400 L 768 688 A 64 64 0 0 1 704 752 L 320 752 \
A 64 64 0 0 1 256 688 L 256 400 A 64 64 0 0 1 320 336 Z"/>
<circle cx="512" cy="544" r="88"/>"""

View File

@ -0,0 +1,30 @@
"""Advance widths of the ASCII letters in the Pixel launcher's label font.
Measured from ``GoogleSansFlex-Regular.ttf`` pulled off kuhy's Pixel 6a
(``/product/fonts``) at a 100px em, rounded. Only the numbers are kept, not
the font, which is not redistributable. Kerning is ignored; on every label
measured it moved the total by under 1%.
"""
from __future__ import annotations
from typing import Final
ADVANCE: Final[dict[str, int]] = {
"a": 54, "b": 60, "c": 54, "d": 60, "e": 56, "f": 38, "g": 60, "h": 56,
"i": 23, "j": 23, "k": 50, "l": 21, "m": 88, "n": 56, "o": 60, "p": 60,
"q": 60, "r": 38, "s": 47, "t": 36, "u": 56, "v": 51, "w": 78, "x": 49,
"y": 51, "z": 48, "A": 67, "B": 61, "C": 74, "D": 70, "E": 55, "F": 53,
"G": 79, "H": 70, "I": 24, "J": 53, "K": 63, "L": 51, "M": 87, "N": 71,
"O": 82, "P": 58, "Q": 82, "R": 59, "S": 55, "T": 54, "U": 65, "V": 63,
"W": 94, "X": 64, "Y": 60, "Z": 57,
} # fmt: skip
# The widest label seen whole in the app drawer is WakeAlarm (522), and
# FocusOwner (580) lost its last letters there (kuhy, 2026-10-23).
MAX_WIDTH: Final[int] = 522
def label_width(name: str) -> int:
"""Return a label's width in font units; only call on ASCII letters."""
return sum(ADVANCE[char] for char in name)

View File

@ -12,7 +12,7 @@ import argparse
from pathlib import Path from pathlib import Path
import sys import sys
from python_pkg.app_icons import apps, generate, glyphs, preview, render, style from python_pkg.app_icons import apps, generate, glyphs, preview
def _emit(message: str) -> None: def _emit(message: str) -> None:
@ -173,13 +173,6 @@ def _cmd_generate(
written = generate.write_assets(app) written = generate.write_assets(app)
_emit(f"{app.key}: wrote {len(written)} asset files to {app.asset_dir}") _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: if linux_out is not None and app.linux:
icons = generate.write_linux_icons(app, linux_out) icons = generate.write_linux_icons(app, linux_out)
_emit(f" wrote {len(icons)} hicolor PNGs to {linux_out}") _emit(f" wrote {len(icons)} hicolor PNGs to {linux_out}")

View File

@ -0,0 +1,95 @@
"""Fit every glyph to the same circle: centred, with ink radius ``INK_RADIUS``.
Hand-authored glyphs span anything from 250 to 400px, so a row of icons looked
like different sizes (kuhy, 2026-10-23). Each glyph is rendered, its ink is
measured, and a :class:`~python_pkg.app_icons.render.Placement` is derived that
centres the ink box and scales it so the furthest inked pixel sits exactly
``style.INK_RADIUS`` from the centre.
The stroke weight does not scale with the glyph (see :func:`render.build_svg`),
so the radius is not linear in the scale; a few measure-and-correct rounds
converge well inside the tolerance.
"""
from __future__ import annotations
from pathlib import Path
import tempfile
from typing import TYPE_CHECKING, Final
import numpy as np
from PIL import Image
from python_pkg.app_icons import render, style
if TYPE_CHECKING:
from python_pkg.app_icons.glyphs import Glyph
# Alpha above this counts as ink; the anti-aliased fringe below it does not.
_INK_ALPHA: Final[int] = 127
_MAX_ROUNDS: Final[int] = 8
_TOLERANCE: Final[float] = 0.002
def ink_geometry(png: Path) -> tuple[float, float, float]:
"""Measure a rendered glyph.
Parameters:
png (Path): Transparent render of the glyph alone.
Returns:
tuple[float, float, float]: ``(radius, cx, cy)`` -- the ink box centre and
the distance from it to the furthest inked pixel. All zero for no ink.
"""
with Image.open(png) as image:
alpha = np.asarray(image.convert("RGBA"))[:, :, 3]
ys, xs = np.nonzero(alpha > _INK_ALPHA)
if xs.size == 0:
return 0.0, 0.0, 0.0
cx = (int(xs.min()) + int(xs.max()) + 1) / 2
cy = (int(ys.min()) + int(ys.max()) + 1) / 2
radius = float(np.hypot(xs + 0.5 - cx, ys + 0.5 - cy).max())
return radius, cx, cy
def _measure(
glyph: Glyph, accent: str, where: render.Placement
) -> tuple[float, float, float]:
"""Render ``glyph`` at ``where`` and measure its ink."""
svg = render.build_svg(glyph, accent, with_background=False, offset=where)
with tempfile.TemporaryDirectory() as tmp:
png = Path(tmp) / "probe.png"
render.rasterise(svg, png, style.CANVAS)
return ink_geometry(png)
def placement(glyph: Glyph, accent: str) -> render.Placement:
"""Find the placement that centres a glyph on the family ink circle.
Parameters:
glyph (Glyph): Artwork to fit.
accent (str): Hex colour to render with; it does not affect the result,
but rsvg-convert needs a concrete value.
Returns:
render.Placement: Transform to pass to :func:`render.build_svg`.
Raises:
render.EmptyGlyphError: If the glyph renders no visible ink.
"""
where = render.IDENTITY
for _ in range(_MAX_ROUNDS):
radius, cx, cy = _measure(glyph, accent, where)
if radius == 0:
raise render.EmptyGlyphError(glyph.name)
factor = style.INK_RADIUS / radius
# Move the ink centre onto the canvas centre, then scale about it:
# p -> C + factor * (scale*p + d - c).
where = render.Placement(
dx=style.CENTRE + factor * (where.dx - cx),
dy=style.CENTRE + factor * (where.dy - cy),
scale=where.scale * factor,
)
if abs(factor - 1) <= _TOLERANCE:
break
return where

View File

@ -21,7 +21,7 @@ import re
import subprocess import subprocess
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
from python_pkg.app_icons import glyphs, render, style from python_pkg.app_icons import fit, glyphs, render, style
# Matches a `flutter_launcher_icons:` block's `adaptive_icon_background:` line # 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 # so it can be kept in sync with style.BACKGROUND without a full YAML
@ -65,7 +65,7 @@ def write_assets(app: AppIcon) -> list[Path]:
list[Path]: Every file written, in creation order. list[Path]: Every file written, in creation order.
""" """
glyph = glyphs.get_glyph(app.glyph) glyph = glyphs.get_glyph(app.glyph)
offset = render.centre_offset(glyph, app.accent) offset = fit.placement(glyph, app.accent)
written: list[Path] = [] written: list[Path] = []
for stem, with_background, colour in _LAYERS: for stem, with_background, colour in _LAYERS:
svg_text = render.build_svg( svg_text = render.build_svg(
@ -94,7 +94,7 @@ def write_linux_icons(app: AppIcon, destination: Path) -> list[Path]:
list[Path]: Every PNG written. list[Path]: Every PNG written.
""" """
glyph = glyphs.get_glyph(app.glyph) glyph = glyphs.get_glyph(app.glyph)
offset = render.centre_offset(glyph, app.accent) offset = fit.placement(glyph, app.accent)
svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset) svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset)
written: list[Path] = [] written: list[Path] = []
for size in style.LINUX_ICON_SIZES: for size in style.LINUX_ICON_SIZES:
@ -108,7 +108,7 @@ def write_web_icons(app: AppIcon) -> list[Path]:
"""Overwrite the Flutter web template's favicon and PWA manifest icons. """Overwrite the Flutter web template's favicon and PWA manifest icons.
The maskable variants are the same full-bleed render as the plain ones: 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 every glyph is fitted to ``style.INK_RADIUS`` (340px of 1024), inside
the maskable safe zone (the inner circle of radius 40% of the icon), so a the maskable safe zone (the inner circle of radius 40% of the icon), so a
circle mask never clips the glyph. circle mask never clips the glyph.
@ -119,7 +119,7 @@ def write_web_icons(app: AppIcon) -> list[Path]:
list[Path]: Every PNG written. list[Path]: Every PNG written.
""" """
glyph = glyphs.get_glyph(app.glyph) glyph = glyphs.get_glyph(app.glyph)
offset = render.centre_offset(glyph, app.accent) offset = fit.placement(glyph, app.accent)
svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset) svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset)
written: list[Path] = [] written: list[Path] = []
for relative, size in _WEB_ICONS: for relative, size in _WEB_ICONS:

View File

@ -29,7 +29,12 @@ from python_pkg.app_icons._glyph_art import (
_SHIELD_CUTLERY, _SHIELD_CUTLERY,
_STORAGE_BOX, _STORAGE_BOX,
) )
from python_pkg.app_icons._glyph_art_extra import _HEADPHONES, _LIGHTBULB from python_pkg.app_icons._glyph_art_extra import (
_CAMERA,
_HEADPHONES,
_LIGHTBULB,
_PROMPT_BUBBLE,
)
from python_pkg.app_icons._glyph_art_more import ( from python_pkg.app_icons._glyph_art_more import (
_BOWL_STAR, _BOWL_STAR,
_HOURGLASS, _HOURGLASS,
@ -101,6 +106,12 @@ GLYPHS: Final[dict[str, Glyph]] = {
), ),
Glyph("lightbulb", "Filled lightbulb over a two-bar screw base", _LIGHTBULB), Glyph("lightbulb", "Filled lightbulb over a two-bar screw base", _LIGHTBULB),
Glyph("headphones", "Headband arc over two filled ear cups", _HEADPHONES), Glyph("headphones", "Headband arc over two filled ear cups", _HEADPHONES),
Glyph(
"prompt-bubble",
"Speech bubble with a tail and two text lines",
_PROMPT_BUBBLE,
),
Glyph("camera", "Camera body with a viewfinder hump and a lens", _CAMERA),
) )
} }

View File

@ -0,0 +1,84 @@
"""Launcher names for every registered app, and the rules they must meet.
kuhy's rules (2026-10-23): a name starts with a capital, holds only ASCII
letters -- no spaces, underscores or punctuation -- and is short enough that
the Pixel launcher never truncates it with an ellipsis. ``signalbot_remote``
showed as ``signalbot_r...`` while the 12-character ``Daily limits`` fitted.
A character cap alone was not enough: the 10-letter ``FocusOwner`` was still
cut in the app drawer, so the rendered width in the launcher font is capped
too (:mod:`_label_widths`).
``repo_contract`` (kuhyx/utils) applies the same rules to every Flutter repo's
``android:label``, so an app that is not registered here is still held to them.
"""
from __future__ import annotations
import re
from typing import Final
from python_pkg.app_icons._label_widths import MAX_WIDTH, label_width
MAX_LENGTH: Final[int] = 10
_ASCII_LETTERS: Final[re.Pattern[str]] = re.compile(r"[A-Za-z]+")
LABELS: Final[dict[str, str]] = {
"dufs_client": "Cloud",
"focus_owner": "Focuser",
"workout_app": "Workout",
"wake_alarm_sync": "WakeAlarm",
"diet_guard_app": "DietGuard",
"todo": "Todo",
"untools": "Untools",
"punchme": "PunchMe",
"restaurant_rater": "Rater",
"habit_stack": "HabitStack",
"home_inventory": "Inventory",
"epopeja_karta": "Epopeja",
"octoforge": "OctoForge",
"kuhylog": "KuhyLog",
"lyricanki": "LyricAnki",
"betting_sim": "BettingSim",
"home_guard_app": "Homer",
"signalbot_remote": "SignalBot",
"umbrella": "Umbrella",
"book_guard_app": "BookGuard",
"daily_limits": "DailyLimit",
"tutor": "Tutor",
"tunedeck": "TuneDeck",
"sufler": "Sufler",
"macro_cam": "MacroCam",
}
def label_problems(name: str) -> list[str]:
"""List every launcher-name rule a name breaks.
Parameters:
name (str): Candidate launcher label.
Returns:
list[str]: One message per broken rule; empty when the name is valid.
"""
if not name:
return ["label is empty"]
problems: list[str] = []
letters_only = _ASCII_LETTERS.fullmatch(name) is not None
if not letters_only:
problems.append(
f"{name!r} must contain only ASCII letters (no spaces, _ or symbols)"
)
if not name[0].isupper():
problems.append(f"{name!r} must start with a capital letter")
# The width table only knows ASCII letters; other names already failed.
if letters_only and label_width(name) > MAX_WIDTH:
problems.append(
f"{name!r} is {label_width(name)} wide in the launcher font; "
f"at most {MAX_WIDTH} fit"
)
if len(name) > MAX_LENGTH:
problems.append(
f"{name!r} has {len(name)} characters; at most {MAX_LENGTH} fit"
)
return problems

View File

@ -12,7 +12,7 @@ from pathlib import Path
import subprocess import subprocess
import tempfile import tempfile
from python_pkg.app_icons import apps, glyphs, render, style from python_pkg.app_icons import apps, fit, glyphs, render, style
# Size each icon is drawn at in the contact sheet. # Size each icon is drawn at in the contact sheet.
_TILE = 512 _TILE = 512
@ -122,7 +122,7 @@ def build_contact_sheet(keys: list[str], destination: Path) -> Path:
for key in keys: for key in keys:
app = apps.get_app(key) app = apps.get_app(key)
glyph = glyphs.get_glyph(app.glyph) glyph = glyphs.get_glyph(app.glyph)
offset = render.centre_offset(glyph, app.accent) offset = fit.placement(glyph, app.accent)
svg_text = render.build_svg( svg_text = render.build_svg(
glyph, app.accent, with_background=True, offset=offset glyph, app.accent, with_background=True, offset=offset
) )

View File

@ -1,30 +1,52 @@
"""SVG assembly and rasterisation for the shared icon family. """SVG assembly and rasterisation for the shared icon family.
The interesting part here is :func:`centre_offset`. Glyphs are hand-authored, so Glyphs are hand-authored, so their ink never lands on the canvas centre and
their ink never lands exactly on the canvas centre; a barbell is wide and short, never spans the same circle. :mod:`python_pkg.app_icons.fit` measures each one
a cloud sits high. Rather than nudging coordinates by hand, the glyph is and returns a :class:`Placement`; :func:`build_svg` applies it. The fragment is
rendered once on its own, its ink bounding box is measured, and the whole translated and scaled, but the group's stroke width is divided by the scale,
fragment is translated so that box is centred. That is what makes a row of so every stroked icon keeps exactly the family stroke weight. A fragment with
icons line up at the same optical height. its own inner ``scale(k)`` writes ``stroke-width="{{STROKE/k}}"`` and gets the
same compensation. Filled shapes have no stroke and simply scale.
Only the fragment is translated -- never scaled -- so every icon keeps exactly
the family stroke weight.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from dataclasses import dataclass
import re
import shutil import shutil
import subprocess import subprocess
import tempfile
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.app_icons import style from python_pkg.app_icons import style
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from python_pkg.app_icons.glyphs import Glyph from python_pkg.app_icons.glyphs import Glyph
# ``{{STROKE/k}}``: the family stroke weight inside a group already scaled by k.
_STROKE_MARKER = re.compile(r"\{\{STROKE/(\d+(?:\.\d+)?)\}\}")
@dataclass(frozen=True)
class Placement:
"""Where a glyph fragment lands: ``translate(dx, dy) scale(scale)``.
Parameters:
dx (float): Horizontal translation in canvas pixels.
dy (float): Vertical translation in canvas pixels.
scale (float): Uniform scale applied after the translation.
"""
dx: float = 0.0
dy: float = 0.0
scale: float = 1.0
IDENTITY = Placement()
class ToolMissingError(RuntimeError): class ToolMissingError(RuntimeError):
"""Raised when a required external rasterisation tool is not installed.""" """Raised when a required external rasterisation tool is not installed."""
@ -74,7 +96,7 @@ def build_svg(
accent: str, accent: str,
*, *,
with_background: bool, with_background: bool,
offset: tuple[float, float] = (0.0, 0.0), offset: Placement = IDENTITY,
) -> str: ) -> str:
"""Assemble a complete SVG document for one icon layer. """Assemble a complete SVG document for one icon layer.
@ -83,14 +105,17 @@ def build_svg(
accent (str): Hex colour for the glyph, e.g. ``"#26A69A"``. accent (str): Hex colour for the glyph, e.g. ``"#26A69A"``.
with_background (bool): Draw the shared charcoal field behind the glyph. with_background (bool): Draw the shared charcoal field behind the glyph.
False produces the transparent adaptive-icon layers. False produces the transparent adaptive-icon layers.
offset (tuple[float, float]): ``(dx, dy)`` translation applied to the offset (Placement): Transform applied to the fragment, normally the
fragment, normally the result of :func:`centre_offset`. result of :func:`python_pkg.app_icons.fit.placement`.
Returns: Returns:
str: A standalone SVG document. str: A standalone SVG document.
""" """
body = glyph.body.replace(style.ACCENT_MARKER, accent) body = glyph.body.replace(style.ACCENT_MARKER, accent)
dx, dy = offset body = _STROKE_MARKER.sub(
lambda m: f"{style.STROKE_WIDTH / (offset.scale * float(m[1])):g}", body
)
stroke = style.STROKE_WIDTH / offset.scale
field = ( field = (
f'\n <rect width="{style.CANVAS}" height="{style.CANVAS}" ' f'\n <rect width="{style.CANVAS}" height="{style.CANVAS}" '
f'fill="{style.BACKGROUND}"/>' f'fill="{style.BACKGROUND}"/>'
@ -101,9 +126,10 @@ def build_svg(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{style.CANVAS}" ' f'<svg xmlns="http://www.w3.org/2000/svg" width="{style.CANVAS}" '
f'height="{style.CANVAS}" viewBox="0 0 {style.CANVAS} {style.CANVAS}">' f'height="{style.CANVAS}" viewBox="0 0 {style.CANVAS} {style.CANVAS}">'
f"{field}\n" f"{field}\n"
f' <g fill="none" stroke="{accent}" stroke-width="{style.STROKE_WIDTH}" ' f' <g fill="none" stroke="{accent}" stroke-width="{stroke:g}" '
f'stroke-linecap="round" stroke-linejoin="round" ' f'stroke-linecap="round" stroke-linejoin="round" '
f'transform="translate({dx:g},{dy:g})">\n' f'transform="translate({offset.dx:g},{offset.dy:g}) '
f'scale({offset.scale:g})">\n'
f"{body}\n" f"{body}\n"
f" </g>\n" f" </g>\n"
f"</svg>\n" f"</svg>\n"
@ -133,71 +159,3 @@ def rasterise(svg_text: str, destination: Path, size: int) -> None:
check=True, check=True,
capture_output=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)

View File

@ -19,11 +19,24 @@ CENTRE: Final[float] = CANVAS / 2
# cool-tinted #1B1D21, so relative lightness is unchanged. # cool-tinted #1B1D21, so relative lightness is unchanged.
BACKGROUND: Final[str] = "#211D1B" BACKGROUND: Final[str] = "#211D1B"
# Android masks an adaptive icon down to roughly the inner 66% of the canvas. # Authoring box for glyph fragments. The renderer rescales every glyph to
# Keeping every glyph inside this box means no launcher mask (circle, squircle, # INK_RADIUS anyway, so this only keeps hand-written coordinates comparable.
# teardrop, ...) can clip it.
SAFE_BOX: Final[int] = 560 SAFE_BOX: Final[int] = 560
# Every glyph is scaled so its furthest inked pixel sits exactly this far from
# the centre, so all icons occupy the same circle (kuhy, 2026-10-23: glyphs had
# drifted from 250 to 394px, and focus_owner filled the whole mask). 340px is
# 24.4dp on the adaptive canvas (flutter_launcher_icons' default 16% inset maps
# 1024px onto 73.44dp), well inside Android's 33dp guaranteed-visible circle
# and the 40% (410px) maskable-web safe zone.
INK_RADIUS: Final[int] = 340
# The same circle in dp, for routes that emit Android vectors instead of PNGs
# (phone-focus-mode's focus_owner): flutter_launcher_icons insets the adaptive
# foreground 16% per side, so the 1024px canvas covers 108 * 0.68 = 73.44dp.
ADAPTIVE_CANVAS_DP: Final[float] = 108 * (1 - 2 * 0.16)
INK_RADIUS_DP: Final[float] = INK_RADIUS / CANVAS * ADAPTIVE_CANVAS_DP
# Single stroke weight for the whole family. Glyph negative space must stay at # 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 # or above STROKE_WIDTH / 2, otherwise adjacent strokes merge into a blob when
# the icon is scaled down to launcher size. # the icon is scaled down to launcher size.

View File

@ -41,48 +41,31 @@ class TestPreviewCommand:
class TestGenerateCommand: 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")]) @patch(f"{MOD}.generate.write_assets", return_value=[Path("a"), Path("b")])
def test_writes_assets_only_by_default( def test_writes_assets_only_by_default(
self, self,
write: MagicMock, write: MagicMock,
mock_overflow: MagicMock,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
) -> None: ) -> None:
assert cli.main(["generate", "--app", "todo"]) == 0 assert cli.main(["generate", "--app", "todo"]) == 0
assert write.call_count == 1 assert write.call_count == 1
assert "wrote 2 asset files" in capsys.readouterr().out 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}.generate.run_flutter_launcher_icons")
@patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0))
@patch(f"{MOD}.generate.write_assets", return_value=[]) @patch(f"{MOD}.generate.write_assets", return_value=[])
def test_android_flag_runs_generator( def test_android_flag_runs_generator(
self, self,
mock_write: MagicMock, mock_write: MagicMock,
mock_overflow: MagicMock,
launcher: MagicMock, launcher: MagicMock,
) -> None: ) -> None:
cli.main(["generate", "--app", "todo", "--android"]) cli.main(["generate", "--app", "todo", "--android"])
assert launcher.call_count == 1 assert launcher.call_count == 1
@patch(f"{MOD}.generate.write_linux_icons", return_value=[Path("x")]) @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=[]) @patch(f"{MOD}.generate.write_assets", return_value=[])
def test_linux_out_only_for_linux_apps( def test_linux_out_only_for_linux_apps(
self, self,
mock_write: MagicMock, mock_write: MagicMock,
mock_overflow: MagicMock,
linux: MagicMock, linux: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@ -103,12 +86,10 @@ class TestGenerateCommand:
class TestWebFlag: class TestWebFlag:
@patch(f"{MOD}.generate.write_web_icons", return_value=[Path("w")]) @patch(f"{MOD}.generate.write_web_icons", return_value=[Path("w")])
@patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0))
@patch(f"{MOD}.generate.write_assets", return_value=[]) @patch(f"{MOD}.generate.write_assets", return_value=[])
def test_writes_into_an_existing_web_dir( def test_writes_into_an_existing_web_dir(
self, self,
mock_write: MagicMock, mock_write: MagicMock,
mock_overflow: MagicMock,
web: MagicMock, web: MagicMock,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
) -> None: ) -> None:
@ -118,12 +99,10 @@ class TestWebFlag:
assert "wrote 1 web icons" in capsys.readouterr().out assert "wrote 1 web icons" in capsys.readouterr().out
@patch(f"{MOD}.generate.write_web_icons") @patch(f"{MOD}.generate.write_web_icons")
@patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0))
@patch(f"{MOD}.generate.write_assets", return_value=[]) @patch(f"{MOD}.generate.write_assets", return_value=[])
def test_skips_apps_without_a_web_dir( def test_skips_apps_without_a_web_dir(
self, self,
mock_write: MagicMock, mock_write: MagicMock,
mock_overflow: MagicMock,
web: MagicMock, web: MagicMock,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
) -> None: ) -> None:

View File

@ -0,0 +1,84 @@
"""Tests for app_icons.fit: every glyph fills exactly the same circle."""
from __future__ import annotations
from pathlib import Path
import pytest
from python_pkg.app_icons import apps, fit, glyphs, render, style
from python_pkg.app_icons.glyphs import Glyph
SMALL = Glyph("small", "a short bar", ' <path d="M 480 512 L 544 512"/>')
NESTED = Glyph(
"nested",
"inner group with its own scale",
' <g transform="translate(400,400) scale(10)" stroke-width="{{STROKE/10}}">'
'<path d="M 0 0 L 20 0"/></g>',
)
def _rendered_radius(glyph: Glyph, tmp_path: Path) -> tuple[float, float, float]:
placement = fit.placement(glyph, "#FFFFFF")
png = tmp_path / f"{glyph.name}.png"
svg = render.build_svg(glyph, "#FFFFFF", with_background=False, offset=placement)
render.rasterise(svg, png, style.CANVAS)
return fit.ink_geometry(png)
class TestPlacement:
@pytest.mark.parametrize("key", sorted(apps.APPS))
def test_every_app_hits_the_family_radius(self, key: str, tmp_path: Path) -> None:
glyph = glyphs.get_glyph(apps.get_app(key).glyph)
radius, cx, cy = _rendered_radius(glyph, tmp_path)
assert radius == pytest.approx(style.INK_RADIUS, rel=0.01)
assert cx == pytest.approx(style.CENTRE, abs=2)
assert cy == pytest.approx(style.CENTRE, abs=2)
def test_tiny_glyph_is_scaled_up(self, tmp_path: Path) -> None:
radius, _cx, _cy = _rendered_radius(SMALL, tmp_path)
assert radius == pytest.approx(style.INK_RADIUS, rel=0.01)
def test_stroke_weight_is_not_scaled(self) -> None:
placement = fit.placement(SMALL, "#FFFFFF")
svg = render.build_svg(
SMALL, "#FFFFFF", with_background=False, offset=placement
)
assert placement.scale > 1
assert f'stroke-width="{style.STROKE_WIDTH / placement.scale:g}"' in svg
def test_nested_scale_marker_is_compensated(self) -> None:
placement = render.Placement(0, 0, 2)
svg = render.build_svg(
NESTED, "#FFFFFF", with_background=False, offset=placement
)
assert f'stroke-width="{style.STROKE_WIDTH / 20:g}"' in svg
assert "{{STROKE" not in svg
class TestInkGeometry:
def test_blank_image_is_zero(self, tmp_path: Path) -> None:
png = tmp_path / "blank.png"
render.rasterise(
render.build_svg(Glyph("none", "", ""), "#FFFFFF", with_background=False),
png,
64,
)
assert fit.ink_geometry(png) == (0.0, 0.0, 0.0)
def test_empty_glyph_raises(self) -> None:
with pytest.raises(render.EmptyGlyphError) as excinfo:
fit.placement(Glyph("none", "", ""), "#FFFFFF")
assert excinfo.value.name == "none"
def test_round_cap_returns_best_effort(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(fit, "_MAX_ROUNDS", 1)
assert fit.placement(SMALL, "#FFFFFF").scale > 1
def test_ink_circle_is_inside_android_visible_zone() -> None:
# Android guarantees a 66dp circle (radius 33dp) survives every mask.
assert pytest.approx(24.39, abs=0.01) == style.INK_RADIUS_DP
assert style.INK_RADIUS_DP < 33

View File

@ -6,7 +6,7 @@ import math
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from python_pkg.app_icons import generate, style from python_pkg.app_icons import generate, render, style
from python_pkg.app_icons.apps import AppIcon from python_pkg.app_icons.apps import AppIcon
if TYPE_CHECKING: if TYPE_CHECKING:
@ -28,7 +28,7 @@ def _app(tmp_path: Path, *, linux: bool = False) -> AppIcon:
class TestWriteAssets: class TestWriteAssets:
@patch(f"{MOD}.render.rasterise") @patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(1.0, 2.0)) @patch(f"{MOD}.fit.placement", return_value=render.Placement(1.0, 2.0))
def test_writes_three_svg_png_pairs( def test_writes_three_svg_png_pairs(
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
) -> None: ) -> None:
@ -39,7 +39,7 @@ class TestWriteAssets:
assert raster.call_count == 3 assert raster.call_count == 3
@patch(f"{MOD}.render.rasterise") @patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY)
def test_only_the_base_layer_has_the_field( def test_only_the_base_layer_has_the_field(
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
) -> None: ) -> None:
@ -51,7 +51,7 @@ class TestWriteAssets:
assert style.BACKGROUND not in foreground assert style.BACKGROUND not in foreground
@patch(f"{MOD}.render.rasterise") @patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY)
def test_monochrome_layer_is_white( def test_monochrome_layer_is_white(
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
) -> None: ) -> None:
@ -64,7 +64,7 @@ class TestWriteAssets:
class TestWriteLinuxIcons: class TestWriteLinuxIcons:
@patch(f"{MOD}.render.rasterise") @patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY)
def test_one_png_per_hicolor_size( def test_one_png_per_hicolor_size(
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
) -> None: ) -> None:
@ -76,7 +76,7 @@ class TestWriteLinuxIcons:
class TestWriteWebIcons: class TestWriteWebIcons:
@patch(f"{MOD}.render.rasterise") @patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY)
def test_replaces_the_flutter_web_template_icons( def test_replaces_the_flutter_web_template_icons(
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
) -> None: ) -> None:

View File

@ -0,0 +1,42 @@
"""Tests for app_icons.labels: the launcher-name rules every app must meet."""
from __future__ import annotations
import pytest
from python_pkg.app_icons import apps, labels
class TestLabelProblems:
@pytest.mark.parametrize("name", ["Todo", "BookGuard", "WakeAlarm", "Focuser", "A"])
def test_valid(self, name: str) -> None:
assert labels.label_problems(name) == []
@pytest.mark.parametrize(
("name", "fragment"),
[
("", "empty"),
("todo", "capital"),
("Book Guard", "ASCII letters"),
("diet_guard", "ASCII letters"),
("Umbrella?", "ASCII letters"),
("Żółw", "ASCII letters"),
("SignalBotRemote", "characters"),
("FocusOwner", "launcher font"),
("WakeAlarmX", "launcher font"),
],
)
def test_invalid(self, name: str, fragment: str) -> None:
assert any(fragment in p for p in labels.label_problems(name))
class TestRegistryLabels:
def test_every_app_has_a_label(self) -> None:
assert set(labels.LABELS) == set(apps.APPS)
@pytest.mark.parametrize("key", sorted(apps.APPS))
def test_label_is_valid(self, key: str) -> None:
assert labels.label_problems(apps.get_app(key).label) == []
def test_labels_are_unique(self) -> None:
assert len(set(labels.LABELS.values())) == len(labels.LABELS)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from python_pkg.app_icons import preview from python_pkg.app_icons import preview, render
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -16,7 +16,7 @@ MOD = "python_pkg.app_icons.preview"
class TestBuildContactSheet: class TestBuildContactSheet:
@patch(f"{MOD}.subprocess.run") @patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.render.rasterise") @patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY)
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick") @patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
def test_renders_one_tile_per_app_and_three_rows( def test_renders_one_tile_per_app_and_three_rows(
self, self,
@ -37,7 +37,7 @@ class TestBuildContactSheet:
@patch(f"{MOD}.subprocess.run") @patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.render.rasterise") @patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY)
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick") @patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
def test_final_append_is_vertical( def test_final_append_is_vertical(
self, self,

View File

@ -2,9 +2,34 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from PIL import Image
import pytest import pytest
from python_pkg.app_icons import apps, glyphs, style from python_pkg.app_icons import apps, fit, glyphs, render, style
if TYPE_CHECKING:
from pathlib import Path
def _centre_column_gaps(glyph_name: str, tmp_path: Path) -> list[int]:
"""Render a glyph at its FINAL fitted scale and list the transparent runs
between inked runs down the centre column, in pixels of the 1024 canvas.
"""
glyph = glyphs.get_glyph(glyph_name)
placement = fit.placement(glyph, "#FFFFFF")
png = tmp_path / f"{glyph_name}.png"
svg = render.build_svg(glyph, "#FFFFFF", with_background=False, offset=placement)
render.rasterise(svg, png, style.CANVAS)
with Image.open(png) as image:
alpha = np.asarray(image.convert("RGBA"))[:, int(style.CENTRE), 3]
ink = alpha > 127
edges = np.flatnonzero(np.diff(ink.astype(np.int8)))
# edges alternate ink-end / ink-start once past the first ink run.
ends, starts = edges[1::2], edges[2::2]
return [int(start - end) for end, start in zip(ends, starts, strict=False)]
class TestGlyphLookup: class TestGlyphLookup:
@ -106,3 +131,31 @@ class TestHeadphonesGlyph:
body = glyphs.get_glyph("headphones").body body = glyphs.get_glyph("headphones").body
assert f'fill="{style.ACCENT_MARKER}"' in body assert f'fill="{style.ACCENT_MARKER}"' in body
assert 'stroke="none"' in body assert 'stroke="none"' in body
class TestPromptBubbleGlyph:
def test_sufler_uses_it(self) -> None:
app = apps.get_app("sufler")
assert app.glyph == "prompt-bubble"
assert app.linux
def test_negative_space_at_final_scale(self, tmp_path: Path) -> None:
"""Bubble top, two text lines, bubble bottom: three gaps, none merging."""
gaps = _centre_column_gaps("prompt-bubble", tmp_path)
assert len(gaps) == 3
assert min(gaps) >= style.MIN_NEGATIVE_SPACE
class TestCameraGlyph:
def test_macro_cam_uses_it(self) -> None:
app = apps.get_app("macro_cam")
assert app.glyph == "camera"
assert not app.linux
def test_negative_space_at_final_scale(self, tmp_path: Path) -> None:
"""Hump->lens, lens hole, lens->body bottom, measured after the fit
shrinks the glyph: the authoring-space gaps alone would overstate them.
"""
gaps = _centre_column_gaps("camera", tmp_path)
assert len(gaps) == 3
assert min(gaps) >= style.MIN_NEGATIVE_SPACE

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@ -41,8 +40,11 @@ class TestBuildSvg:
assert style.BACKGROUND not in svg assert style.BACKGROUND not in svg
def test_offset_is_applied(self) -> None: def test_offset_is_applied(self) -> None:
svg = render.build_svg(DOT, "#123456", with_background=False, offset=(3.5, -7)) svg = render.build_svg(
assert 'transform="translate(3.5,-7)"' in svg DOT, "#123456", with_background=False, offset=render.Placement(3.5, -7, 2)
)
assert 'transform="translate(3.5,-7) scale(2)"' in svg
assert f'stroke-width="{style.STROKE_WIDTH / 2:g}"' in svg
def test_accent_token_is_substituted(self) -> None: def test_accent_token_is_substituted(self) -> None:
svg = render.build_svg(FILLED, "#ABCDEF", with_background=False) svg = render.build_svg(FILLED, "#ABCDEF", with_background=False)
@ -63,47 +65,3 @@ class TestRasterise:
assert argv[0] == "/usr/bin/rsvg-convert" assert argv[0] == "/usr/bin/rsvg-convert"
assert "256" in argv assert "256" in argv
assert run.call_args.kwargs["input"] == b"<svg/>" 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)