mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 17:47:05 +01:00
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
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:
parent
92aa2f971d
commit
35b497b409
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -21,7 +21,8 @@ PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \
|
||||
generate --app todo --android --linux-out ~/src/todo/linux/icons
|
||||
|
||||
# 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 \
|
||||
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
|
||||
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.
|
||||
- **Every glyph fills the same circle.** `fit.placement` centres the ink and
|
||||
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
|
||||
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.
|
||||
- Glyph position and size inside the fragment are irrelevant. Only the shape
|
||||
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
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.app_icons.labels import LABELS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
@ -39,3 +41,8 @@ class AppIcon:
|
||||
def web_dir(self) -> Path:
|
||||
"""Return the Flutter ``web/`` directory holding favicon and PWA icons."""
|
||||
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]
|
||||
|
||||
@ -33,4 +33,23 @@ MORE_APPS: Final[tuple[AppIcon, ...]] = (
|
||||
# window (see the repo's SPEC.md), so it gets the hicolor set too.
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
@ -41,3 +41,42 @@ _HEADPHONES = """\
|
||||
fill="{{ACCENT}}" stroke="none"/>
|
||||
<rect x="656" y="520" width="112" height="216" rx="40" \
|
||||
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"/>"""
|
||||
|
||||
30
python_pkg/app_icons/_label_widths.py
Normal file
30
python_pkg/app_icons/_label_widths.py
Normal 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)
|
||||
@ -12,7 +12,7 @@ import argparse
|
||||
from pathlib import Path
|
||||
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:
|
||||
@ -173,13 +173,6 @@ def _cmd_generate(
|
||||
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}")
|
||||
|
||||
95
python_pkg/app_icons/fit.py
Normal file
95
python_pkg/app_icons/fit.py
Normal 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
|
||||
@ -21,7 +21,7 @@ import re
|
||||
import subprocess
|
||||
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
|
||||
# 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.
|
||||
"""
|
||||
glyph = glyphs.get_glyph(app.glyph)
|
||||
offset = render.centre_offset(glyph, app.accent)
|
||||
offset = fit.placement(glyph, app.accent)
|
||||
written: list[Path] = []
|
||||
for stem, with_background, colour in _LAYERS:
|
||||
svg_text = render.build_svg(
|
||||
@ -94,7 +94,7 @@ def write_linux_icons(app: AppIcon, destination: Path) -> list[Path]:
|
||||
list[Path]: Every PNG written.
|
||||
"""
|
||||
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)
|
||||
written: list[Path] = []
|
||||
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.
|
||||
|
||||
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
|
||||
circle mask never clips the glyph.
|
||||
|
||||
@ -119,7 +119,7 @@ def write_web_icons(app: AppIcon) -> list[Path]:
|
||||
list[Path]: Every PNG written.
|
||||
"""
|
||||
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)
|
||||
written: list[Path] = []
|
||||
for relative, size in _WEB_ICONS:
|
||||
|
||||
@ -29,7 +29,12 @@ from python_pkg.app_icons._glyph_art import (
|
||||
_SHIELD_CUTLERY,
|
||||
_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 (
|
||||
_BOWL_STAR,
|
||||
_HOURGLASS,
|
||||
@ -101,6 +106,12 @@ GLYPHS: Final[dict[str, Glyph]] = {
|
||||
),
|
||||
Glyph("lightbulb", "Filled lightbulb over a two-bar screw base", _LIGHTBULB),
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
84
python_pkg/app_icons/labels.py
Normal file
84
python_pkg/app_icons/labels.py
Normal 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
|
||||
@ -12,7 +12,7 @@ from pathlib import Path
|
||||
import subprocess
|
||||
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.
|
||||
_TILE = 512
|
||||
@ -122,7 +122,7 @@ def build_contact_sheet(keys: list[str], destination: Path) -> Path:
|
||||
for key in keys:
|
||||
app = apps.get_app(key)
|
||||
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
|
||||
)
|
||||
|
||||
@ -1,30 +1,52 @@
|
||||
"""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.
|
||||
Glyphs are hand-authored, so their ink never lands on the canvas centre and
|
||||
never spans the same circle. :mod:`python_pkg.app_icons.fit` measures each one
|
||||
and returns a :class:`Placement`; :func:`build_svg` applies it. The fragment is
|
||||
translated and scaled, but the group's stroke width is divided by the scale,
|
||||
so every stroked icon keeps exactly the family stroke weight. A fragment with
|
||||
its own inner ``scale(k)`` writes ``stroke-width="{{STROKE/k}}"`` and gets the
|
||||
same compensation. Filled shapes have no stroke and simply scale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.app_icons import style
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
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):
|
||||
"""Raised when a required external rasterisation tool is not installed."""
|
||||
|
||||
@ -74,7 +96,7 @@ def build_svg(
|
||||
accent: str,
|
||||
*,
|
||||
with_background: bool,
|
||||
offset: tuple[float, float] = (0.0, 0.0),
|
||||
offset: Placement = IDENTITY,
|
||||
) -> str:
|
||||
"""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"``.
|
||||
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`.
|
||||
offset (Placement): Transform applied to the fragment, normally the
|
||||
result of :func:`python_pkg.app_icons.fit.placement`.
|
||||
|
||||
Returns:
|
||||
str: A standalone SVG document.
|
||||
"""
|
||||
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 = (
|
||||
f'\n <rect width="{style.CANVAS}" height="{style.CANVAS}" '
|
||||
f'fill="{style.BACKGROUND}"/>'
|
||||
@ -101,9 +126,10 @@ def build_svg(
|
||||
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' <g fill="none" stroke="{accent}" stroke-width="{stroke:g}" '
|
||||
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" </g>\n"
|
||||
f"</svg>\n"
|
||||
@ -133,71 +159,3 @@ def rasterise(svg_text: str, destination: Path, size: int) -> None:
|
||||
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)
|
||||
|
||||
@ -19,11 +19,24 @@ CENTRE: Final[float] = CANVAS / 2
|
||||
# cool-tinted #1B1D21, so relative lightness is unchanged.
|
||||
BACKGROUND: Final[str] = "#211D1B"
|
||||
|
||||
# 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.
|
||||
# Authoring box for glyph fragments. The renderer rescales every glyph to
|
||||
# INK_RADIUS anyway, so this only keeps hand-written coordinates comparable.
|
||||
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
|
||||
# or above STROKE_WIDTH / 2, otherwise adjacent strokes merge into a blob when
|
||||
# the icon is scaled down to launcher size.
|
||||
|
||||
@ -41,48 +41,31 @@ class TestPreviewCommand:
|
||||
|
||||
|
||||
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:
|
||||
@ -103,12 +86,10 @@ class TestGenerateCommand:
|
||||
|
||||
class TestWebFlag:
|
||||
@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=[])
|
||||
def test_writes_into_an_existing_web_dir(
|
||||
self,
|
||||
mock_write: MagicMock,
|
||||
mock_overflow: MagicMock,
|
||||
web: MagicMock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
@ -118,12 +99,10 @@ class TestWebFlag:
|
||||
assert "wrote 1 web icons" in capsys.readouterr().out
|
||||
|
||||
@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=[])
|
||||
def test_skips_apps_without_a_web_dir(
|
||||
self,
|
||||
mock_write: MagicMock,
|
||||
mock_overflow: MagicMock,
|
||||
web: MagicMock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
|
||||
84
python_pkg/app_icons/tests/test_fit.py
Normal file
84
python_pkg/app_icons/tests/test_fit.py
Normal 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
|
||||
@ -6,7 +6,7 @@ import math
|
||||
from typing import TYPE_CHECKING
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -28,7 +28,7 @@ def _app(tmp_path: Path, *, linux: bool = False) -> AppIcon:
|
||||
|
||||
class TestWriteAssets:
|
||||
@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(
|
||||
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
@ -39,7 +39,7 @@ class TestWriteAssets:
|
||||
assert raster.call_count == 3
|
||||
|
||||
@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(
|
||||
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
@ -51,7 +51,7 @@ class TestWriteAssets:
|
||||
assert style.BACKGROUND not in foreground
|
||||
|
||||
@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(
|
||||
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
@ -64,7 +64,7 @@ class TestWriteAssets:
|
||||
|
||||
class TestWriteLinuxIcons:
|
||||
@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(
|
||||
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
@ -76,7 +76,7 @@ class TestWriteLinuxIcons:
|
||||
|
||||
class TestWriteWebIcons:
|
||||
@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(
|
||||
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
42
python_pkg/app_icons/tests/test_labels.py
Normal file
42
python_pkg/app_icons/tests/test_labels.py
Normal 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)
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.app_icons import preview
|
||||
from python_pkg.app_icons import preview, render
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -16,7 +16,7 @@ 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}.fit.placement", return_value=render.IDENTITY)
|
||||
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
|
||||
def test_renders_one_tile_per_app_and_three_rows(
|
||||
self,
|
||||
@ -37,7 +37,7 @@ 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}.fit.placement", return_value=render.IDENTITY)
|
||||
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
|
||||
def test_final_append_is_vertical(
|
||||
self,
|
||||
|
||||
@ -2,9 +2,34 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
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:
|
||||
@ -106,3 +131,31 @@ class TestHeadphonesGlyph:
|
||||
body = glyphs.get_glyph("headphones").body
|
||||
assert f'fill="{style.ACCENT_MARKER}"' 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
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@ -41,8 +40,11 @@ class TestBuildSvg:
|
||||
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
|
||||
svg = render.build_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:
|
||||
svg = render.build_svg(FILLED, "#ABCDEF", with_background=False)
|
||||
@ -63,47 +65,3 @@ class TestRasterise:
|
||||
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