`HostRow.os` has been plumbed since the model landed, with a comment saying the drawing was a follow-up because "the Skia glyph set doesn't exist yet". It does exist: assets/os-icons ships thirteen licensed masters, and `pf_client_core::os::os_icon_tokens` already resolves a chain to them - walking most-specific-first and applying the brand aliases (`macos` -> `apple`, `steamos` -> `steam`). Every other front-end walks that same list. So the console takes the shared resolver rather than inventing one, and gets its table GENERATED from the masters (`scripts/gen_os_mark_table.py`, hooked into the existing `gen-os-icons.sh`) rather than hand-transcribed. Thirteen paths of up to 3.5 kB where one mangled character is a silently wrong logo is not work for a human, which is precisely the reasoning the launcher-icon tables already carry. A new master now reaches the console for free; the script's closing note says so. Two corrections to the plan this implements, both found in the code: - The chain is SLASH-separated and resolves most-specific-FIRST, not "the first known token of a `;`-chain". A `linux/fedora/bazzite` host draws Bazzite, and falls back through Fedora to Tux - so the console is right about thirteen distros rather than the four the plan scoped. - The hint bar was already a glass pill, not "ink on the field". What it was missing is that it mixed its OWN glass (a flat wash and a hand-rolled stroke), making it the one floating surface that ignored the palette; it now goes through `theme::panel` like the chip and the toast, and picks up the lit edge. `draw_monogram` becomes `draw_badge`: the OS mark when the chain resolves, the initial when it doesn't. A substitution, not an addition - a badge showing both a Tux and an "L" says the same thing twice - and an older host that advertises no `os` keeps its monogram pixel for pixel. The controller chip gains a pad silhouette and a battery pip. `PadInfo` gets an additive `battery: Option<PadBattery>`; nothing crosses the wire, this is local SDL state. The plan expected to poll "on the existing pad-refresh cadence" - there isn't one, `publish()` is entirely event-driven (hotplug, pin change). And `pad_info` is deliberately open-free because an open GRABS the hardware, while SDL only reports power for an OPEN device. So the level is read from the ONE pad the service already holds open - `menu_open`, the nav pad, which is open exactly while a console is on screen and is the only pad any UI asks about - on a 15 s poll inside the loop that already wakes every 10 ms. Every other pad publishes `None`, which is the honest answer. `None` renders as no battery at all, never 0 %: a wired pad, a Steam virtual pad and SDL's `-1` "powered, level unknown" are all the same non-answer, and 0 % is the one reading that sends someone hunting for a charger. Charging outranks the low-charge red, because a pad at 4 % on the cable is not the problem a pad at 4 % off it is. Finally, Rescan: a second sentinel tile trailing Add Host, sending the `ConsoleCmd::Probe` that has existed unsent by any screen since it was written. A controller surface has no pull-to-refresh, so the affordance has to be a tile. The two trailing tiles are actions rather than hosts, so `hosts.get(i)` answering both "which host" and "which action" with one `None` became a `Slot` enum - with a second action tile that ambiguity is a bug waiting, and the test that matters is that an accidental A on the end of the strip can never start a session. Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings, plain build, 104 tests green.
186 lines
7.8 KiB
Python
186 lines
7.8 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: thirteen 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 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)
|
|
print(f" {OUT} ({len(body):,} bytes, {len(MARKS)} marks)")
|