Files
punktfunk/scripts/gen_os_mark_table.py
enricobuehler a67bff1324 fix(os-icons): rustfmt the generated console mark table
The template emitted an assert! on one line that rustfmt rewraps, so
every regeneration left crates/pf-console-ui/src/os_marks.rs dirty
against the workspace fmt gate. That gate blocks the next commit for
everyone, not only whoever ran the generator.

Run rustfmt on the output rather than hand-wrapping the template to
rustfmt's taste, so the next mark added cannot reintroduce it.
2026-08-31 13:31:17 +02:00

195 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Emit the Skia console's OS-mark table from the assets/os-icons masters.
crates/pf-console-ui/src/os_marks.rs in-session console UI, Skia Path::from_svg
The console parses SVG path data at runtime, so unlike the GTK/Windows/Apple clients it needs
no baked raster or PDF — it needs the path string. And unlike the three hand-kept inline
registries (web, Decky, Android), it is generated outright for the same reason the
launcher-icon tables are: a dozen-odd paths of up to 3.5 kB each, where one mangled character
is a silently wrong logo that no test would catch.
Always emits EVERY master, whatever tokens gen-os-icons.sh was invoked with — this is one file,
and a partial rewrite would drop the rest.
Usage: python3 scripts/gen_os_mark_table.py (from anywhere; paths are repo-relative)
"""
from __future__ import annotations
import pathlib
import re
import shutil
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
MASTERS = ROOT / "assets" / "os-icons"
OUT = "crates/pf-console-ui/src/os_marks.rs"
BANNER = (
"GENERATED by scripts/gen_os_mark_table.py from the assets/os-icons masters.\n"
"Do not edit by hand — re-run `bash scripts/gen-os-icons.sh` instead.\n"
"Per-mark provenance and licensing: assets/os-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'<path[^>]*\sd="([^"]+)"', svg)
if len(paths) != 1:
sys.exit(f"{token}: expected exactly one <path>, 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)
# Sorted so the emitted file has a stable order across runs and machines.
TOKENS = sorted(p.stem for p in MASTERS.glob("*.svg"))
MARKS = [mark(t) for t in TOKENS]
# `!r` on a float always writes a decimal point (`384.0`, never `384`), which matters: the
# table's type is `f32` and a bare integer literal is a type error, not a coercion.
rows = "\n".join(f' ("{t}", {w!r}, {h!r}, "{d}"),' for t, d, w, h in MARKS)
banner = "\n".join(f"//! {line}".rstrip() for line in BANNER.splitlines())
body = f"""{banner}
//!
//! The OS mark a host tile draws, resolved from the host's advertised OS-identity chain.
//!
//! The RESOLUTION is not ours: [`pf_client_core::os::os_icon_tokens`] walks the chain
//! most-specific-first and applies the brand aliases (`macos` → `apple`, `steamos` →
//! `steam`), and every front-end — GTK, WinUI, Swift, Kotlin, the web console — walks the
//! same list. That is the whole point of it living in the shared crate: a Bazzite host must
//! not draw Tux here and a Fedora hat there. All this module owns is which tokens it has
//! art for, and how the art is fitted.
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<String, Option<Glyph>>;
/// `(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.
///
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the home
/// carousel re-renders every frame while the cursor springs.
fn glyph(token: &str) -> Option<Glyph> {{
static CACHE: OnceLock<Mutex<GlyphCache>> = 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 an OS-identity `chain` (`"linux/fedora/bazzite"`), scaled to fit `dst` and
/// centred in it — aspect ratio preserved, because the masters' viewports are not all square.
///
/// `None` when the chain is empty, unknown, or made of tokens we ship no art for; the tile then
/// draws its monogram, exactly as every tile did before OS marks existed. A chain we only
/// partly know still resolves: `linux/fedora/bazzite` on a build shipping no Bazzite mark falls
/// to Fedora, then to Tux, because that is the order the shared resolver hands back.
pub(crate) fn os_mark(chain: &str, dst: Rect) -> Option<Path> {{
let (path, vw, vh) = pf_client_core::os::os_icon_tokens(chain)
.into_iter()
.find_map(|token| 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");
}}
}}
/// The chain resolves most-specific-first, through the shared resolver. `steamos` reaching
/// the Steam mark is the alias doing its job, not a coincidence of table order.
#[test]
fn chains_resolve_most_specific_first() {{
let dst = Rect::from_wh(64.0, 64.0);
for chain in [
"windows",
"linux",
"linux/arch/steamos",
"linux/fedora/bazzite",
"macos",
] {{
assert!(os_mark(chain, dst).is_some(), "{{chain}} resolved nothing");
}}
// A distro we ship no art for still lands on its family's mark.
let known = os_mark("linux/debian/raspbian", dst);
assert!(known.is_some(), "an unknown leaf must fall back to its family");
}}
/// An unknown or empty chain draws NOTHING, so the tile keeps its monogram — older hosts
/// advertise no `os` at all, and they must look exactly as they did.
#[test]
fn unknown_chain_draws_nothing() {{
let dst = Rect::from_wh(64.0, 64.0);
assert!(os_mark("", dst).is_none());
assert!(os_mark("plan9/glenda", dst).is_none());
// Untrusted mDNS input that sanitizes away entirely is the same case.
assert!(os_mark("!!!/???", dst).is_none());
}}
/// The mark is letterboxed into the destination, never stretched past it — the guarantee the
/// non-square viewports (apple is 384x512, windows 24x24) depend on.
#[test]
fn mark_is_contained_and_centred() {{
let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
let b = os_mark("apple", dst).unwrap().compute_tight_bounds();
assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
assert!((b.center_x() - dst.center_x()).abs() < 1.0);
assert!((b.center_y() - dst.center_y()).abs() < 1.0);
}}
}}
"""
path = ROOT / OUT
path.write_text(body)
# CI fmt-gates the whole tree, so a generator that emits anything rustfmt would rewrite leaves
# every regeneration one dirty file away from a blocked commit. Formatting the output here is
# what keeps this template from having to stay hand-wrapped to rustfmt's taste.
if shutil.which("rustfmt"):
subprocess.run(["rustfmt", "--edition", "2024", str(path)], check=True)
print(f" {OUT} ({path.stat().st_size:,} bytes, {len(MARKS)} marks)")