#!/usr/bin/env python3 """Emit the three INLINE launcher-icon registries from the assets/launcher-icons masters. The OS-icon pipeline prints its path data for a human to paste into each client. That is fine for a mark you add once a year; it is not fine here, where three clients each need seven paths of up to 3 kB and a single mangled character is a silently wrong logo. So these three files are generated outright, with their commentary baked in below: web/src/components/launcher-icon.tsx web console, inline SVG clients/android/.../components/LauncherIcons.kt Android, Compose ImageVector via PathParser crates/pf-console-ui/src/launcher_icons.rs in-session console UI, Skia Path::from_svg The baked derivatives (GTK / Windows / Apple) come from gen-launcher-icons.sh, which calls this. Usage: python3 scripts/gen_launcher_icon_tables.py (from anywhere; paths are repo-relative) """ from __future__ import annotations import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parent.parent MASTERS = ROOT / "assets" / "launcher-icons" # Registry order — the order a reader of any of the three files sees. Live tiles first, then the # marks that ship dormant (see the masters' README), so "what actually renders today" reads off # the top of the list. TOKENS = ["steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox"] BANNER = ( "GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.\n" "Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.\n" "Per-mark provenance and licensing: assets/launcher-icons/README.md." ) def mark(token: str) -> tuple[str, str, float, float]: """(token, path data, viewport width, viewport height) for one master.""" svg = (MASTERS / f"{token}.svg").read_text() box = re.search(r'viewBox="([^"]+)"', svg).group(1) paths = re.findall(r']*\sd="([^"]+)"', svg) if len(paths) != 1: sys.exit(f"{token}: expected exactly one , found {len(paths)}") d = paths[0] if any(c in d for c in "\n\t\"\\"): sys.exit(f"{token}: path data must be single-line and free of quotes/backslashes") _, _, w, h = box.split() return token, d, float(w), float(h) MARKS = [mark(t) for t in TOKENS] def comment(prefix: str) -> str: return "\n".join(f"{prefix} {line}".rstrip() for line in BANNER.splitlines()) def write(rel: str, body: str) -> None: p = ROOT / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(body) print(f" {rel} ({len(body):,} bytes)") # --- web console ----------------------------------------------------------------------------- rows = "\n".join( f'\t{t}: {{\n\t\tviewBox: "0 0 {w:g} {h:g}",\n\t\td: "{d}",\n\t}},' for t, d, w, h in MARKS ) write( "web/src/components/launcher-icon.tsx", f"""{comment("//")} // // The mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token. lucide // deliberately ships no brand marks, so this is a curated registry — the same shape as // os-icon.tsx, which does the equivalent job for the host cards' OS marks. import type {{ FC }} from "react"; /** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */ const LAUNCHER_ICONS: Record = {{ {rows} }}; /** * The mark for an entry's `icon` token, or null — render nothing — when the entry carries no * token or names one this console ships no art for. Callers fall back to the title, which is * what every launcher tile looked like before the token existed. * * The token is looked up in the shipped set, never interpolated into anything, so a host * sending something unexpected can only ever produce "no icon". */ export const LauncherIcon: FC<{{ \ticon?: string | null; \tclassName?: string; \tlabel?: string; }}> = ({{ icon, className, label }}) => {{ \tconst m = icon ? LAUNCHER_ICONS[icon] : undefined; \tif (!m) return null; \treturn ( \t\t \t\t\t{{label && {{label}}}} \t\t\t \t\t \t); }}; export default LauncherIcon; """, ) # --- Android --------------------------------------------------------------------------------- rows = "\n".join( f' "{t}" to LauncherGlyph(\n' f" viewportWidth = {w:g}f,\n" f" viewportHeight = {h:g}f,\n" f' d = "{d}",\n' f" )," for t, d, w, h in MARKS ) write( "clients/android/app/src/main/kotlin/io/unom/punktfunk/components/LauncherIcons.kt", f"""package io.unom.punktfunk.components {comment("//")} import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.unit.dp import kotlin.math.max /** * The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token. * Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons], * which does the equivalent job for the host cards. * * Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds * the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT * all square, so the builder letterboxes — a mark forced into a square box is a squashed mark. */ private class LauncherGlyph( val viewportWidth: Float, val viewportHeight: Float, val d: String, ) private val GLYPHS: Map = mapOf( {rows} ) private val CACHE = HashMap() /** * The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark * this build ships no art for — the caller then falls back to naming the launcher, which is * what every launcher tile looked like before the token existed. * * Tinted by the caller via `tint`, so one mark serves every palette. */ fun launcherIcon(token: String?): ImageVector? {{ val glyph = GLYPHS[token ?: return null] ?: return null return CACHE.getOrPut(token) {{ // Square the box and centre the mark in it, so a wide or tall master keeps its aspect // ratio instead of being stretched to the tile. val side = max(glyph.viewportWidth, glyph.viewportHeight) val dx = (side - glyph.viewportWidth) / 2f val dy = (side - glyph.viewportHeight) / 2f ImageVector.Builder( name = "launcher_$token", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = side, viewportHeight = side, ).apply {{ addGroup(translationX = dx, translationY = dy) addPath( pathData = PathParser().parsePathString(glyph.d).toNodes(), fill = SolidColor(Color.White), ) clearGroup() }}.build() }} }} """, ) # --- in-session console UI (Skia) -------------------------------------------------------------- rows = "\n".join( f' ("{t}", {w:g}.0, {h:g}.0, "{d}"),' for t, d, w, h in MARKS ) write( "crates/pf-console-ui/src/launcher_icons.rs", f"""{comment("//!")} //! //! The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token. //! Skia parses SVG path data directly, so the masters need no transcription into a drawing //! DSL — the path string is the asset. use skia_safe::{{Matrix, Path, Rect}}; use std::collections::HashMap; use std::sync::{{Mutex, OnceLock}}; /// A parsed mark and the viewport its coordinates are in. type Glyph = (Path, f32, f32); /// Token → parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not /// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this /// file is generated — an inline type would fail the `-D warnings` gate on every regeneration. type GlyphCache = HashMap>; /// `(token, viewport width, viewport height, path data)` — the masters, verbatim. const GLYPHS: &[(&str, f32, f32, &str)] = &[ {rows} ]; /// The parsed path for a token plus the viewport it was authored in, or `None` when the token is /// absent, unknown, or (defensively) unparseable — the tile then names its launcher instead, /// which is exactly how every launcher tile looked before icons existed. /// /// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the /// library shelf re-renders every frame while the cursor springs. fn glyph(token: &str) -> Option {{ static CACHE: OnceLock> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut cache = cache.lock().ok()?; if let Some(hit) = cache.get(token) {{ return hit.clone(); }} let built = GLYPHS .iter() .find(|(t, ..)| *t == token) .and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h))); cache.insert(token.to_string(), built.clone()); built }} /// The mark for `token`, scaled to fit `dst` and centred in it — aspect ratio preserved, because /// the masters' viewports are not all square. `None` when there is no mark to draw. pub(crate) fn launcher_mark(token: &str, dst: Rect) -> Option {{ let (path, vw, vh) = glyph(token)?; let scale = (dst.width() / vw).min(dst.height() / vh); let mut m = Matrix::new_identity(); m.set_scale((scale, scale), None); m.post_translate(( dst.left + (dst.width() - vw * scale) / 2.0, dst.top + (dst.height() - vh * scale) / 2.0, )); Some(path.with_transform(&m)) }} #[cfg(test)] mod tests {{ use super::*; /// Every shipped master parses. A mark that silently fails to parse is a tile that silently /// loses its icon, which no other test in this crate would notice. #[test] fn every_glyph_parses() {{ for (token, ..) in GLYPHS {{ assert!(glyph(token).is_some(), "{{token}} failed to parse"); }} }} #[test] fn unknown_token_draws_nothing() {{ assert!(launcher_mark("not-a-launcher", Rect::from_wh(64.0, 64.0)).is_none()); }} /// The mark is letterboxed into the destination, never stretched past it — the guarantee the /// non-square viewports (playnite is 1024x1024, steam 496x512) depend on. #[test] fn mark_is_contained_and_centred() {{ let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0); let b = launcher_mark("steam", dst).unwrap().compute_tight_bounds(); assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5); let (cx, cy) = (b.center_x(), b.center_y()); assert!((cx - dst.center_x()).abs() < 1.0, "off-centre horizontally: {{cx}}"); assert!((cy - dst.center_y()).abs() < 1.0, "off-centre vertically: {{cy}}"); }} }} """, )