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
+105 -2
View File
@@ -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<PadBattery>,
}
/// 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<PadBattery> {
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 {
/// 0100. 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<Instant>,
/// Connected pad ids in connection order (metadata only, no device open); the most
/// recently connected is the auto selection.
order: Vec<u32>,
@@ -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<PadInfo> {
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<PadInfo> = 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();
}
}
+122 -11
View File
@@ -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;
+2
View File
@@ -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;
+140
View File
@@ -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<String, Option<Glyph>>;
/// `(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<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);
}
}
+153 -37
View File
@@ -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<String> = 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<MenuPulse> {
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<Hint> {
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,
);
}
+32 -11
View File
@@ -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);
+37
View File
@@ -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
+12 -1
View File
@@ -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.)"
+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)")