feat(console): host tiles get their OS mark, the chip gets a battery, the strip gets Rescan

`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.
This commit is contained in:
2026-08-16 15:36:36 +02:00
parent a289f7b9af
commit be8183caab
9 changed files with 788 additions and 62 deletions
+185
View File
@@ -0,0 +1,185 @@
#!/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)")