From 2292bf700d28ade39cf2d64b45509e6db21034bc Mon Sep 17 00:00:00 2001 From: cirakg Date: Sun, 18 Oct 2026 21:10:37 +0200 Subject: test(tools): add screenshot, budget and product checks; enforce file headers - sim_screenshot.py finds the device drawing in the simulator window, crops the screen and compares it with approved golden images. - check_budgets.py reads key=value DIAG/HARNESS lines (the simulator console escapes quotes, so JSON was dropped). - ciq_test_report.py accepts SDK 9.2.0's real summary line `PASSED (passed=N, failed=N, errors=N)`; a captured log is a fixture. - gen_products.py checks all manifest products are watches with API 5.0+ and at least 512 KB for apps. - lint_project.py rule HDR001: every file carries the Author/Summary header (JSON keys, PNG text chunks; generated package-lock exempt). - make_icon.py --all draws one icon per launcher size in use. - Golden screenshots for fr255s, venusq2, fr70 and fr965. - 44 tests, warnings as errors, ruff and mypy --strict clean. Co-Authored-By: Claude Opus 5.5 --- tools/make_icon.py | 118 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 11 deletions(-) (limited to 'tools/make_icon.py') diff --git a/tools/make_icon.py b/tools/make_icon.py index 6bb1cbf..f05d0c9 100644 --- a/tools/make_icon.py +++ b/tools/make_icon.py @@ -1,28 +1,50 @@ #!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Draw the launcher icon at every size the target watches need, plus the jungle that maps devices to sizes. """Draw the app's launcher icon: a white padlock with three "code" dots on a teal disc, on a transparent background. The icon is drawn at 8x the target size and scaled down, which gives smooth -edges at the small sizes watches use (35 to 70 px). +edges at the small sizes watches use (38 to 70 px). Every PNG carries the +project's Author and a Description in PNG text chunks. -Usage: make_icon.py --size 60 --out resources/drawables/launcher_icon.png +Two modes: + + make_icon.py --size 60 --out FILE.png + One icon of the given size. + + make_icon.py --all --devices DIR --manifest manifest.xml + One icon per launcher-icon size used by the products in manifest.xml + (read from each device's compiler.json), written to + resources-icons/x/, plus icons.jungle, which points each product + at its folder. The Monkey C compiler warns when it has to scale an + icon, and warnings fail the build, so every device needs an exact size. """ from __future__ import annotations import argparse +import json +import xml.etree.ElementTree as ET +from collections import defaultdict from pathlib import Path from PIL import Image, ImageDraw +from PIL.PngImagePlugin import PngInfo +AUTHOR = "cirakg & claude ai" +DESCRIPTION = "OTP Wallet launcher icon: a padlock with a one-time code, on a teal disc." TEAL = (14, 124, 134, 255) WHITE = (255, 255, 255, 255) SUPERSAMPLE = 8 +IQ_NS = "{http://www.garmin.com/xml/connectiq}" -def draw_icon(size: int) -> Image.Image: - """Return the icon as an RGBA image of size x size pixels.""" - s = size * SUPERSAMPLE +def draw_icon(width: int, height: int | None = None) -> Image.Image: + """Return the icon as an RGBA image; non-square sizes get a centred square icon.""" + height = width if height is None else height + side = min(width, height) + s = side * SUPERSAMPLE img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) d = ImageDraw.Draw(img) @@ -49,18 +71,92 @@ def draw_icon(size: int) -> Image.Image: center_x = s * (0.38 + 0.12 * i) d.ellipse((center_x - radius, center_y - radius, center_x + radius, center_y + radius), fill=TEAL) - return img.resize((size, size), Image.Resampling.LANCZOS) + icon = img.resize((side, side), Image.Resampling.LANCZOS) + if (width, height) == (side, side): + return icon + canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + canvas.paste(icon, ((width - side) // 2, (height - side) // 2)) + return canvas + + +def save_png(img: Image.Image, path: Path) -> None: + """Save with the project's Author and Description text chunks.""" + info = PngInfo() + info.add_text("Author", AUTHOR) + info.add_text("Description", DESCRIPTION) + path.parent.mkdir(parents=True, exist_ok=True) + img.save(path, format="PNG", optimize=True, pnginfo=info) + + +def manifest_products(manifest: Path) -> list[str]: + """Product IDs listed in manifest.xml.""" + root = ET.parse(manifest).getroot() + return [p.attrib["id"] for p in root.iter(f"{IQ_NS}product")] + + +def icon_sizes(devices: Path, products: list[str]) -> dict[tuple[int, int], list[str]]: + """Group products by the launcher-icon size in their compiler.json.""" + groups: dict[tuple[int, int], list[str]] = defaultdict(list) + for product in products: + compiler = json.loads((devices / product / "compiler.json").read_text(encoding="utf-8")) + icon = compiler["launcherIcon"] + groups[(int(icon["width"]), int(icon["height"]))].append(product) + return dict(sorted(groups.items())) + + +DRAWABLES_XML = """\ + + + + +""" + +JUNGLE_HEADER = """\ +# Author: cirakg & claude ai +# Summary: Per-device launcher-icon resources; generated by tools/make_icon.py --all (do not edit). +# +# Each product gets the icon folder matching the launcher-icon size in its +# device definition, so the compiler never has to scale (and warn). +""" + + +def write_all(devices: Path, manifest: Path, out_dir: Path, jungle: Path) -> None: + groups = icon_sizes(devices, manifest_products(manifest)) + lines = [JUNGLE_HEADER] + for (w, h), products in groups.items(): + folder = out_dir / f"{w}x{h}" + save_png(draw_icon(w, h), folder / "launcher_icon.png") + (folder / "drawables.xml").write_text(DRAWABLES_XML.format(w=w, h=h), encoding="utf-8") + lines += [f"{p}.resourcePath = $({p}.resourcePath);{folder.as_posix()}" for p in sorted(products)] + print(f"{w}x{h}: {len(products)} devices") + jungle.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {jungle} and {len(groups)} icon folders under {out_dir}") def main() -> None: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--size", type=int, default=60, help="icon width and height in pixels") - parser.add_argument("--out", type=Path, required=True, help="PNG file to write") + parser = argparse.ArgumentParser(description="Draw the OTP Wallet launcher icon(s).") + parser.add_argument("--size", type=int, default=60, help="single icon: width and height in pixels") + parser.add_argument("--out", type=Path, help="single icon: PNG file to write") + parser.add_argument("--all", action="store_true", help="every size used by the manifest's products") + parser.add_argument("--devices", type=Path, help="device definitions (~/.Garmin/ConnectIQ/Devices)") + parser.add_argument("--manifest", type=Path, default=Path("manifest.xml")) + parser.add_argument("--out-dir", type=Path, default=Path("resources-icons")) + parser.add_argument("--jungle", type=Path, default=Path("icons.jungle")) args = parser.parse_args() + + if args.all: + if args.devices is None: + parser.error("--all needs --devices") + write_all(args.devices, args.manifest, args.out_dir, args.jungle) + return + if args.out is None: + parser.error("give --out (single icon) or --all") if not 16 <= args.size <= 256: parser.error("--size must be between 16 and 256") - args.out.parent.mkdir(parents=True, exist_ok=True) - draw_icon(args.size).save(args.out, format="PNG", optimize=True) + save_png(draw_icon(args.size), args.out) print(f"wrote {args.out} ({args.size}x{args.size})") -- cgit v1.2.3