From be8183caab75ebcd5c2ffabcfccda3ec0b193167 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 16 Aug 2026 15:36:36 +0200 Subject: [PATCH] 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`; 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. --- crates/pf-client-core/src/gamepad.rs | 107 ++++++++++++- crates/pf-console-ui/src/glyphs.rs | 133 ++++++++++++++-- crates/pf-console-ui/src/lib.rs | 2 + crates/pf-console-ui/src/os_marks.rs | 140 +++++++++++++++++ crates/pf-console-ui/src/screens/home.rs | 190 ++++++++++++++++++----- crates/pf-console-ui/src/shell/render.rs | 43 +++-- crates/pf-console-ui/src/shell/tests.rs | 37 +++++ scripts/gen-os-icons.sh | 13 +- scripts/gen_os_mark_table.py | 185 ++++++++++++++++++++++ 9 files changed, 788 insertions(+), 62 deletions(-) create mode 100644 crates/pf-console-ui/src/os_marks.rs create mode 100644 scripts/gen_os_mark_table.py diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 0c8ab65f..342138a3 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -96,6 +96,8 @@ const MENU_DEADZONE: u16 = 16384; const MENU_REPEAT_DELAY: Duration = Duration::from_millis(380); /// …and then repeats at this cadence until released or changed. const MENU_REPEAT_INTERVAL: Duration = Duration::from_millis(160); +/// How often the open pad's battery is re-read. See [`GamepadWorker::battery_poll`]. +const BATTERY_POLL: Duration = Duration::from_secs(15); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MenuDir { @@ -266,6 +268,47 @@ pub struct PadInfo { /// physical controller and has no sensors/touchpad, so auto-selection skips it while a real /// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode. pub steam_virtual: bool, + /// The pad's own power state, when it reports one. Purely LOCAL SDL state — nothing about + /// this crosses the wire, so it is additive with no ABI implication whatever. + /// + /// `None` is the common case, not an error: a wired pad has nothing to report, and Steam's + /// virtual gamepad reports nothing about the physical device behind it. Anything reading + /// this must degrade to "no battery shown" rather than to "0 %". + pub battery: Option, +} + +/// SDL's power report for an OPEN pad, reduced to the two facts a UI can act on. +/// +/// `None` folds together every "nothing useful to say" case: a wired pad with no battery at +/// all, an error, an unknown state, and the `-1` percentage SDL returns for "powered, level +/// unknown". A caller must draw NO battery for `None` — never 0 %, which is the one reading +/// that would send someone hunting for a charger. +fn battery_of(pad: &sdl3::gamepad::Gamepad) -> Option { + use sdl3::joystick::PowerLevel; + let info = pad.power_info(); + let charging = match info.state { + PowerLevel::OnBattery => false, + PowerLevel::Charging | PowerLevel::Charged => true, + PowerLevel::NoBattery | PowerLevel::Error | PowerLevel::Unknown => return None, + }; + if info.percentage < 0 { + return None; + } + Some(PadBattery { + percent: info.percentage.min(100) as u8, + charging, + }) +} + +/// A controller's power state, as SDL reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PadBattery { + /// 0–100. SDL gives −1 for "on power but level unknown", which callers map to `None` + /// rather than storing here. + pub percent: u8, + /// On the cable (or dock) right now. Worth showing separately: a pad at 4 % that is + /// charging is not the problem a pad at 4 % that is not. + pub charging: bool, } impl PadInfo { @@ -1100,6 +1143,11 @@ struct Worker { /// The ONE device held open for menu navigation while menu mode is on and NO session is /// attached (`active_id`); mutually exclusive with `slots` (a session supersedes the menu). menu_open: Option<(u32, sdl3::gamepad::Gamepad)>, + /// The menu pad's last-read power state, `(id, level)`. Cached rather than read in + /// [`publish`](Self::publish) because publish runs on every hotplug and pin change, + /// while the battery only wants looking at every few seconds. + battery: Option<(u32, PadBattery)>, + battery_at: Option, /// Connected pad ids in connection order (metadata only, no device open); the most /// recently connected is the auto selection. order: Vec, @@ -1211,6 +1259,9 @@ impl Worker { || name.starts_with("Steam Virtual Gamepad"), name, pref, + // Unknowable from an ID-based getter — SDL reports power only for an OPEN + // device. `publish` fills it in for the one pad this service holds open. + battery: None, }) } @@ -1897,14 +1948,63 @@ impl Worker { /// Publish the pad list, active pad, and pin to the UI-facing mutexes. fn publish(&self) { + // `pad_info` is deliberately open-free, and SDL only reports power for an OPEN + // device — so the battery is attached here, from the cache + // [`battery_poll`](Self::battery_poll) keeps for the one pad this service holds + // open. Every other pad publishes `None`, which is the honest answer: we cannot + // know without grabbing hardware that isn't ours to grab. + let with_battery = |id: u32| -> Option { + let mut info = self.pad_info(id)?; + if let Some((bid, b)) = self.battery { + if bid == id { + info.battery = Some(b); + } + } + Some(info) + }; let mut list: Vec = self .order .iter() - .filter_map(|&id| self.pad_info(id)) + .copied() + .filter_map(with_battery) .collect(); list.reverse(); // most recent first — the Settings list order *self.pads_out.lock().unwrap() = list; - *self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id)); + *self.active_out.lock().unwrap() = self.active_id().and_then(with_battery); + } + + /// Re-read the open pad's battery on a slow cadence, republishing only when it moved. + /// + /// Polled rather than event-driven because nothing reports a battery CHANGING — it + /// drifts, so the only way to show it is to look now and then. 15 s is far finer than a + /// percent takes to move and far coarser than anything the service's 10 ms loop would + /// notice; the read itself is a cached HID report, not a device transaction. + /// + /// Only ever the menu pad, which is the only one open while a console is on screen — + /// and the only one any UI asks about. + fn battery_poll(&mut self) { + let Some((id, pad)) = &self.menu_open else { + // Nothing open: forget the level rather than publish a stale one for a pad that + // may since have been unplugged. + if self.battery.take().is_some() { + self.publish(); + } + self.battery_at = None; + return; + }; + let now = Instant::now(); + if self + .battery_at + .is_some_and(|t| now.duration_since(t) < BATTERY_POLL) + { + return; + } + self.battery_at = Some(now); + let fresh = battery_of(pad).map(|b| (*id, b)); + if fresh != self.battery { + self.battery = fresh; + self.publish(); + } } /// Apply queued control-plane messages from the UI thread. Returns false when the @@ -2533,6 +2633,8 @@ impl Worker { active_out, slots: Vec::new(), menu_open: None, + battery: None, + battery_at: None, order: Vec::new(), pinned: None, forwarding: true, @@ -2617,6 +2719,7 @@ fn run( w.maybe_fire_disconnect(); w.menu_poll(); + w.battery_poll(); w.render_feedback(); } } diff --git a/crates/pf-console-ui/src/glyphs.rs b/crates/pf-console-ui/src/glyphs.rs index a0a02138..c40d845d 100644 --- a/crates/pf-console-ui/src/glyphs.rs +++ b/crates/pf-console-ui/src/glyphs.rs @@ -31,6 +31,112 @@ impl GlyphStyle { } } +/// A compact mark for WHAT is driving the console, drawn from `(x, cy)` across `w`: a +/// controller silhouette, or a keycap when there is no pad and the keyboard is doing the +/// work. Says at a glance which glyph set the legend below is speaking in. +pub(crate) fn pad_mark( + canvas: &Canvas, + style: GlyphStyle, + x: f64, + cy: f64, + w: f64, + k: f64, + ink: skia_safe::Color4f, +) { + let mut p = Paint::new(ink, None); + p.set_anti_alias(true); + if style == GlyphStyle::Keyboard { + // A keycap: the same shape the hint bar draws for a key, at chip size. + let h = w * 0.72; + let r = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32); + p.set_style(skia_safe::PaintStyle::Stroke); + p.set_stroke_width((1.3 * k) as f32); + canvas.draw_rrect( + RRect::new_rect_xy(r, (3.0 * k) as f32, (3.0 * k) as f32), + &p, + ); + return; + } + // A gamepad: a wide rounded body with a grip under each end. Detail beyond the + // silhouette is invisible at 15 dp, so there is none — the outline IS the glyph. + let h = w * 0.52; + let body = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32); + canvas.draw_rrect( + RRect::new_rect_xy(body, (h / 2.2) as f32, (h / 2.2) as f32), + &p, + ); + let grip = (w * 0.17) as f32; + canvas.draw_circle(((x + w * 0.2) as f32, (cy + h * 0.36) as f32), grip, &p); + canvas.draw_circle(((x + w * 0.8) as f32, (cy + h * 0.36) as f32), grip, &p); +} + +/// A four-segment charge pip, drawn from `(x, cy)` across `w`. Filled segments are the +/// charge; the outline is always the full cell, so "one bar" and "four bars" occupy the +/// same width and the chip never reflows as the pad drains. +/// +/// Three states, in priority order. CHARGING takes the palette's accent and outranks the +/// low warning, because a pad at 4 % on the cable is not the problem a pad at 4 % off it +/// is. Otherwise under 20 % goes red — a fixed red, not the accent, for the same reason the +/// error toast uses one: on a `moss` or `mint` field the accent is a colour that means +/// "fine". Everything else is plain foreground. +pub(crate) fn battery_pip( + canvas: &Canvas, + x: f64, + cy: f64, + w: f64, + k: f64, + b: pf_client_core::gamepad::PadBattery, +) { + let h = w * 0.5; + let cell = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, (w * 0.86) as f32, h as f32); + let ink = if b.charging { + crate::theme::accent(1.0) + } else if b.percent < 20 { + skia_safe::Color4f::new(0.93, 0.31, 0.28, 1.0) + } else { + crate::theme::fg(0.7) + }; + let mut outline = Paint::new(ink, None); + outline.set_anti_alias(true); + outline.set_style(skia_safe::PaintStyle::Stroke); + outline.set_stroke_width((1.2 * k) as f32); + let r = (2.0 * k) as f32; + canvas.draw_rrect(RRect::new_rect_xy(cell, r, r), &outline); + // The terminal nub, so the cell reads as a battery and not as a text field. + canvas.draw_rrect( + RRect::new_rect_xy( + Rect::from_xywh( + cell.right + (1.5 * k) as f32, + (cy - h * 0.22) as f32, + (1.8 * k) as f32, + (h * 0.44) as f32, + ), + r, + r, + ), + &Paint::new(ink, None), + ); + // Four segments, rounded UP so a pad with any charge left always shows at least one — + // an empty-looking cell on a pad that still works reads as broken. + let filled = ((f32::from(b.percent) / 100.0) * 4.0) + .ceil() + .clamp(0.0, 4.0) as i32; + let pad = (1.6 * k) as f32; + let seg_w = (cell.width() - 2.0 * pad) / 4.0; + for i in 0..filled { + let sx = cell.left + pad + i as f32 * seg_w; + canvas.draw_rect( + Rect::from_xywh( + sx + 0.4 * k as f32, + cell.top + pad, + seg_w - 0.8 * k as f32, + cell.height() - 2.0 * pad, + ), + &Paint::new(ink, None), + ); + } +} + /// What a hint's glyph depicts. `Key` renders a literal keycap chip in any style (used /// for keyboard fallbacks and the Deck's "Steam + X" keyboard chord). #[derive(Clone, Copy, PartialEq, Eq)] @@ -107,22 +213,27 @@ pub(crate) fn hint_bar( let h = BADGE_D * k + 2.0 * pad; let w = content_w + 2.0 * pad; let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32); + // A scrim under the glass, then the SHARED glass recipe — the legend used to mix its + // own (a flat `fg(0.06)` wash and a hand-rolled stroke), which meant it was the one + // floating surface in the console that didn't pick up the palette's glass. The scrim + // stays because this pill sits over the aurora at full contrast, where glass alone has + // little to separate it from the field. Same construction as the toast. + let corner = (h / 2.0 / k) as f32; canvas.draw_rrect( RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), &Paint::new(crate::theme::shade(0.30), None), ); - canvas.draw_rrect( - RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &Paint::new(fg(0.06), None), - ); - let mut sp = Paint::new(fg(0.12), None); - sp.set_style(skia_safe::PaintStyle::Stroke); - sp.set_stroke_width(1.0); - sp.set_anti_alias(true); - canvas.draw_rrect( - RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &sp, + crate::theme::panel( + canvas, + rect, + corner, + None, + crate::theme::PanelStroke::Plain(0.12), + k as f32, ); + // One lit edge per frame for the whole legend — the cost discipline the highlight is + // rationed by counts ROWS, and this is chrome. + crate::theme::panel_highlight(canvas, rect, corner, k as f32); let cy = bottom - h / 2.0; let mut pen = x + pad; diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index 560a6557..bb9ed4e0 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -23,6 +23,8 @@ pub mod library; #[cfg(any(target_os = "linux", windows))] pub mod model; #[cfg(any(target_os = "linux", windows))] +mod os_marks; +#[cfg(any(target_os = "linux", windows))] mod pointer; #[cfg(any(target_os = "linux", windows))] mod screens; diff --git a/crates/pf-console-ui/src/os_marks.rs b/crates/pf-console-ui/src/os_marks.rs new file mode 100644 index 00000000..2020e1cf --- /dev/null +++ b/crates/pf-console-ui/src/os_marks.rs @@ -0,0 +1,140 @@ +//! GENERATED by scripts/gen_os_mark_table.py from the assets/os-icons masters. +//! Do not edit by hand — re-run `bash scripts/gen-os-icons.sh` instead. +//! Per-mark provenance and licensing: assets/os-icons/README.md. +//! +//! 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>; + +/// `(token, viewport width, viewport height, path data)` — the masters, verbatim. +const GLYPHS: &[(&str, f32, f32, &str)] = &[ + ("apple", 384.0, 512.0, "M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"), + ("arch", 24.0, 24.0, "M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"), + ("bazzite", 24.0, 24.0, "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"), + ("cachyos", 24.0, 24.0, "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"), + ("debian", 24.0, 24.0, "M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"), + ("fedora", 448.0, 512.0, "M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"), + ("linux", 448.0, 512.0, "M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"), + ("nixos", 24.0, 24.0, "M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"), + ("nobara", 24.0, 24.0, "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"), + ("opensuse", 640.0, 512.0, "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"), + ("steam", 496.0, 512.0, "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"), + ("ubuntu", 496.0, 512.0, "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"), + ("windows", 24.0, 24.0, "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"), +]; + +/// 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 { + 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 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 { + 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); + } +} diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index 76c0bfd5..16e63c7b 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -25,6 +25,28 @@ const TILE_CORNER: f64 = 26.0; /// The Add Host tile's synthetic key (host keys are fingerprints or `addr:port`, /// neither starts with `\0`). const ADD_KEY: &str = "\0add"; +/// The Rescan tile's, the second sentinel after it. +const SCAN_KEY: &str = "\0scan"; + +/// What a carousel index actually is. The two trailing tiles are ACTIONS, not hosts, so +/// every place that used to ask "is there a host at this index?" asks this instead — the +/// old `hosts.get(i)` was already answering two questions with one `None`, and a second +/// action tile makes that ambiguity a bug. +enum Slot<'h> { + Host(&'h HostRow), + AddHost, + /// Ask the discovery sweep to look again. A controller surface has no pull-to-refresh, + /// so the affordance has to be a tile — the same reasoning the Apple client uses. + Rescan, +} + +fn slot_at(i: usize, hosts: &[HostRow]) -> Slot<'_> { + match hosts.get(i) { + Some(h) => Slot::Host(h), + None if i == hosts.len() => Slot::AddHost, + None => Slot::Rescan, + } +} pub(crate) struct HomeScreen { cursor: i32, @@ -61,7 +83,7 @@ impl HomeScreen { let keys: Vec = hosts .iter() .map(|h| h.key.clone()) - .chain(std::iter::once(ADD_KEY.to_string())) + .chain([ADD_KEY.to_string(), SCAN_KEY.to_string()]) .collect(); if keys != self.keys { let followed = self @@ -79,6 +101,15 @@ impl HomeScreen { hosts.get(self.cursor as usize) } + fn slot<'h>(&self, hosts: &'h [HostRow]) -> Slot<'h> { + slot_at(self.cursor.max(0) as usize, hosts) + } + + /// Tiles in the strip: every host, then Add Host, then Rescan. + fn len(hosts: &[HostRow]) -> usize { + hosts.len() + 2 + } + pub(crate) fn menu( &mut self, ev: MenuEvent, @@ -86,27 +117,32 @@ impl HomeScreen { fx: &mut Outbox, ) -> Option { self.reconcile(ctx.hosts); - let len = ctx.hosts.len() + 1; + let len = Self::len(ctx.hosts); match ev { MenuEvent::Move(MenuDir::Left) => self.step(-1, len, false), MenuEvent::Move(MenuDir::Right) => self.step(1, len, false), MenuEvent::JumpBack => self.step(-5, len, true), MenuEvent::JumpForward => self.step(5, len, true), MenuEvent::Confirm => { - match self.focused(ctx.hosts) { - None => fx.push(Screen::AddHost(super::add_host::AddHostScreen::new())), - Some(h) if !h.paired => fx.push(Screen::Pair(super::pair::PairScreen::new( - h, - ctx.device_name, - ))), - Some(h) if !h.online && h.can_wake => { + match self.slot(ctx.hosts) { + Slot::AddHost => { + fx.push(Screen::AddHost(super::add_host::AddHostScreen::new())) + } + Slot::Rescan => { + fx.cmds.push(ConsoleCmd::Probe); + fx.toast = Some("Scanning for hosts…".into()); + } + Slot::Host(h) if !h.paired => fx.push(Screen::Pair( + super::pair::PairScreen::new(h, ctx.device_name), + )), + Slot::Host(h) if !h.online && h.can_wake => { // Wake first; the wake overlay connects once it answers. fx.cmds.push(ConsoleCmd::Wake { key: h.key.clone(), then_connect: true, }); } - Some(h) => { + Slot::Host(h) => { // Dial-first even when the presence pips say offline — a // routed/VPN host is mDNS-blind and probe-shy but dials fine. // A pinned card connects with ITS profile (one-off, §5.2a); @@ -176,7 +212,7 @@ impl HomeScreen { /// safer read and the one a coverflow trains you to expect. pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { self.reconcile(ctx.hosts); - let len = ctx.hosts.len() + 1; + let len = Self::len(ctx.hosts); match p.kind { PointerKind::Scroll { up } => { self.step(if up { -1 } else { 1 }, len, false); @@ -218,13 +254,14 @@ impl HomeScreen { pub(crate) fn hints(&self, ctx: &Ctx) -> Vec { let mut hints = Vec::new(); - match self.focused(ctx.hosts) { - None => hints.push(Hint::new(HintKey::Confirm, "Add Host")), - Some(h) if !h.paired => hints.push(Hint::new(HintKey::Confirm, "Pair…")), - Some(h) if !h.online && h.can_wake => { + match self.slot(ctx.hosts) { + Slot::AddHost => hints.push(Hint::new(HintKey::Confirm, "Add Host")), + Slot::Rescan => hints.push(Hint::new(HintKey::Confirm, "Scan Again")), + Slot::Host(h) if !h.paired => hints.push(Hint::new(HintKey::Confirm, "Pair…")), + Slot::Host(h) if !h.online && h.can_wake => { hints.push(Hint::new(HintKey::Confirm, "Wake & Connect")) } - Some(_) => hints.push(Hint::new(HintKey::Confirm, "Connect")), + Slot::Host(_) => hints.push(Hint::new(HintKey::Confirm, "Connect")), } if self.focused(ctx.hosts).is_some_and(|h| h.paired && h.saved) { hints.push(Hint::new(HintKey::Secondary, "Library")); @@ -286,7 +323,7 @@ impl HomeScreen { let cx0 = f64::from(rect.left) + w / 2.0 + self.bump.pos * k; let cy = f64::from(rect.top) + f64::from(rect.height()) / 2.0; - let len = ctx.hosts.len() + 1; + let len = Self::len(ctx.hosts); self.geom.clear(); self.geom.resize(len, Rect::new_empty()); for i in 0..len { @@ -348,9 +385,10 @@ impl HomeScreen { 0.45 * f as f32, ); } - match ctx.hosts.get(i) { - Some(h) => draw_host_tile(canvas, fonts, h, tile, k, ctx.t), - None => draw_add_tile(canvas, fonts, tile, k), + match slot_at(i, ctx.hosts) { + Slot::Host(h) => draw_host_tile(canvas, fonts, h, tile, k, ctx.t), + Slot::AddHost => draw_action_tile(canvas, fonts, tile, k, ActionTile::AddHost), + Slot::Rescan => draw_action_tile(canvas, fonts, tile, k, ActionTile::Rescan), } // The veil, at HALF its old strength. It used to do the whole recede on its own // and had to be heavy for it; now the colour matrix above drains saturation and @@ -398,7 +436,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 crate::theme::panel_highlight(canvas, rect, TILE_CORNER as f32, k as f32); let pad = 20.0 * k; let (l, t) = (f64::from(rect.left) + pad, f64::from(rect.top) + pad); - draw_monogram(canvas, fonts, &h.name, h.saved, l, t, k); + draw_badge(canvas, fonts, &h.name, &h.os, h.saved, l, t, k); // Top-right status cluster: a lock for a paired identity, a glowing pip when live. let mut sx = f64::from(rect.right) - pad; @@ -512,7 +550,16 @@ fn accent_color(hex: Option<&str>) -> skia_safe::Color4f { ) } -fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { +/// The two action tiles trailing the strip. Same shape, same badge, different mark and +/// words — they are the same KIND of thing (something the console does, rather than +/// somewhere it goes), and drawing them alike is what says so. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ActionTile { + AddHost, + Rescan, +} + +fn draw_action_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64, kind: ActionTile) { crate::theme::panel( canvas, rect, @@ -524,7 +571,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { crate::theme::panel_highlight(canvas, rect, TILE_CORNER as f32, k as f32); let pad = 20.0 * k; let (l, t) = (f64::from(rect.left) + pad, f64::from(rect.top) + pad); - // The badge with a + instead of a monogram. + // The badge with a mark instead of a monogram. let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32); canvas.draw_rrect( RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32), @@ -545,22 +592,55 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { p.set_stroke_cap(skia_safe::PaintCap::Round); p.set_anti_alias(true); let r = 9.0 * k; - canvas.draw_line( - ((bcx - r) as f32, bcy as f32), - ((bcx + r) as f32, bcy as f32), - &p, - ); - canvas.draw_line( - (bcx as f32, (bcy - r) as f32), - (bcx as f32, (bcy + r) as f32), - &p, - ); + match kind { + ActionTile::AddHost => { + canvas.draw_line( + ((bcx - r) as f32, bcy as f32), + ((bcx + r) as f32, bcy as f32), + &p, + ); + canvas.draw_line( + (bcx as f32, (bcy - r) as f32), + (bcx as f32, (bcy + r) as f32), + &p, + ); + } + // A refresh arrow: three-quarters of a circle with a head on the open end. Drawn + // rather than spun — the sweep's progress is reported by the toast and by hosts + // appearing, and a permanently spinning tile would claim work that isn't running. + ActionTile::Rescan => { + let mut arc = PathBuilder::new(); + arc.add_arc( + Rect::from_xywh( + (bcx - r) as f32, + (bcy - r) as f32, + (2.0 * r) as f32, + (2.0 * r) as f32, + ), + -45.0, + 280.0, + ); + canvas.draw_path(&arc.detach(), &p); + let head = 4.6 * k; + let (hx, hy) = (bcx + r * 0.72, bcy - r * 0.72); + let mut tip = PathBuilder::new(); + tip.move_to(((hx - head) as f32, (hy - head * 0.2) as f32)); + tip.line_to(((hx + head * 0.5) as f32, (hy - head * 1.1) as f32)); + tip.line_to(((hx + head * 0.2) as f32, (hy + head * 0.7) as f32)); + tip.close(); + canvas.draw_path(&tip.detach(), &Paint::new(accent(1.0), None)); + } + } + let (title, sub) = match kind { + ActionTile::AddHost => ("Add Host", "Register a host by address"), + ActionTile::Rescan => ("Rescan", "Look for hosts on this network again"), + }; let max_w = f64::from(rect.width()) - 2.0 * pad; let sub_base = f64::from(rect.bottom) - pad; fonts.draw_clipped( canvas, - "Register a host by address", + sub, l, sub_base, W::Regular, @@ -570,7 +650,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { ); fonts.draw_clipped( canvas, - "Add Host", + title, l, sub_base - 22.0 * k, W::Bold, @@ -580,7 +660,28 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { ); } -fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f64, y: f64, k: f64) { +/// The tile's identity badge: the host's OS mark when its advertised chain resolves to one, +/// and its initial when it doesn't. +/// +/// The substitution, not an addition — a badge showing both a Tux and an "L" would say the +/// same thing twice. An older host advertises no `os` at all and an unknown chain resolves +/// to nothing, so both keep the monogram they have always drawn, pixel for pixel. +/// +/// Accessibility note for the ports: the mark carries no information the card doesn't +/// already state in words. The host's NAME is right beside it, and the OS is a property of +/// that name, so a reader that skips the badge loses nothing — which is why this is a +/// decorative substitution and not a labelled image. +#[allow(clippy::too_many_arguments)] +fn draw_badge( + canvas: &Canvas, + fonts: &Fonts, + name: &str, + os: &str, + filled: bool, + x: f64, + y: f64, + k: f64, +) { let badge = Rect::from_xywh(x as f32, y as f32, (52.0 * k) as f32, (52.0 * k) as f32); let rr = RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32); if filled { @@ -610,6 +711,21 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 ring.set_anti_alias(true); canvas.draw_rrect(rr, &ring); } + let ink = if filled { fg(1.0) } else { accent(1.0) }; + // Inset to ~54 % of the badge so the mark reads as a mark ON a badge rather than a + // cropped one; `os_mark` letterboxes inside that box, so a non-square master (apple is + // 384x512, windows 24x24) keeps its proportions. + let side = 28.0 * k; + let inner = Rect::from_xywh( + (x + 26.0 * k - side / 2.0) as f32, + (y + 26.0 * k - side / 2.0) as f32, + side as f32, + side as f32, + ); + if let Some(path) = crate::os_marks::os_mark(os, inner) { + canvas.draw_path(&path, &Paint::new(ink, None)); + return; + } let letter: String = name .trim() .chars() @@ -625,7 +741,7 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 y + 26.0 * k + size * 0.36, W::Bold, size, - if filled { fg(1.0) } else { accent(1.0) }, + ink, ); } diff --git a/crates/pf-console-ui/src/shell/render.rs b/crates/pf-console-ui/src/shell/render.rs index ac71f05c..cd9da0c6 100644 --- a/crates/pf-console-ui/src/shell/render.rs +++ b/crates/pf-console-ui/src/shell/render.rs @@ -166,18 +166,27 @@ impl Shell { } } - // Persistent chrome: the controller chip (top-right, above every layer). + // Persistent chrome: the controller chip (top-right, above every layer). Reads + // left-to-right as kind · name · charge — a mark for what is connected, its name, + // and how long it has left. if let Some(chip) = &self.chip { let size = 12.0 * k; let tw = f64::from(fonts.measure(chip, W::Medium, size)); - let (bh, pad_x) = (24.0 * k, 12.0 * k); - let bx = w - 24.0 * k - tw - 2.0 * pad_x; - let rect = Rect::from_xywh( - bx as f32, - (18.0 * k) as f32, - (tw + 2.0 * pad_x) as f32, - bh as f32, - ); + let (bh, pad_x, gap) = (24.0 * k, 12.0 * k, 8.0 * k); + let mark_w = 15.0 * k; + // The battery only takes room when there IS one: a wired pad, a Steam virtual + // pad and "no controller" all report nothing, and the chip must not carry a + // gap where their charge would have been. + let battery = self.pads.first().and_then(|p| p.battery); + let pip_w = if battery.is_some() { + 22.0 * k + gap + } else { + 0.0 + }; + let bw = pad_x + mark_w + gap + tw + pip_w + pad_x; + let bx = w - 24.0 * k - bw; + let top = 18.0 * k; + let rect = Rect::from_xywh(bx as f32, top as f32, bw as f32, bh as f32); crate::theme::panel( canvas, rect, @@ -186,15 +195,27 @@ impl Shell { PanelStroke::Plain(0.12), k as f32, ); + let cy = top + bh / 2.0; + crate::glyphs::pad_mark(canvas, self.glyphs, bx + pad_x, cy, mark_w, k, fg(0.7)); fonts.draw( canvas, chip, - bx + pad_x, - 18.0 * k + 16.0 * k, + bx + pad_x + mark_w + gap, + cy + size * 0.36, W::Medium, size, fg(0.7), ); + if let Some(b) = battery { + crate::glyphs::battery_pip( + canvas, + bx + pad_x + mark_w + gap + tw + gap, + cy, + 22.0 * k, + k, + b, + ); + } } self.draw_overlays(canvas, w, h, k, dt, t, fonts); diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index b7ce7b70..996f4eee 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -571,6 +571,43 @@ fn a_completed_pop_frees_its_screen_and_republishes_hints() { ); } +/// The trailing Rescan tile asks discovery to look again — and nothing else. It sits one +/// step past Add Host, where a mis-timed press used to land on nothing at all, so the test +/// that matters is that it CANNOT connect: an accidental A on the end of the strip must +/// never start a session. +#[test] +fn the_rescan_tile_probes_and_never_connects() { + let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.sync(); + // Walk to the very end of the strip: hosts, then Add Host, then Rescan. + for _ in 0..12 { + s.handle_menu(MenuEvent::Move(MenuDir::Right)); + } + assert!( + s.stack + .last() + .is_some_and(|sc| matches!(sc, Screen::Home(_))), + "still on the home carousel" + ); + s.handle_menu(MenuEvent::Confirm); + + assert!( + s.take_action().is_none(), + "a scan must raise no Launch, and no Quit" + ); + assert!(s.connecting.is_none(), "and must not open the connect card"); + assert_eq!(s.stack.len(), 1, "and must push no screen"); + assert!(s.toast.is_some(), "it says it is scanning"); + // One step back is Add Host, which DOES push — proof the walk reached the end rather + // than stalling somewhere harmless. + s.handle_menu(MenuEvent::Move(MenuDir::Left)); + s.handle_menu(MenuEvent::Confirm); + assert!( + matches!(s.stack.last(), Some(Screen::AddHost(_))), + "the tile before Rescan is Add Host" + ); +} + /// The three toast kinds must be tellable apart WITHOUT reading the words — that is the /// whole reason the kind exists. In particular the error tint is fixed rather than /// palette-derived: `moss`'s accent is a green and `ember`'s is an orange, and reporting a diff --git a/scripts/gen-os-icons.sh b/scripts/gen-os-icons.sh index ea6ffeba..04a0f328 100755 --- a/scripts/gen-os-icons.sh +++ b/scripts/gen-os-icons.sh @@ -76,6 +76,16 @@ for t in "${tokens[@]}"; do JSON done +echo +# The Skia console parses SVG path data at RUNTIME, so it needs no baked derivative — it +# needs the path string, and gets it as a generated Rust table rather than a hand-kept +# inline registry. Thirteen paths of up to 3.5 kB where one mangled character is a silently +# wrong logo is not transcription work for a human. Always regenerated from EVERY master, +# whatever tokens this script was invoked with: it is one file, and a partial rewrite would +# drop the rest. +log "console Rust table (crates/pf-console-ui/src/os_marks.rs)" +python3 scripts/gen_os_mark_table.py + echo log "Inline path data (web/src/components/os-icon.tsx, clients/decky/src/os-icon.tsx," log " clients/android/.../components/OsIcons.kt — hand-kept, paste from here)" @@ -94,4 +104,5 @@ echo log "Remember: a NEW token also has to be added to each client's shipped-token list —" log " clients/linux/src/ui_hosts.rs, clients/linux/data/resources.gresource.xml," log " clients/windows/src/app/os_icons.rs, clients/apple/.../PunktfunkKit/OsIcon.swift," -log " plus the three inline registries above." +log " plus the three inline registries above. (The console's table is generated above and" +log " needs no list — it ships whatever masters exist.)" diff --git a/scripts/gen_os_mark_table.py b/scripts/gen_os_mark_table.py new file mode 100644 index 00000000..2517cd93 --- /dev/null +++ b/scripts/gen_os_mark_table.py @@ -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']*\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) + + +# 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>; + +/// `(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 {{ + 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 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 {{ + 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)")