forked from unom/punktfunk
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d92266a402 | ||
|
|
451f95079e | ||
|
|
d927fcb2b0 | ||
|
|
c354d7cd09 | ||
|
|
c804a0dc9f | ||
|
|
be8183caab | ||
|
|
a289f7b9af | ||
|
|
50ef253e43 | ||
|
|
2314637c0d | ||
|
|
46d648cf0f | ||
|
|
adf895ced1 | ||
|
|
18fdc4cc6b | ||
|
|
7ba344cf4e |
@@ -22,6 +22,16 @@ session end returns to the library, B quits (Gaming Mode returns). Paired hosts
|
||||
pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<file.json>`
|
||||
feeds canned entries with no host (portrait paths starting with `/` load from disk).
|
||||
|
||||
`fixtures/mixed-platform-library.json` is the standing asset for the library's grouping and
|
||||
sorting work: launchers, five platforms, several stores, and entries with no platform at
|
||||
all — which is the case that matters, because a platform-less Steam library must not
|
||||
collapse into one "Unknown" heap. Two titles are deliberately awkward ("The Witcher 3"
|
||||
sorts under W, "Émigré" under E) so a broken article fold or diacritic relaxation shows up
|
||||
on screen rather than only in a unit test:
|
||||
|
||||
PUNKTFUNK_FAKE_LIBRARY=clients/session/fixtures/mixed-platform-library.json \
|
||||
punktfunk-session --browse
|
||||
|
||||
Reads the same identity / known-hosts / settings stores as the desktop client
|
||||
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"id": "launcher:steam",
|
||||
"store": "steam",
|
||||
"title": "Steam Big Picture",
|
||||
"role": "launcher",
|
||||
"icon": "steam"
|
||||
},
|
||||
{
|
||||
"id": "launcher:heroic",
|
||||
"store": "heroic",
|
||||
"title": "Heroic",
|
||||
"role": "launcher",
|
||||
"icon": "heroic"
|
||||
},
|
||||
|
||||
{ "id": "steam:570", "store": "steam", "title": "Dota 2" },
|
||||
{ "id": "steam:220", "store": "steam", "title": "Half-Life 2" },
|
||||
{ "id": "steam:1091500", "store": "steam", "title": "Cyberpunk 2077" },
|
||||
{ "id": "steam:292030", "store": "steam", "title": "The Witcher 3: Wild Hunt" },
|
||||
{ "id": "steam:377160", "store": "steam", "title": "Fallout 4" },
|
||||
|
||||
{ "id": "rom:ps3-1", "store": "custom", "title": "Demon's Souls", "platform": "PS3" },
|
||||
{ "id": "rom:ps3-2", "store": "custom", "title": "The Last of Us", "platform": "PS3" },
|
||||
{ "id": "rom:ps3-3", "store": "custom", "title": "Ni no Kuni", "platform": "PS3" },
|
||||
|
||||
{ "id": "rom:ps2-1", "store": "custom", "title": "Shadow of the Colossus", "platform": "PS2" },
|
||||
{ "id": "rom:ps2-2", "store": "custom", "title": "Ico", "platform": "PS2" },
|
||||
{ "id": "rom:ps2-3", "store": "custom", "title": "Ōkami", "platform": "PS2" },
|
||||
|
||||
{ "id": "rom:snes-1", "store": "custom", "title": "A Link to the Past", "platform": "SNES" },
|
||||
{ "id": "rom:snes-2", "store": "custom", "title": "Super Metroid", "platform": "SNES" },
|
||||
{ "id": "rom:snes-3", "store": "custom", "title": "Chrono Trigger", "platform": "SNES" },
|
||||
{ "id": "rom:snes-4", "store": "custom", "title": "EarthBound", "platform": "SNES" },
|
||||
|
||||
{ "id": "rom:gc-1", "store": "custom", "title": "Metroid Prime", "platform": "GameCube" },
|
||||
{ "id": "rom:gc-2", "store": "custom", "title": "The Wind Waker", "platform": "GameCube" },
|
||||
|
||||
{ "id": "epic:fn", "store": "epic", "title": "Alan Wake 2" },
|
||||
{ "id": "gog:1", "store": "gog", "title": "An Untitled Story" },
|
||||
|
||||
{ "id": "custom:none-1", "store": "wat", "title": "Unlabelled Thing" },
|
||||
{ "id": "custom:none-2", "store": "wat", "title": "Émigré" }
|
||||
]
|
||||
@@ -920,6 +920,7 @@ fn spawn_fetch(
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
platform: g.platform.clone(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -962,6 +963,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
platform: g.platform.clone(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "Cross-client CONSOLE-UI parity vectors: the background palette table, the settings section tabs, and the screen-transition motion contract. Consumed by pf-console-ui's Rust tests, the Android app's ConsoleVectorsTest, and the Apple client's ConsoleVectorsTests -- so the three hand-copies of these tables can no longer drift in silence. Sibling of deeplink-vectors.json, same rules: this file is the contract, and a value absent here is a value no client may assume.",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"cell_ramp": [
|
||||
0.1,
|
||||
-0.06,
|
||||
@@ -1953,6 +1953,7 @@
|
||||
}
|
||||
],
|
||||
"motion": {
|
||||
"$deprecated": "SUPERSEDED by `motion_spring` (version 2). The desktop console's transition is a damped spring now, not a 0.26 s ease-out-cubic, so it no longer implements this block and its test reads `motion_spring` instead. Kept in place, and still correct, for the clients that have not migrated: the Android client's ConsoleVectorsTest pins it, and the Apple client's GamepadShell mirrors these constants (untested there - its ConsoleVectorsTests covers the palette table only). DELETE THIS BLOCK when the last client moves; until then the drift is visible rather than silent, because each client's test names which block it implements.",
|
||||
"$why": "The console screen transition, from pf-console-ui's shell: TRANSITION_S and the paint geometry in shell/render.rs. Every client re-implements this by hand in its own animation system, which is why it is pinned here.",
|
||||
"transition_s": 0.26,
|
||||
"push_slide_dp": 36.0,
|
||||
@@ -2001,5 +2002,16 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"motion_spring": {
|
||||
"$why": "The console screen transition, from pf-console-ui's shell. Springs are INTEGRATOR-dependent - semi-implicit Euler in <=8 ms substeps here, a different solver in every other runtime - so unlike the v1 curve this pins PARAMETERS, not sampled positions. Two implementations that both honour response/damping will agree to the eye and disagree in the third decimal, and sampling would pin the disagreement instead of the feel. The geometry below is unchanged from v1: the spring replaced the time-course, not the choreography.",
|
||||
"response": 0.42,
|
||||
"damping": 0.88,
|
||||
"push_slide_dp": 36.0,
|
||||
"enter_scale": 0.985,
|
||||
"exit_scale": 0.96,
|
||||
"reveal_alpha": 0.4,
|
||||
"interruptible": true,
|
||||
"$interruptible": "Back pressed mid-push retargets the SAME spring to 0 - the entering screen decelerates, turns, and goes back - and mid-pop starts the next pop immediately. Input other than Back stays queued-dropped until the spring passes 0.85, which keeps a double-tapped A from pushing two screens."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// 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<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1295,6 +1295,33 @@ pub struct Settings {
|
||||
/// newer client may have shipped a palette this binary doesn't know.
|
||||
#[serde(default = "default_ui_palette")]
|
||||
pub ui_palette: String,
|
||||
/// Suppress the gamepad UI's decorative motion: the living backdrop freezes, screen
|
||||
/// transitions become a plain fade, entrances stop staggering, and refused moves keep
|
||||
/// their haptic but drop the recoil travel. Presentation only, exactly like
|
||||
/// [`ui_palette`](Self::ui_palette) — a device preference, never part of a settings
|
||||
/// profile.
|
||||
///
|
||||
/// A console setting rather than a mirror of an OS one, because there is no
|
||||
/// system "reduce motion" SDL can read portably across Linux and Windows. It also
|
||||
/// doubles as the OLED-friendly mode: a frozen backdrop is a static image.
|
||||
/// `default` so pre-existing stores load with the full motion they have today.
|
||||
#[serde(default)]
|
||||
pub reduce_motion: bool,
|
||||
/// How the console's game library orders titles within a group: `""`/unknown (the
|
||||
/// host's own order — today's shelf, byte for byte), `"title"`, `"platform"` or
|
||||
/// `"store"`. See `pf-console-ui`'s `collate` module, which is the portable spec the
|
||||
/// Apple and Android ports implement.
|
||||
///
|
||||
/// Presentation only, like [`ui_palette`](Self::ui_palette), so it is a device
|
||||
/// preference and never part of a settings profile. Parsed leniently — an unrecognized
|
||||
/// value is a newer client's key, and the right answer to one is the default shelf.
|
||||
#[serde(default)]
|
||||
pub library_sort: String,
|
||||
/// How the console's game library is arranged: `"shelf"` (the coverflow — the default,
|
||||
/// and unknown values read as it) or `"grid"`. Presentation only, same rules as
|
||||
/// [`library_sort`](Self::library_sort).
|
||||
#[serde(default)]
|
||||
pub library_view: String,
|
||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
|
||||
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
|
||||
@@ -1500,6 +1527,9 @@ impl Default for Settings {
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
ui_palette: default_ui_palette(),
|
||||
reduce_motion: false,
|
||||
library_sort: String::new(),
|
||||
library_view: String::new(),
|
||||
auto_wake: true,
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
//! The console shell's motion vocabulary. Two kinds of movement, deliberately kept
|
||||
//! apart: **springs** (`Spring`, wrapping `library::spring_advance`) for anything the
|
||||
//! user pushes around — cursors, trays, recoil — where velocity must carry across
|
||||
//! retargets; and **timed progressions** (`Progress` + the easing functions) for
|
||||
//! fire-and-forget choreography — screen entrances/exits, fades — where a deterministic
|
||||
//! duration matters more than momentum.
|
||||
//! The console shell's motion vocabulary. Two kinds of movement, deliberately kept apart:
|
||||
//!
|
||||
//! **Springs** ([`Spring`], wrapping `library::spring_advance`) for anything the user
|
||||
//! pushes around — cursors, trays, recoil, and now the screen transitions themselves —
|
||||
//! where velocity must carry across a retarget. [`SpringSpec`] and the [`springs`] table
|
||||
//! are how a feel is named rather than spelled out as a `k`/`c` pair.
|
||||
//!
|
||||
//! **Timed choreography** ([`Entrance`] + the easing functions) for the fire-and-forget
|
||||
//! kind, where the shape over a fixed window matters more than momentum and there is
|
||||
//! nothing to interrupt. An entrance is a pure function of the clock, so a screen holds
|
||||
//! one and asks it per item instead of keeping per-item state.
|
||||
//!
|
||||
//! There used to be a general-purpose `Progress` timer here. It went when the screen
|
||||
//! transition — its last caller — became a spring: reversing a timer mid-flight means
|
||||
//! either a snap or a second animation, which is the whole reason that work happened.
|
||||
|
||||
use crate::library::spring_advance;
|
||||
|
||||
@@ -21,6 +30,68 @@ pub(crate) fn approach(current: f64, target: f64, dt: f64, tau: f64) -> f64 {
|
||||
current + (target - current) * (1.0 - (-dt / tau).exp())
|
||||
}
|
||||
|
||||
/// A spring named the way a designer reasons about one: `response` is the period of a
|
||||
/// full undamped oscillation in seconds ("how long does it take to get there"), `damping`
|
||||
/// is ζ (1.0 lands dead, below it overshoots). The integrator wants `k`/`c` instead, and
|
||||
/// [`SpringSpec::kc`] is the conversion — the same one SwiftUI's
|
||||
/// `.spring(response:dampingFraction:)` performs.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SpringSpec {
|
||||
pub response: f64,
|
||||
pub damping: f64,
|
||||
}
|
||||
|
||||
impl SpringSpec {
|
||||
/// `(k, c)` for [`Spring::step`], from `ω = 2π/response`: `k = ω²`, `c = 2ζω`.
|
||||
///
|
||||
/// `const` so [`springs`] costs nothing at runtime — which is also why it is written
|
||||
/// with `ω` rather than `2ζ√k`: `√k` IS `ω`, and `f64::sqrt` is not callable in a
|
||||
/// const context. Same numbers, no root.
|
||||
pub(crate) const fn kc(self) -> (f64, f64) {
|
||||
let w = std::f64::consts::TAU / self.response;
|
||||
(w * w, 2.0 * self.damping * w)
|
||||
}
|
||||
}
|
||||
|
||||
/// The console's motion vocabulary: feel is chosen from this table rather than from
|
||||
/// constants scattered through the widgets, so "the focus pop" is one name with one
|
||||
/// definition instead of a τ someone picked in a hurry.
|
||||
///
|
||||
/// These are the SHELL's springs. The carousel's own pairs (`library::SPRING_K/C`,
|
||||
/// `BUMP_K/C`) deliberately stay raw: they are tuned numbers shared with the GTK
|
||||
/// launcher's math, and restating them as specs would invite a "tidy-up" that changes the
|
||||
/// coverflow's feel on both surfaces at once.
|
||||
pub(crate) mod springs {
|
||||
use super::SpringSpec;
|
||||
|
||||
/// Screen push/pop. Just under critical — a whole screen that visibly bounces reads as
|
||||
/// broken rather than alive — but sprung rather than tweened because the velocity has
|
||||
/// to carry: a Back pressed mid-push retargets this spring to 0 and the screen turns
|
||||
/// around where it is, which a time-based curve cannot do without snapping.
|
||||
pub(crate) const NAV: SpringSpec = SpringSpec {
|
||||
response: 0.42,
|
||||
damping: 0.88,
|
||||
};
|
||||
/// Row and tile focus. Deliberately the loosest of the table: the whisker of overshoot
|
||||
/// IS the pop that makes a focused row feel picked up rather than merely tinted.
|
||||
pub(crate) const FOCUS: SpringSpec = SpringSpec {
|
||||
response: 0.30,
|
||||
damping: 0.80,
|
||||
};
|
||||
/// The tab pill and the keyboard tray. One spec because they are one gesture — a
|
||||
/// single object gliding to a new seat — and it is the pair [`TRAY_K`]/[`TRAY_C`]
|
||||
/// already encode (see `spring_spec_matches_the_tray_constants`).
|
||||
pub(crate) const INDICATOR: SpringSpec = SpringSpec {
|
||||
response: 0.32,
|
||||
damping: 0.86,
|
||||
};
|
||||
/// The confirm dip. Fast and loose, so a press reads as a press and not as a fade.
|
||||
pub(crate) const PRESS: SpringSpec = SpringSpec {
|
||||
response: 0.18,
|
||||
damping: 0.65,
|
||||
};
|
||||
}
|
||||
|
||||
/// A damped spring with persistent velocity. `k`/`c` choose the feel; see the pairs in
|
||||
/// [`crate::library`] (cursor chase, boundary bump) and [`TRAY_K`]/[`TRAY_C`] below.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -38,6 +109,12 @@ impl Spring {
|
||||
(self.pos, self.vel) = spring_advance(self.pos, self.vel, target, k, c, dt);
|
||||
}
|
||||
|
||||
/// [`Spring::step`] with the feel named instead of spelled out.
|
||||
pub(crate) fn step_spec(&mut self, target: f64, spec: SpringSpec, dt: f64) {
|
||||
let (k, c) = spec.kc();
|
||||
self.step(target, k, c, dt);
|
||||
}
|
||||
|
||||
/// Snap onto `target` once the motion is imperceptible (stops per-frame damage).
|
||||
pub(crate) fn settle(&mut self, target: f64, eps_pos: f64, eps_vel: f64) {
|
||||
if (target - self.pos).abs() < eps_pos && self.vel.abs() < eps_vel {
|
||||
@@ -52,30 +129,121 @@ impl Spring {
|
||||
pub(crate) const TRAY_K: f64 = 385.0;
|
||||
pub(crate) const TRAY_C: f64 = 33.7;
|
||||
|
||||
/// A clamped 0→1 timer for fire-and-forget choreography. `advance` returns the RAW
|
||||
/// progress — callers apply their easing so one Progress can drive several curves.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Progress {
|
||||
t: f64,
|
||||
duration: f64,
|
||||
// --- Entrance choreography ------------------------------------------------------------
|
||||
|
||||
/// Ease-out-back: crosses 1.0 near the end and settles back onto it, so an arriving card
|
||||
/// reads as THROWN into place rather than slid. `c1` is 1.2 rather than the CSS-standard
|
||||
/// 1.70158 — at full strength the overshoot reads as a bounce, which is a different (and
|
||||
/// sillier) gesture.
|
||||
pub(crate) fn ease_out_back(t: f64) -> f64 {
|
||||
const C1: f64 = 1.2;
|
||||
const C3: f64 = C1 + 1.0;
|
||||
let u = t.clamp(0.0, 1.0) - 1.0;
|
||||
1.0 + C3 * u * u * u + C1 * u * u
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
pub(crate) fn new(duration: f64) -> Progress {
|
||||
Progress { t: 0.0, duration }
|
||||
/// The share of an item's window spent fading in. Short on purpose: the card is solid
|
||||
/// well before it stops moving, so what you read is the motion and not a dissolve.
|
||||
const FADE_SHARE: f64 = 0.34;
|
||||
|
||||
/// How a staggered entrance is shaped.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct EntranceSpec {
|
||||
/// How long ONE item takes to arrive.
|
||||
pub window: f64,
|
||||
/// Delay added per step of distance from the anchor.
|
||||
pub stagger: f64,
|
||||
/// Ceiling on that delay. Without it a 400-title shelf would still be arriving a
|
||||
/// minute later; with it, everything past ~6 items away starts together.
|
||||
pub cap: f64,
|
||||
}
|
||||
|
||||
pub(crate) mod entrances {
|
||||
use super::EntranceSpec;
|
||||
|
||||
/// Carousel and coverflow cards — the loud one, and the reason this exists.
|
||||
pub(crate) const CARDS: EntranceSpec = EntranceSpec {
|
||||
window: 0.6,
|
||||
stagger: 0.07,
|
||||
cap: 0.42,
|
||||
};
|
||||
/// Menu rows. Same language, deliberately quieter: a settings list that fans open like
|
||||
/// a shelf of box art is a settings list showing off.
|
||||
pub(crate) const ROWS: EntranceSpec = EntranceSpec {
|
||||
window: 0.42,
|
||||
stagger: 0.03,
|
||||
cap: 0.24,
|
||||
};
|
||||
}
|
||||
|
||||
/// One item's place in an entrance.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(crate) struct EntranceAt {
|
||||
/// 0 → 1 travel on [`ease_out_back`]. The SCREEN decides what travels (a card's
|
||||
/// scale, its Y-turn, a row's rise) — this only says how far along it is.
|
||||
pub travel: f64,
|
||||
/// 0 → 1 opacity.
|
||||
pub fade: f64,
|
||||
}
|
||||
|
||||
impl EntranceAt {
|
||||
/// At rest and fully opaque — what every item reads once the entrance is over.
|
||||
pub(crate) const SETTLED: EntranceAt = EntranceAt {
|
||||
travel: 1.0,
|
||||
fade: 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
/// The staggered card entrance, as a pure function of the shell clock: a screen holds ONE
|
||||
/// of these and asks it per item, so there is no per-card state to keep in step with a
|
||||
/// list that churns under discovery.
|
||||
///
|
||||
/// Armed once per screen MOUNT (the first frame after a push), not per frame-loop — the
|
||||
/// desktop equivalent of arming on `onAppear`.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Entrance {
|
||||
spec: EntranceSpec,
|
||||
anchor: usize,
|
||||
t0: f64,
|
||||
/// Snapshotted when the entrance is armed rather than read per item, so one entrance
|
||||
/// plays one way even if the setting is stepped while it runs.
|
||||
reduced: bool,
|
||||
}
|
||||
|
||||
impl Entrance {
|
||||
/// Arm at `t0`, fanning out from `anchor` — which callers pass as the CURSOR, so a
|
||||
/// restored selection assembles around the eye instead of sweeping in from a corner.
|
||||
pub(crate) fn new(spec: EntranceSpec, anchor: usize, t0: f64) -> Entrance {
|
||||
Entrance {
|
||||
spec,
|
||||
anchor,
|
||||
t0,
|
||||
reduced: crate::theme::reduce_motion(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn advance(&mut self, dt: f64) -> f64 {
|
||||
self.t = (self.t + dt / self.duration).min(1.0);
|
||||
self.t
|
||||
pub(crate) fn at(&self, i: usize, t: f64) -> EntranceAt {
|
||||
let elapsed = t - self.t0;
|
||||
if self.reduced {
|
||||
// A plain crossfade: no travel, no stagger, everything together.
|
||||
return EntranceAt {
|
||||
travel: 1.0,
|
||||
fade: (elapsed / self.spec.window).clamp(0.0, 1.0),
|
||||
};
|
||||
}
|
||||
let delay = (self.anchor.abs_diff(i) as f64 * self.spec.stagger).min(self.spec.cap);
|
||||
let w = ((elapsed - delay) / self.spec.window).clamp(0.0, 1.0);
|
||||
EntranceAt {
|
||||
travel: ease_out_back(w),
|
||||
fade: ease_out_cubic(w / FADE_SHARE),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value(&self) -> f64 {
|
||||
self.t
|
||||
}
|
||||
|
||||
pub(crate) fn done(&self) -> bool {
|
||||
self.t >= 1.0
|
||||
/// Has every item landed? The farthest one starts at `cap` and takes `window`, so the
|
||||
/// whole thing is over at their sum whatever `len` is — which is why callers can drop
|
||||
/// the entrance entirely (and stop paying for its transforms) without counting items.
|
||||
pub(crate) fn done(&self, t: f64) -> bool {
|
||||
t - self.t0 >= self.spec.cap + self.spec.window
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,17 +269,6 @@ mod tests {
|
||||
assert!((v - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_completes_on_time() {
|
||||
let mut p = Progress::new(0.3);
|
||||
let mut steps = 0;
|
||||
while !p.done() {
|
||||
p.advance(1.0 / 60.0);
|
||||
steps += 1;
|
||||
}
|
||||
assert!((17..=19).contains(&steps), "{steps}"); // 0.3 s at 60 Hz
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_settles() {
|
||||
let mut s = Spring::rest(0.0);
|
||||
@@ -121,4 +278,132 @@ mod tests {
|
||||
}
|
||||
assert_eq!((s.pos, s.vel), (1.0, 0.0));
|
||||
}
|
||||
|
||||
/// The conversion, pinned against the one pair that predates the table. `TRAY_K`/`TRAY_C`
|
||||
/// were hand-derived from `.spring(response: 0.32, dampingFraction: 0.86)` and written
|
||||
/// ROUNDED (385.0/33.7 against an exact 385.53/33.77), so this asserts to 0.3 % rather
|
||||
/// than exactly — the point is that the arithmetic in `kc` is the arithmetic those
|
||||
/// constants came from, not that someone re-typed more digits.
|
||||
#[test]
|
||||
fn spring_spec_matches_the_tray_constants() {
|
||||
let (k, c) = springs::INDICATOR.kc();
|
||||
assert!(
|
||||
(k - TRAY_K).abs() / TRAY_K < 0.003,
|
||||
"k: spec says {k}, TRAY_K is {TRAY_K}"
|
||||
);
|
||||
assert!(
|
||||
(c - TRAY_C).abs() / TRAY_C < 0.003,
|
||||
"c: spec says {c}, TRAY_C is {TRAY_C}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The entrance envelope's four load-bearing properties. Asserted on FADE rather than
|
||||
/// travel wherever "further along" is the question: travel rides an ease-out-BACK, so
|
||||
/// it crosses 1.0 and comes back, and a card at 80 % of its window can legitimately be
|
||||
/// further displaced than one that has already landed. Fade is the monotone channel.
|
||||
#[test]
|
||||
fn entrance_envelope() {
|
||||
let e = Entrance::new(entrances::CARDS, 5, 0.0);
|
||||
|
||||
// The anchor leads. It is the cursor, so the strip assembles around the eye.
|
||||
for t in [0.05, 0.2, 0.4, 0.7, 1.1] {
|
||||
let anchor = e.at(5, t).fade;
|
||||
for i in 0..14 {
|
||||
assert!(
|
||||
e.at(i, t).fade <= anchor + 1e-9,
|
||||
"item {i} beat the anchor at t={t}"
|
||||
);
|
||||
}
|
||||
// …and symmetric neighbours arrive together.
|
||||
assert_eq!(e.at(3, t), e.at(7, t));
|
||||
}
|
||||
|
||||
// The cap holds: past cap/stagger = 6 steps out, everything starts at once. This is
|
||||
// what keeps a 400-title shelf from still arriving a minute later.
|
||||
assert_eq!(e.at(5 + 7, 0.3), e.at(5 + 250, 0.3));
|
||||
|
||||
// Monotone once started, and never outside 0..=1.
|
||||
let mut last = 0.0;
|
||||
for step in 0..=120 {
|
||||
let at = e.at(9, f64::from(step) * 0.01);
|
||||
assert!(at.fade >= last - 1e-9, "fade went backwards at step {step}");
|
||||
assert!((0.0..=1.0).contains(&at.fade));
|
||||
last = at.fade;
|
||||
}
|
||||
|
||||
// Identity at the end — the whole thing is over at cap + window, whatever `len` is,
|
||||
// which is what lets a screen drop the entrance instead of counting items.
|
||||
let over = entrances::CARDS.cap + entrances::CARDS.window;
|
||||
assert!(e.done(over));
|
||||
assert!(!e.done(over - 0.01));
|
||||
for i in [5, 9, 400] {
|
||||
assert_eq!(e.at(i, over), EntranceAt::SETTLED, "item {i} never landed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ease-out-back must overshoot — that is the difference between a card being thrown
|
||||
/// into place and slid there — and must still land exactly on 1.0.
|
||||
#[test]
|
||||
fn ease_out_back_overshoots_then_lands() {
|
||||
// To a tolerance at 0, not exactly: the polynomial is `1 − 2.2 + 1.2`, which is
|
||||
// zero in real arithmetic and −2.2e−16 in f64. The contract is "starts where the
|
||||
// card starts", and a fifth of a femto-unit is that.
|
||||
assert!(ease_out_back(0.0).abs() < 1e-12);
|
||||
assert_eq!(ease_out_back(1.0), 1.0);
|
||||
assert_eq!(ease_out_back(2.0), 1.0, "clamped");
|
||||
let peak = (0..=100)
|
||||
.map(|i| ease_out_back(f64::from(i) / 100.0))
|
||||
.fold(f64::MIN, f64::max);
|
||||
assert!(peak > 1.0, "no overshoot at all: {peak}");
|
||||
assert!(peak < 1.12, "overshoot reads as a bounce: {peak}");
|
||||
}
|
||||
|
||||
/// Reduced motion turns the entrance into a plain crossfade: no travel, no stagger.
|
||||
/// Snapshotted at arm time, so one entrance plays one way even if the setting moves.
|
||||
#[test]
|
||||
fn entrance_under_reduced_motion_is_a_staggerless_crossfade() {
|
||||
crate::theme::set_reduce_motion(true);
|
||||
let e = Entrance::new(entrances::CARDS, 5, 0.0);
|
||||
crate::theme::set_reduce_motion(false);
|
||||
for t in [0.0, 0.1, 0.3, 0.6] {
|
||||
let a = e.at(5, t);
|
||||
assert_eq!(a.travel, 1.0, "nothing travels");
|
||||
assert_eq!(a, e.at(200, t), "and nothing staggers");
|
||||
}
|
||||
assert_eq!(e.at(0, 0.6), EntranceAt::SETTLED);
|
||||
}
|
||||
|
||||
/// Peak of a unit step as a fraction over the target — 0.0 when the spring never
|
||||
/// crosses. Integrated at 120 Hz so the measurement is the SPRING's shape and not the
|
||||
/// sampling's.
|
||||
fn peak_overshoot(spec: SpringSpec) -> f64 {
|
||||
let mut s = Spring::rest(0.0);
|
||||
let mut peak: f64 = 0.0;
|
||||
for _ in 0..600 {
|
||||
s.step_spec(1.0, spec, 1.0 / 120.0);
|
||||
peak = peak.max(s.pos);
|
||||
}
|
||||
(peak - 1.0).max(0.0)
|
||||
}
|
||||
|
||||
/// The table's damping choices, stated as behaviour rather than as numbers: FOCUS is
|
||||
/// under-damped ENOUGH to read as a pop, INDICATOR is damped enough that a gliding pill
|
||||
/// doesn't wobble at the end of its travel. A future re-tune that breaks either breaks
|
||||
/// this.
|
||||
#[test]
|
||||
fn spec_damping_choices_show_up_as_overshoot() {
|
||||
let focus = peak_overshoot(springs::FOCUS);
|
||||
assert!(focus > 0.005, "FOCUS must visibly overshoot, got {focus}");
|
||||
let indicator = peak_overshoot(springs::INDICATOR);
|
||||
assert!(
|
||||
indicator < 0.01,
|
||||
"INDICATOR must not visibly overshoot, got {indicator}"
|
||||
);
|
||||
let nav = peak_overshoot(springs::NAV);
|
||||
assert!(nav < 0.01, "NAV must not visibly overshoot, got {nav}");
|
||||
assert!(
|
||||
peak_overshoot(springs::PRESS) > focus,
|
||||
"PRESS is the loosest of the table"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
//! Sorting and grouping the library — all policy, no Skia.
|
||||
//!
|
||||
//! Kept a separate, pure module for two reasons. It is the half of "alternate views and
|
||||
//! group & sort" that has to be identical on every client, so this file is also the
|
||||
//! portable SPEC the Apple and Android ports implement; and grouping rules are exactly the
|
||||
//! kind of thing that reads obviously correct and is quietly wrong (a platform-less Steam
|
||||
//! library collapsing into one "Unknown" heap, a fold that files "The Witcher" under T).
|
||||
//! Both are cheap to test here and expensive to notice on a TV.
|
||||
//!
|
||||
//! Everything returns INDICES into the caller's slice. The screens' art cache, fetch pump
|
||||
//! and cursor arithmetic all key off the shared model's ordering, so a collation that
|
||||
//! handed back cloned games would fork the identity of every title in the shelf.
|
||||
|
||||
use crate::library::{store_label, LibraryGame};
|
||||
|
||||
/// How titles are ordered within a group.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub(crate) enum SortKey {
|
||||
/// The host's own order, untouched. The DEFAULT, and byte-identical to the shelf as it
|
||||
/// has always been — a user who never opens the sort pills must see no change at all.
|
||||
#[default]
|
||||
HostOrder,
|
||||
/// A–Z, case- and diacritic-relaxed, with the leading article folded away.
|
||||
Title,
|
||||
/// Platform label A–Z, then title within it.
|
||||
Platform,
|
||||
/// Store label A–Z, then title.
|
||||
Store,
|
||||
}
|
||||
|
||||
impl SortKey {
|
||||
/// Parse the persisted `library_sort` value. Lenient by design: an unknown string is a
|
||||
/// newer client's key, and the right answer to one is today's shelf rather than an
|
||||
/// error — the same rule `ui_palette` follows.
|
||||
pub(crate) fn parse(s: &str) -> SortKey {
|
||||
match s {
|
||||
"title" => SortKey::Title,
|
||||
"platform" => SortKey::Platform,
|
||||
"store" => SortKey::Store,
|
||||
_ => SortKey::HostOrder,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted name. A FILE FORMAT — renaming one silently resets every user's
|
||||
/// chosen sort to the default on their next launch.
|
||||
pub(crate) fn id(self) -> &'static str {
|
||||
match self {
|
||||
SortKey::HostOrder => "host",
|
||||
SortKey::Title => "title",
|
||||
SortKey::Platform => "platform",
|
||||
SortKey::Store => "store",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
SortKey::HostOrder => "Default",
|
||||
SortKey::Title => "A–Z",
|
||||
SortKey::Platform => "Platform",
|
||||
SortKey::Store => "Store",
|
||||
}
|
||||
}
|
||||
|
||||
/// The pills, in the order they are offered.
|
||||
pub(crate) const ALL: [SortKey; 4] = [
|
||||
SortKey::HostOrder,
|
||||
SortKey::Title,
|
||||
SortKey::Platform,
|
||||
SortKey::Store,
|
||||
];
|
||||
}
|
||||
|
||||
/// What a group IS, kept as data rather than a formatted string so a filtered library can
|
||||
/// be compared against it without re-parsing a label.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GroupKey {
|
||||
/// The launcher entries (design D4's leading group).
|
||||
Launchers,
|
||||
Platform(String),
|
||||
Store(String),
|
||||
}
|
||||
|
||||
/// One collated bucket: what it is, what to call it, and which games are in it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Group {
|
||||
pub key: GroupKey,
|
||||
pub label: String,
|
||||
/// Indices into the slice passed to [`collate`], in display order.
|
||||
pub games: Vec<usize>,
|
||||
}
|
||||
|
||||
/// What to bucket by. `None` = one group holding everything (the plain shelf).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GroupBy {
|
||||
Platform,
|
||||
Store,
|
||||
}
|
||||
|
||||
/// Fold a title down to something sortable: lowercase, diacritics relaxed to their base
|
||||
/// letter, punctuation dropped, and a leading article removed.
|
||||
///
|
||||
/// The article fold is what a user actually means by A–Z. "The Witcher 3" belongs under W;
|
||||
/// left alone, every "The …" in a library piles up under T and the sort is useless exactly
|
||||
/// where it is most needed. English articles only — the host's titles are whatever the
|
||||
/// store called them, and inventing rules for languages we cannot detect would file things
|
||||
/// under letters nobody expects.
|
||||
pub(crate) fn sort_title(title: &str) -> String {
|
||||
let relaxed: String = title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' => 'a',
|
||||
'é' | 'è' | 'ê' | 'ë' => 'e',
|
||||
'í' | 'ì' | 'î' | 'ï' => 'i',
|
||||
'ó' | 'ò' | 'ô' | 'ö' | 'õ' => 'o',
|
||||
'ú' | 'ù' | 'û' | 'ü' => 'u',
|
||||
'ç' => 'c',
|
||||
'ñ' => 'n',
|
||||
c => c,
|
||||
})
|
||||
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||
.collect();
|
||||
let trimmed = relaxed.trim();
|
||||
for article in ["the ", "a ", "an "] {
|
||||
if let Some(rest) = trimmed.strip_prefix(article) {
|
||||
// Guard against a title that IS an article ("The", "A Way Out" keeps "way out",
|
||||
// but a bare "The" must not sort as an empty string and float to the front).
|
||||
let rest = rest.trim();
|
||||
if !rest.is_empty() {
|
||||
return rest.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// The bucket label for one game under `by`.
|
||||
///
|
||||
/// The interesting case is a game with no platform. It does NOT go to "Unknown": a Steam
|
||||
/// library is entirely platform-less, and one giant "Unknown" heap would be a worse view
|
||||
/// than no grouping at all. A store-front game buckets under its STORE instead ("Steam"),
|
||||
/// which is both true and useful, and only an entry with neither lands in "Other".
|
||||
fn bucket(g: &LibraryGame, by: GroupBy) -> GroupKey {
|
||||
match by {
|
||||
GroupBy::Platform => match g
|
||||
.platform
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
{
|
||||
Some(p) => GroupKey::Platform(p.to_string()),
|
||||
None => match store_label(&g.store) {
|
||||
"Game" => GroupKey::Platform("Other".to_string()),
|
||||
store => GroupKey::Store(store.to_string()),
|
||||
},
|
||||
},
|
||||
GroupBy::Store => GroupKey::Store(store_label(&g.store).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn label_of(key: &GroupKey) -> String {
|
||||
match key {
|
||||
GroupKey::Launchers => "Launchers".to_string(),
|
||||
GroupKey::Platform(p) | GroupKey::Store(p) => p.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collate `games` into display groups.
|
||||
///
|
||||
/// Launchers always form the leading group, which is how design D4's "launcher entries come
|
||||
/// first" invariant survives grouping BY CONSTRUCTION rather than by every caller
|
||||
/// remembering it. Sorting applies WITHIN groups, never across them.
|
||||
pub(crate) fn collate(
|
||||
games: &[LibraryGame],
|
||||
sort: SortKey,
|
||||
group_by: Option<GroupBy>,
|
||||
) -> Vec<Group> {
|
||||
let mut launchers: Vec<usize> = Vec::new();
|
||||
// Insertion-ordered rather than a map, so groups appear in the order the library first
|
||||
// mentions them and two runs over the same library agree.
|
||||
let mut buckets: Vec<(GroupKey, Vec<usize>)> = Vec::new();
|
||||
|
||||
for (i, g) in games.iter().enumerate() {
|
||||
if g.launcher {
|
||||
launchers.push(i);
|
||||
continue;
|
||||
}
|
||||
let key = match group_by {
|
||||
Some(by) => bucket(g, by),
|
||||
// Ungrouped: one bucket holding the whole shelf. Its label is never drawn (the
|
||||
// shelf has no heading when there is only one group), so it names itself
|
||||
// honestly rather than inventing a title.
|
||||
None => GroupKey::Platform("All".to_string()),
|
||||
};
|
||||
match buckets.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some((_, v)) => v.push(i),
|
||||
None => buckets.push((key, vec![i])),
|
||||
}
|
||||
}
|
||||
|
||||
let order = |a: usize, b: usize| -> std::cmp::Ordering {
|
||||
let (ga, gb) = (&games[a], &games[b]);
|
||||
match sort {
|
||||
// Untouched: the host's order IS the order, so the comparator never fires.
|
||||
SortKey::HostOrder => a.cmp(&b),
|
||||
SortKey::Title => sort_title(&ga.title)
|
||||
.cmp(&sort_title(&gb.title))
|
||||
.then(a.cmp(&b)),
|
||||
SortKey::Platform => ga
|
||||
.platform
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.cmp(gb.platform.as_deref().unwrap_or(""))
|
||||
.then_with(|| sort_title(&ga.title).cmp(&sort_title(&gb.title)))
|
||||
.then(a.cmp(&b)),
|
||||
SortKey::Store => store_label(&ga.store)
|
||||
.cmp(store_label(&gb.store))
|
||||
.then_with(|| sort_title(&ga.title).cmp(&sort_title(&gb.title)))
|
||||
.then(a.cmp(&b)),
|
||||
}
|
||||
};
|
||||
|
||||
let mut out: Vec<Group> = Vec::with_capacity(buckets.len() + 1);
|
||||
if !launchers.is_empty() {
|
||||
// Launchers keep the host's order whatever the sort says: there are two or three of
|
||||
// them, they are a fixed set, and shuffling them by title makes muscle memory
|
||||
// useless for no gain.
|
||||
out.push(Group {
|
||||
key: GroupKey::Launchers,
|
||||
label: label_of(&GroupKey::Launchers),
|
||||
games: launchers,
|
||||
});
|
||||
}
|
||||
for (key, mut idx) in buckets {
|
||||
// `sort_by` is stable, and every comparator falls back to the index, so equal keys
|
||||
// keep the host's order rather than an arbitrary one.
|
||||
idx.sort_by(|&a, &b| order(a, b));
|
||||
out.push(Group {
|
||||
label: label_of(&key),
|
||||
key,
|
||||
games: idx,
|
||||
});
|
||||
}
|
||||
// Groups themselves go A–Z by label, launchers excepted — they were pushed first and
|
||||
// `sort_by` is stable, so pinning them by key keeps them leading.
|
||||
out.sort_by(|a, b| {
|
||||
let lead = |g: &Group| u8::from(g.key != GroupKey::Launchers);
|
||||
lead(a).cmp(&lead(b)).then_with(|| a.label.cmp(&b.label))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The flat index list for a group filter — `None` = the whole library, in collated order.
|
||||
pub(crate) fn filtered(
|
||||
games: &[LibraryGame],
|
||||
sort: SortKey,
|
||||
filter: Option<&GroupKey>,
|
||||
) -> Vec<usize> {
|
||||
let by = match filter {
|
||||
Some(GroupKey::Platform(_)) => Some(GroupBy::Platform),
|
||||
Some(GroupKey::Store(_)) => Some(GroupBy::Store),
|
||||
Some(GroupKey::Launchers) | None => None,
|
||||
};
|
||||
let groups = collate(games, sort, by);
|
||||
match filter {
|
||||
None => groups.into_iter().flat_map(|g| g.games).collect(),
|
||||
Some(want) => groups
|
||||
.into_iter()
|
||||
.find(|g| &g.key == want)
|
||||
.map(|g| g.games)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is there anything worth browsing? A library with one platform and one store has nothing
|
||||
/// to drill INTO, and a screen that opens onto a single tile is worse than no screen —
|
||||
/// so the button that reaches it is hidden rather than made to disappoint.
|
||||
pub(crate) fn worth_browsing(games: &[LibraryGame]) -> bool {
|
||||
collate(games, SortKey::HostOrder, Some(GroupBy::Platform))
|
||||
.iter()
|
||||
.filter(|g| g.key != GroupKey::Launchers)
|
||||
.count()
|
||||
>= 2
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EVERY_SORT: [SortKey; 4] = SortKey::ALL;
|
||||
|
||||
fn game(
|
||||
id: &str,
|
||||
title: &str,
|
||||
store: &str,
|
||||
platform: Option<&str>,
|
||||
launcher: bool,
|
||||
) -> LibraryGame {
|
||||
LibraryGame {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
store: store.into(),
|
||||
launcher,
|
||||
icon: String::new(),
|
||||
platform: platform.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn article_fold_files_titles_where_a_reader_looks_for_them() {
|
||||
assert_eq!(sort_title("The Witcher 3"), "witcher 3");
|
||||
assert_eq!(sort_title("A Way Out"), "way out");
|
||||
assert_eq!(sort_title("An Untitled Story"), "untitled story");
|
||||
// Not an article, just a word starting with one.
|
||||
assert_eq!(sort_title("Theme Hospital"), "theme hospital");
|
||||
assert_eq!(sort_title("Anno 1800"), "anno 1800");
|
||||
// Diacritics relax; punctuation goes.
|
||||
assert_eq!(sort_title("Pokémon: Red!"), "pokemon red");
|
||||
// A title that is ONLY an article keeps something to sort on.
|
||||
assert_eq!(sort_title("The"), "the");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_less_store_games_bucket_under_their_store_not_unknown() {
|
||||
let games = [
|
||||
game("a", "Dota 2", "steam", None, false),
|
||||
game("b", "Half-Life", "steam", None, false),
|
||||
game("c", "Shadow of the Colossus", "custom", Some("PS2"), false),
|
||||
// Neither a platform nor a store we have a label for.
|
||||
game("d", "Mystery", "wat", None, false),
|
||||
];
|
||||
let groups = collate(&games, SortKey::HostOrder, Some(GroupBy::Platform));
|
||||
let labels: Vec<&str> = groups.iter().map(|g| g.label.as_str()).collect();
|
||||
assert!(labels.contains(&"Steam"), "{labels:?}");
|
||||
assert!(labels.contains(&"PS2"), "{labels:?}");
|
||||
assert!(labels.contains(&"Other"), "{labels:?}");
|
||||
assert!(
|
||||
!labels.contains(&"Unknown"),
|
||||
"a platform-less Steam library must not become one heap: {labels:?}"
|
||||
);
|
||||
let steam = groups.iter().find(|g| g.label == "Steam").unwrap();
|
||||
assert_eq!(steam.games, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchers_always_lead_whatever_the_sort() {
|
||||
let games = [
|
||||
game("z", "Zed", "steam", Some("PC"), false),
|
||||
game("l", "Steam", "steam", None, true),
|
||||
game("a", "Aaa", "steam", Some("PC"), false),
|
||||
];
|
||||
for sort in EVERY_SORT {
|
||||
let groups = collate(&games, sort, Some(GroupBy::Platform));
|
||||
assert_eq!(groups[0].key, GroupKey::Launchers, "{sort:?}");
|
||||
assert_eq!(groups[0].games, vec![1], "{sort:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_order_is_byte_identical_to_no_sorting_at_all() {
|
||||
let games = [
|
||||
game("c", "Zed", "steam", Some("PC"), false),
|
||||
game("a", "Aaa", "steam", Some("PC"), false),
|
||||
game("b", "Mmm", "steam", Some("PC"), false),
|
||||
];
|
||||
// The default must leave the shelf exactly as the host handed it over — a user who
|
||||
// never touches the sort pills sees no change whatever.
|
||||
assert_eq!(filtered(&games, SortKey::HostOrder, None), vec![0, 1, 2]);
|
||||
assert_eq!(filtered(&games, SortKey::Title, None), vec![1, 2, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_keys_keep_the_hosts_order() {
|
||||
let games = [
|
||||
game("a", "Same", "steam", Some("PC"), false),
|
||||
game("b", "Same", "steam", Some("PC"), false),
|
||||
game("c", "Same", "steam", Some("PC"), false),
|
||||
];
|
||||
assert_eq!(filtered(&games, SortKey::Title, None), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_returns_only_that_groups_games() {
|
||||
let games = [
|
||||
game("a", "Ico", "custom", Some("PS2"), false),
|
||||
game("b", "Dota", "steam", None, false),
|
||||
game("c", "SotC", "custom", Some("PS2"), false),
|
||||
];
|
||||
let want = GroupKey::Platform("PS2".into());
|
||||
assert_eq!(
|
||||
filtered(&games, SortKey::HostOrder, Some(&want)),
|
||||
vec![0, 2]
|
||||
);
|
||||
// A filter naming a group that no longer exists yields nothing, rather than
|
||||
// silently showing everything — a stale filter must not look like a working shelf.
|
||||
let gone = GroupKey::Platform("PS3".into());
|
||||
assert!(filtered(&games, SortKey::HostOrder, Some(&gone)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_single_group_libraries_are_not_worth_browsing() {
|
||||
assert!(!worth_browsing(&[]));
|
||||
assert!(!worth_browsing(&[game("l", "Steam", "steam", None, true)]));
|
||||
// One store, no platforms: nothing to choose between, so the button stays hidden.
|
||||
let one = [
|
||||
game("a", "Dota", "steam", None, false),
|
||||
game("b", "HL", "steam", None, false),
|
||||
];
|
||||
assert!(!worth_browsing(&one));
|
||||
// A second group earns the screen.
|
||||
let two = [
|
||||
game("a", "Dota", "steam", None, false),
|
||||
game("b", "Ico", "custom", Some("PS2"), false),
|
||||
];
|
||||
assert!(worth_browsing(&two));
|
||||
// Launchers alone never count — every library has them, and a screen offering
|
||||
// only "Launchers" is the single-tile screen this rule exists to prevent.
|
||||
let launcher_plus_one = [
|
||||
game("l", "Steam", "steam", None, true),
|
||||
game("a", "Dota", "steam", None, false),
|
||||
];
|
||||
assert!(!worth_browsing(&launcher_plus_one));
|
||||
}
|
||||
|
||||
/// The persisted strings, pinned literally. They are a FILE FORMAT: renaming one here
|
||||
/// silently resets every user's chosen sort to the default on their next launch.
|
||||
#[test]
|
||||
fn sort_keys_parse_from_their_stored_names_and_unknown_falls_back() {
|
||||
assert_eq!(SortKey::parse("title"), SortKey::Title);
|
||||
assert_eq!(SortKey::parse("platform"), SortKey::Platform);
|
||||
assert_eq!(SortKey::parse("store"), SortKey::Store);
|
||||
for k in EVERY_SORT {
|
||||
assert_eq!(SortKey::parse(k.id()), k, "{} round-trips", k.label());
|
||||
}
|
||||
// Anything else is a newer client's key, and the right answer to one is today's
|
||||
// shelf rather than an error — the rule `ui_palette` already follows.
|
||||
assert_eq!(SortKey::parse("something-newer"), SortKey::HostOrder);
|
||||
assert_eq!(SortKey::parse(""), SortKey::HostOrder);
|
||||
assert_eq!(SortKey::default(), SortKey::HostOrder);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod anim;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod collate;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod glyphs;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod launcher_icons;
|
||||
@@ -23,6 +25,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;
|
||||
|
||||
@@ -26,10 +26,20 @@ pub const RECEDE_SCALE: f64 = 0.24;
|
||||
pub const ROTATE_DEG: f64 = 38.0;
|
||||
/// Perspective depth for the tilt, px (CSS `perspective()` semantics).
|
||||
pub const PERSPECTIVE: f64 = 800.0;
|
||||
/// The darkening veil's max opacity (side cards stay opaque — they overlap).
|
||||
pub const RECEDE_DIM: f64 = 0.30;
|
||||
/// The darkening veil's max opacity (side cards stay opaque — they overlap). HALVED when
|
||||
/// the colour recede landed: this used to carry the whole "further away" reading on its
|
||||
/// own and had to be heavy for it, and is now left doing the one job a flat darkening is
|
||||
/// good at — separating cards that overlap. See `theme::recede_matrix`.
|
||||
pub const RECEDE_DIM: f64 = 0.15;
|
||||
/// Boundary recoil: a refused move deflects the strip this many px against the push.
|
||||
pub const BUMP_PX: f64 = 16.0;
|
||||
/// Mount entrance (see [`crate::anim::Entrance`]): a card arrives at this scale, this many
|
||||
/// design units below its berth, and — where the surface can turn a card at all — this many
|
||||
/// degrees away from the viewer. Shared by the home carousel and the library coverflow so
|
||||
/// the two read as one console arriving, not two widgets each with its own idea.
|
||||
pub const ENTER_SCALE: f64 = 0.74;
|
||||
pub const ENTER_RISE: f64 = 34.0;
|
||||
pub const ENTER_TURN_DEG: f64 = 62.0;
|
||||
/// L1/R1 jump distance.
|
||||
pub const JUMP: i32 = 5;
|
||||
|
||||
@@ -92,6 +102,97 @@ pub fn step_cursor(cursor: i32, len: usize, delta: i32, clamp: bool) -> StepResu
|
||||
}
|
||||
}
|
||||
|
||||
/// Which arrangement the library draws in.
|
||||
///
|
||||
/// The shelf is a browsing surface — one title at a time, big, with its artwork doing the
|
||||
/// talking. The grid is a FINDING surface: ~18 covers at once instead of the coverflow's
|
||||
/// legible three, for the moment you know what you want and just need to see it.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum LibraryView {
|
||||
#[default]
|
||||
Shelf,
|
||||
Grid,
|
||||
}
|
||||
|
||||
impl LibraryView {
|
||||
/// Parse the persisted `library_view` value, leniently — an unknown string is a newer
|
||||
/// client's, and the right answer to one is the shelf everyone already has.
|
||||
pub fn parse(s: &str) -> LibraryView {
|
||||
match s {
|
||||
"grid" => LibraryView::Grid,
|
||||
_ => LibraryView::Shelf,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
LibraryView::Shelf => "shelf",
|
||||
LibraryView::Grid => "grid",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LibraryView::Shelf => "Shelf",
|
||||
LibraryView::Grid => "Grid",
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [LibraryView; 2] = [LibraryView::Shelf, LibraryView::Grid];
|
||||
}
|
||||
|
||||
/// Grid cell geometry: the same 2:3 as the coverflow poster at roughly two-thirds the size,
|
||||
/// which is what puts three rows on a Deck's 800-tall panel with the detail band still
|
||||
/// readable underneath.
|
||||
pub const GRID_W: f64 = 150.0;
|
||||
pub const GRID_H: f64 = 225.0;
|
||||
pub const GRID_GAP: f64 = 16.0;
|
||||
|
||||
/// Which way a grid cursor is being pushed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GridDir {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
PageBack,
|
||||
PageForward,
|
||||
}
|
||||
|
||||
/// How many rows a shoulder press jumps.
|
||||
pub const GRID_PAGE_ROWS: i32 = 3;
|
||||
|
||||
/// Cursor arithmetic for a 2-D grid, `cols` wide.
|
||||
///
|
||||
/// Left/right walk WITHIN a row and refuse at its ends, which is the shelf's rule and the
|
||||
/// one a thumb already knows — wrapping to the next row would make a held Right scan the
|
||||
/// whole library, and there are shoulders for that.
|
||||
///
|
||||
/// Up/down move by a whole row and CLAMP into the tail row rather than refusing. A short
|
||||
/// last row is a layout accident, not a boundary the user chose to hit: pressing Down from
|
||||
/// above the gap should land on the last title, not thud.
|
||||
pub fn grid_step(cursor: i32, len: usize, cols: usize, dir: GridDir) -> StepResult {
|
||||
if len == 0 || cols == 0 {
|
||||
return StepResult::Boundary;
|
||||
}
|
||||
let (max, cols_i) = (len as i32 - 1, cols as i32);
|
||||
let col = cursor.rem_euclid(cols_i);
|
||||
let target = match dir {
|
||||
GridDir::Left if col > 0 => cursor - 1,
|
||||
GridDir::Right if col < cols_i - 1 => cursor + 1,
|
||||
GridDir::Left | GridDir::Right => return StepResult::Boundary,
|
||||
GridDir::Up => cursor - cols_i,
|
||||
GridDir::Down => (cursor + cols_i).min(max),
|
||||
GridDir::PageBack => (cursor - cols_i * GRID_PAGE_ROWS).max(0),
|
||||
GridDir::PageForward => (cursor + cols_i * GRID_PAGE_ROWS).min(max),
|
||||
};
|
||||
if target == cursor || target < 0 || target > max {
|
||||
StepResult::Boundary
|
||||
} else {
|
||||
StepResult::Moved(target)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4×4 matrix (row-major) — the coverflow card transform ------------------------------
|
||||
|
||||
/// `T(cx,cy) · P(depth) · Ry(angle) · S(s) · T(-w/2,-h/2)`: card-local (0..w, 0..h) →
|
||||
@@ -573,6 +674,11 @@ pub struct LibraryGame {
|
||||
/// [`pf_client_core::library::GameEntry::icon_token`]. Empty when the entry names no mark;
|
||||
/// a token we ship no art for simply draws nothing and the tile falls back to its name.
|
||||
pub icon: String,
|
||||
/// The system this title runs on (`"PC"`, `"PS2"`, …) — the host's own free-form display
|
||||
/// string, passed through untouched. `None` for a store-front game whose host said
|
||||
/// nothing, which is the common case: the rom-manager plugin populates this, Steam does
|
||||
/// not. [`crate::collate`] is where that `None` is given a meaning.
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -761,6 +867,91 @@ mod tests {
|
||||
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// The grid's two different boundary rules, which is the whole subtlety of this
|
||||
/// function: a row END refuses (like the shelf), a short TAIL row clamps.
|
||||
#[test]
|
||||
fn grid_rows_refuse_at_their_ends_but_the_tail_row_clamps() {
|
||||
// 11 items, 4 columns: rows of 4, 4, 3.
|
||||
let (len, cols) = (11, 4);
|
||||
// Within a row.
|
||||
assert_eq!(
|
||||
grid_step(1, len, cols, GridDir::Right),
|
||||
StepResult::Moved(2)
|
||||
);
|
||||
assert_eq!(grid_step(2, len, cols, GridDir::Left), StepResult::Moved(1));
|
||||
// At a row's ends: refused, NOT wrapped onto the neighbouring row.
|
||||
assert_eq!(
|
||||
grid_step(3, len, cols, GridDir::Right),
|
||||
StepResult::Boundary
|
||||
);
|
||||
assert_eq!(grid_step(4, len, cols, GridDir::Left), StepResult::Boundary);
|
||||
// Down from the top row lands directly below.
|
||||
assert_eq!(grid_step(1, len, cols, GridDir::Down), StepResult::Moved(5));
|
||||
// Down into the SHORT tail row clamps to the last item rather than thudding —
|
||||
// index 7 would map to 11, which does not exist.
|
||||
assert_eq!(
|
||||
grid_step(7, len, cols, GridDir::Down),
|
||||
StepResult::Moved(10)
|
||||
);
|
||||
// …and once there, Down really is the end.
|
||||
assert_eq!(
|
||||
grid_step(10, len, cols, GridDir::Down),
|
||||
StepResult::Boundary
|
||||
);
|
||||
assert_eq!(grid_step(2, len, cols, GridDir::Up), StepResult::Boundary);
|
||||
assert_eq!(grid_step(6, len, cols, GridDir::Up), StepResult::Moved(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_pages_by_rows_and_lands_on_the_ends() {
|
||||
let (len, cols) = (40, 5);
|
||||
assert_eq!(
|
||||
grid_step(0, len, cols, GridDir::PageForward),
|
||||
StepResult::Moved(15)
|
||||
);
|
||||
// A page past the end lands ON the end rather than refusing — a jump is a
|
||||
// "take me there", the same reading `step_cursor`'s clamped mode has.
|
||||
assert_eq!(
|
||||
grid_step(35, len, cols, GridDir::PageForward),
|
||||
StepResult::Moved(39)
|
||||
);
|
||||
assert_eq!(
|
||||
grid_step(39, len, cols, GridDir::PageForward),
|
||||
StepResult::Boundary
|
||||
);
|
||||
assert_eq!(
|
||||
grid_step(3, len, cols, GridDir::PageBack),
|
||||
StepResult::Moved(0)
|
||||
);
|
||||
assert_eq!(
|
||||
grid_step(0, len, cols, GridDir::PageBack),
|
||||
StepResult::Boundary
|
||||
);
|
||||
}
|
||||
|
||||
/// The persisted view name is a FILE FORMAT, and an unknown one must land on the shelf
|
||||
/// — a newer client writing `"coverwall"` must not leave this one with no arrangement.
|
||||
#[test]
|
||||
fn library_view_parses_leniently() {
|
||||
assert_eq!(LibraryView::parse("grid"), LibraryView::Grid);
|
||||
assert_eq!(LibraryView::parse("shelf"), LibraryView::Shelf);
|
||||
assert_eq!(LibraryView::parse("coverwall"), LibraryView::Shelf);
|
||||
assert_eq!(LibraryView::parse(""), LibraryView::Shelf);
|
||||
assert_eq!(LibraryView::default(), LibraryView::Shelf);
|
||||
for v in LibraryView::ALL {
|
||||
assert_eq!(LibraryView::parse(v.id()), v, "{} round-trips", v.label());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_step_is_safe_on_a_degenerate_grid() {
|
||||
assert_eq!(grid_step(0, 0, 4, GridDir::Right), StepResult::Boundary);
|
||||
assert_eq!(grid_step(0, 5, 0, GridDir::Right), StepResult::Boundary);
|
||||
// One column: left/right are always refused, up/down still walk.
|
||||
assert_eq!(grid_step(1, 5, 1, GridDir::Right), StepResult::Boundary);
|
||||
assert_eq!(grid_step(1, 5, 1, GridDir::Down), StepResult::Moved(2));
|
||||
}
|
||||
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
|
||||
/// each group. The renderer's `launcher_count()` reads the launcher group as the prefix
|
||||
/// `0..n`, so an interleaved list would silently mislabel the group heading.
|
||||
@@ -772,6 +963,7 @@ mod tests {
|
||||
store: "steam".into(),
|
||||
launcher,
|
||||
icon: String::new(),
|
||||
platform: None,
|
||||
};
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(vec![
|
||||
@@ -801,6 +993,7 @@ mod tests {
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
platform: None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! every screen animates and reads identically.
|
||||
|
||||
pub(crate) mod add_host;
|
||||
pub(crate) mod collections;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod host_options;
|
||||
pub(crate) mod library;
|
||||
@@ -137,6 +138,9 @@ pub(crate) fn host_link(row: &HostRow) -> Option<String> {
|
||||
pub(crate) enum Screen {
|
||||
Home(home::HomeScreen),
|
||||
Library(library::LibraryScreen),
|
||||
/// The library's groups, one tile each — the drill-in that turns "group by console"
|
||||
/// into somewhere you can actually go.
|
||||
Collections(collections::CollectionsScreen),
|
||||
Settings(settings::SettingsScreen),
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
@@ -156,6 +160,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::Home(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Library(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Collections(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Settings(s) => s.menu(ev, ctx, fx),
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
@@ -173,6 +178,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::Home(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Library(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Collections(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Settings(s) => s.pointer(p, ctx, fx),
|
||||
Screen::AddHost(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Pair(s) => s.pointer(p, ctx, fx),
|
||||
@@ -212,7 +218,7 @@ impl Screen {
|
||||
|
||||
pub(crate) fn background(&self) -> Bg {
|
||||
match self {
|
||||
Screen::Home(_) | Screen::Library(_) => Bg::Aurora,
|
||||
Screen::Home(_) | Screen::Library(_) | Screen::Collections(_) => Bg::Aurora,
|
||||
_ => Bg::Form,
|
||||
}
|
||||
}
|
||||
@@ -221,6 +227,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::Home(_) => "Select a Host".into(),
|
||||
Screen::Library(s) => s.title(),
|
||||
Screen::Collections(s) => s.title(),
|
||||
Screen::Settings(_) => "Settings".into(),
|
||||
Screen::AddHost(s) => s.title(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
@@ -233,6 +240,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::Home(s) => s.hints(ctx),
|
||||
Screen::Library(s) => s.hints(ctx),
|
||||
Screen::Collections(s) => s.hints(ctx),
|
||||
Screen::Settings(s) => s.hints(ctx),
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
@@ -256,6 +264,7 @@ impl Screen {
|
||||
match self {
|
||||
Screen::Home(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Library(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Collections(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
//! The library's collections: one tile per group, and the sort that orders them all.
|
||||
//!
|
||||
//! This is the drill-in the whole of Part C exists for — group by platform, walk the
|
||||
//! platforms, pick PS3, see its games. It deliberately borrows the home carousel's tile
|
||||
//! language rather than inventing a third one: a collection is a place you go, exactly like
|
||||
//! a host is, and the console should have one idea of what "a place you go" looks like.
|
||||
//!
|
||||
//! It owns no model. The groups come from [`crate::collate`] over the library screen's own
|
||||
//! snapshot, and opening one pushes a `LibraryScreen` with a FILTER rather than a filtered
|
||||
//! copy of the library — so the art pump, the fetch flow and the shared model never learn
|
||||
//! that collections exist.
|
||||
|
||||
use crate::anim::{entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::collate::{collate, GroupBy, GroupKey, SortKey};
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::{
|
||||
initials, step_cursor, LibraryGame, LibraryShared, StepResult, BUMP_C, BUMP_K, BUMP_PX,
|
||||
ENTER_RISE, ENTER_SCALE, SPRING_C, SPRING_K,
|
||||
};
|
||||
use crate::model::HostRow;
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
|
||||
use crate::widgets::TabStrip;
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, Image, Paint, RRect, Rect};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const TILE_W: f64 = 300.0;
|
||||
const TILE_H: f64 = 210.0;
|
||||
const TILE_GAP: f64 = 26.0;
|
||||
const TILE_CORNER: f64 = 24.0;
|
||||
/// How many poster thumbs fan across a tile. Three is enough to say "this is a shelf of
|
||||
/// covers" and few enough that they stay recognisable at tile size.
|
||||
const FAN: usize = 3;
|
||||
|
||||
pub(crate) struct CollectionsScreen {
|
||||
/// Everything needed to build the filtered library this screen pushes into. Copied at
|
||||
/// construction from the shelf that opened it, so a collection inherits its host AND
|
||||
/// its pinned profile — a drill-in off a pinned card's shelf must still launch the way
|
||||
/// that card does.
|
||||
host: HostRow,
|
||||
cursor: i32,
|
||||
anim: Spring,
|
||||
bump: Spring,
|
||||
sort: SortKey,
|
||||
sort_tabs: TabStrip,
|
||||
/// Group labels/keys/counts as of the last sync, and the poster ids that fan on each.
|
||||
groups: Vec<GroupTile>,
|
||||
generation: u64,
|
||||
/// Decoded posters, borrowed by id from whatever the library screen already fetched —
|
||||
/// this screen never asks for art of its own. A group whose covers have not arrived
|
||||
/// yet simply fans nothing and shows its monogram, which is also what a platform of
|
||||
/// art-less ROM entries looks like permanently.
|
||||
art: HashMap<String, Image>,
|
||||
geom: Vec<Rect>,
|
||||
entrance: Option<Entrance>,
|
||||
entrance_armed: bool,
|
||||
}
|
||||
|
||||
struct GroupTile {
|
||||
key: GroupKey,
|
||||
label: String,
|
||||
count: usize,
|
||||
/// Up to [`FAN`] game ids whose posters, if decoded, fan across the tile.
|
||||
fan: Vec<String>,
|
||||
}
|
||||
|
||||
impl CollectionsScreen {
|
||||
pub(crate) fn new(host: &HostRow, sort: SortKey) -> CollectionsScreen {
|
||||
CollectionsScreen {
|
||||
host: host.clone(),
|
||||
cursor: 0,
|
||||
anim: Spring::rest(0.0),
|
||||
bump: Spring::rest(0.0),
|
||||
sort,
|
||||
sort_tabs: TabStrip::new(),
|
||||
groups: Vec::new(),
|
||||
generation: u64::MAX,
|
||||
art: HashMap::new(),
|
||||
geom: Vec::new(),
|
||||
entrance: None,
|
||||
entrance_armed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn title(&self) -> String {
|
||||
self.host.name.clone()
|
||||
}
|
||||
|
||||
/// Re-derive the tiles when the library moved under us (a rescan, a late fetch) or the
|
||||
/// sort changed. Cheap enough to do on a generation bump: collation is one pass and
|
||||
/// the tiles hold labels and ids, not games.
|
||||
fn sync(&mut self, library: &LibraryShared) {
|
||||
if library.generation() == self.generation {
|
||||
return;
|
||||
}
|
||||
let (_, games, generation) = library.snapshot();
|
||||
self.generation = generation;
|
||||
self.groups = collate(&games, self.sort, Some(GroupBy::Platform))
|
||||
.into_iter()
|
||||
.map(|g| GroupTile {
|
||||
count: g.games.len(),
|
||||
fan: g
|
||||
.games
|
||||
.iter()
|
||||
.filter_map(|&i| games.get(i))
|
||||
.map(|game: &LibraryGame| game.id.clone())
|
||||
.take(FAN)
|
||||
.collect(),
|
||||
key: g.key,
|
||||
label: g.label,
|
||||
})
|
||||
.collect();
|
||||
self.cursor = self.cursor.clamp(0, (self.groups.len() as i32 - 1).max(0));
|
||||
}
|
||||
|
||||
/// Adopt whatever posters the library screen has already decoded. Read from the shared
|
||||
/// model's art queue would be wrong — that queue is drained by the shelf — so this
|
||||
/// takes a snapshot handed down at construction time instead.
|
||||
pub(crate) fn adopt_art(&mut self, art: HashMap<String, Image>) {
|
||||
self.art = art;
|
||||
}
|
||||
|
||||
fn step(&mut self, delta: i32) -> Option<MenuPulse> {
|
||||
match step_cursor(self.cursor, self.groups.len(), delta, false) {
|
||||
StepResult::Moved(c) => {
|
||||
self.cursor = c;
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
StepResult::Boundary => {
|
||||
self.bump = Spring {
|
||||
pos: -BUMP_PX * f64::from(delta.signum()),
|
||||
vel: 0.0,
|
||||
};
|
||||
Some(MenuPulse::Boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Step the sort and persist it. The shelf reads `library_sort` every frame, so the
|
||||
/// collection tiles AND the shelf waiting behind this screen both re-order at once —
|
||||
/// which is the point of putting the pills here rather than in a dialog.
|
||||
fn step_sort(&mut self, delta: i32, ctx: &mut Ctx) -> Option<MenuPulse> {
|
||||
let all = SortKey::ALL;
|
||||
let at = all.iter().position(|s| *s == self.sort).unwrap_or(0);
|
||||
let next = (at as i32 + delta).rem_euclid(all.len() as i32) as usize;
|
||||
self.sort = all[next];
|
||||
ctx.settings.library_sort = self.sort.id().to_string();
|
||||
ctx.settings.save();
|
||||
// Force a re-collate: the sort changed, not the library.
|
||||
self.generation = u64::MAX;
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
self.sync(ctx.library);
|
||||
match ev {
|
||||
MenuEvent::Move(MenuDir::Left) => self.step(-1),
|
||||
MenuEvent::Move(MenuDir::Right) => self.step(1),
|
||||
MenuEvent::JumpBack => self.step_sort(-1, ctx),
|
||||
MenuEvent::JumpForward => self.step_sort(1, ctx),
|
||||
MenuEvent::Confirm => {
|
||||
let g = self.groups.get(self.cursor.max(0) as usize)?;
|
||||
let mut shelf = super::library::LibraryScreen::new(&self.host);
|
||||
shelf.set_filter(g.key.clone(), g.label.clone());
|
||||
fx.push(Screen::Library(shelf));
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
self.sync(ctx.library);
|
||||
match p.kind {
|
||||
PointerKind::Scroll { up } => {
|
||||
self.step(if up { -1 } else { 1 });
|
||||
true
|
||||
}
|
||||
PointerKind::Press => {
|
||||
if let Some(i) = self.sort_tabs.pointer(p) {
|
||||
let all = SortKey::ALL;
|
||||
if let Some(&s) = all.get(i) {
|
||||
self.sort = s;
|
||||
ctx.settings.library_sort = s.id().to_string();
|
||||
ctx.settings.save();
|
||||
self.generation = u64::MAX;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
match p.pick(&self.geom).filter(|i| *i < self.groups.len()) {
|
||||
// The carousel's rule: a press brings a tile to the centre, and a press
|
||||
// on the CENTRED tile opens it.
|
||||
Some(i) if i == self.cursor as usize => {
|
||||
self.menu(MenuEvent::Confirm, ctx, fx);
|
||||
true
|
||||
}
|
||||
Some(i) => {
|
||||
self.cursor = i as i32;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Open"),
|
||||
Hint::new(HintKey::Shoulders, "Sort"),
|
||||
Hint::new(HintKey::Back, "Back"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
self.sync(ctx.library);
|
||||
let labels: Vec<&str> = SortKey::ALL.iter().map(|s| s.label()).collect();
|
||||
let selected = SortKey::ALL
|
||||
.iter()
|
||||
.position(|s| *s == self.sort)
|
||||
.unwrap_or(0);
|
||||
let strip = Rect::from_xywh(rect.left, rect.top, rect.width(), (46.0 * k) as f32);
|
||||
self.sort_tabs
|
||||
.render(canvas, strip, &labels, selected, fonts, k, dt);
|
||||
|
||||
let field = Rect::from_ltrb(rect.left, strip.bottom, rect.right, rect.bottom);
|
||||
self.anim
|
||||
.step(f64::from(self.cursor), SPRING_K, SPRING_C, dt);
|
||||
self.anim.settle(f64::from(self.cursor), 0.001, 0.01);
|
||||
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
|
||||
self.bump.settle(0.0, 0.3, 4.0);
|
||||
if crate::theme::reduce_motion() {
|
||||
self.bump = Spring::rest(0.0);
|
||||
}
|
||||
if !self.entrance_armed {
|
||||
self.entrance_armed = true;
|
||||
self.entrance = Some(Entrance::new(
|
||||
entrances::CARDS,
|
||||
self.cursor.max(0) as usize,
|
||||
ctx.t,
|
||||
));
|
||||
}
|
||||
if self.entrance.is_some_and(|e| e.done(ctx.t)) {
|
||||
self.entrance = None;
|
||||
}
|
||||
|
||||
let w = f64::from(field.width());
|
||||
let tile_w = (TILE_W * k).min(w * 0.8);
|
||||
let tile_h = (TILE_H * k).min(f64::from(field.height()) - 40.0 * k);
|
||||
let pitch = tile_w + TILE_GAP * k;
|
||||
let cx0 = f64::from(field.left) + w / 2.0 + self.bump.pos * k;
|
||||
let cy = f64::from(field.top) + f64::from(field.height()) / 2.0;
|
||||
|
||||
self.geom.clear();
|
||||
self.geom.resize(self.groups.len(), Rect::new_empty());
|
||||
for i in 0..self.groups.len() {
|
||||
let d = i as f64 - self.anim.pos;
|
||||
if d.abs() > 2.6 {
|
||||
continue;
|
||||
}
|
||||
let f = 1.0 - d.abs().min(1.0);
|
||||
let ent = self
|
||||
.entrance
|
||||
.map_or(EntranceAt::SETTLED, |e| e.at(i, ctx.t));
|
||||
let arrive = ENTER_SCALE + (1.0 - ENTER_SCALE) * ent.travel;
|
||||
let scale = (0.88 + 0.12 * f) * arrive;
|
||||
let alpha = (0.78 + 0.22 * f) * ent.fade;
|
||||
let cxx = cx0 + d * pitch;
|
||||
let cyy = cy + (1.0 - ent.travel) * ENTER_RISE * k;
|
||||
let tile = Rect::from_xywh(
|
||||
(cxx - tile_w / 2.0) as f32,
|
||||
(cyy - tile_h / 2.0) as f32,
|
||||
tile_w as f32,
|
||||
tile_h as f32,
|
||||
);
|
||||
self.geom[i] = Rect::from_xywh(
|
||||
(cxx - tile_w * scale / 2.0) as f32,
|
||||
(cyy - tile_h * scale / 2.0) as f32,
|
||||
(tile_w * scale) as f32,
|
||||
(tile_h * scale) as f32,
|
||||
);
|
||||
canvas.save();
|
||||
canvas.translate((cxx as f32, cyy as f32));
|
||||
canvas.scale((scale as f32, scale as f32));
|
||||
canvas.translate((-cxx as f32, -cyy as f32));
|
||||
let recede = 1.0 - f;
|
||||
let mut lp = Paint::default();
|
||||
lp.set_alpha_f(alpha as f32);
|
||||
if recede > 0.001 {
|
||||
lp.set_color_filter(skia_safe::color_filters::matrix_row_major(
|
||||
&crate::theme::recede_matrix(recede),
|
||||
None,
|
||||
));
|
||||
}
|
||||
canvas.save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&lp));
|
||||
crate::theme::focus_halo(canvas, tile, TILE_CORNER as f32, k as f32, f as f32);
|
||||
if f > 0.4 {
|
||||
crate::theme::drop_shadow(
|
||||
canvas,
|
||||
tile,
|
||||
TILE_CORNER as f32,
|
||||
k as f32,
|
||||
0.45 * f as f32,
|
||||
);
|
||||
}
|
||||
self.draw_tile(canvas, fonts, i, tile, k);
|
||||
canvas.restore();
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
if self.groups.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"Nothing to collect yet — this library is still loading.",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
fg(0.55),
|
||||
f64::from(field.left) + w / 2.0,
|
||||
cy,
|
||||
w * 0.7,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tile(&self, canvas: &Canvas, fonts: &Fonts, i: usize, rect: Rect, k: f64) {
|
||||
let Some(g) = self.groups.get(i) else { return };
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
rect,
|
||||
TILE_CORNER as f32,
|
||||
Some(accent(0.16)),
|
||||
PanelStroke::Gradient,
|
||||
k as f32,
|
||||
);
|
||||
crate::theme::panel_highlight(canvas, rect, TILE_CORNER as f32, k as f32);
|
||||
|
||||
// The fan: up to three covers overlapping to the right, newest on top — the visual
|
||||
// shorthand for "a stack of games" that says more about a collection than any count
|
||||
// does. Falls back to the group's monogram when none of them have art, which is
|
||||
// also the permanent look of a platform full of art-less ROM entries.
|
||||
let pad = 18.0 * k;
|
||||
let thumb_h = f64::from(rect.height()) - 2.0 * pad - 34.0 * k;
|
||||
let thumb_w = thumb_h * 2.0 / 3.0;
|
||||
let have: Vec<&Image> = g.fan.iter().filter_map(|id| self.art.get(id)).collect();
|
||||
let (l, t) = (f64::from(rect.left) + pad, f64::from(rect.top) + pad);
|
||||
if have.is_empty() {
|
||||
let badge = Rect::from_xywh(l as f32, t as f32, thumb_w as f32, thumb_h as f32);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(badge, (10.0 * k) as f32, (10.0 * k) as f32),
|
||||
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
|
||||
);
|
||||
let mono = initials(&g.label);
|
||||
let size = thumb_h * 0.3;
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&mono, None).0;
|
||||
canvas.draw_str(
|
||||
&mono,
|
||||
skia_safe::Point::new(
|
||||
badge.center_x() - tw / 2.0,
|
||||
badge.center_y() + (size * 0.36) as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(fg(0.45), None),
|
||||
);
|
||||
} else {
|
||||
for (n, img) in have.iter().enumerate() {
|
||||
let x = l + n as f64 * thumb_w * 0.42;
|
||||
let cell = Rect::from_xywh(x as f32, t as f32, thumb_w as f32, thumb_h as f32);
|
||||
canvas.save();
|
||||
canvas.clip_rrect(
|
||||
RRect::new_rect_xy(cell, (10.0 * k) as f32, (10.0 * k) as f32),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
let (iw, ih) = (img.width() as f32, img.height() as f32);
|
||||
let aspect = cell.width() / cell.height();
|
||||
let src = if iw / ih > aspect {
|
||||
let sw = ih * aspect;
|
||||
Rect::from_xywh((iw - sw) / 2.0, 0.0, sw, ih)
|
||||
} else {
|
||||
let sh = iw / aspect;
|
||||
Rect::from_xywh(0.0, (ih - sh) / 2.0, iw, sh)
|
||||
};
|
||||
canvas.draw_image_rect(
|
||||
*img,
|
||||
Some((&src, skia_safe::canvas::SrcRectConstraint::Fast)),
|
||||
cell,
|
||||
&Paint::default(),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
let text_x =
|
||||
l + thumb_w + (have.len().saturating_sub(1)) as f64 * thumb_w * 0.42 + 16.0 * k;
|
||||
let max_w = f64::from(rect.right) - pad - text_x;
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&g.label,
|
||||
text_x,
|
||||
t + 26.0 * k,
|
||||
W::Bold,
|
||||
21.0 * k,
|
||||
fg(1.0),
|
||||
max_w,
|
||||
);
|
||||
let count = if g.count == 1 {
|
||||
"1 title".to_string()
|
||||
} else {
|
||||
format!("{} titles", g.count)
|
||||
};
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&count,
|
||||
text_x,
|
||||
t + 50.0 * k,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
max_w,
|
||||
);
|
||||
// What KIND of collection this is, so "Steam" as a platform bucket and "Steam" as
|
||||
// a store read as the same thing they are.
|
||||
let kind = match &g.key {
|
||||
GroupKey::Launchers => "LAUNCHERS",
|
||||
GroupKey::Platform(_) => "PLATFORM",
|
||||
GroupKey::Store(_) => "STORE",
|
||||
};
|
||||
fonts.draw_tracked(
|
||||
canvas,
|
||||
kind,
|
||||
text_x,
|
||||
f64::from(rect.bottom) - pad,
|
||||
W::SemiBold,
|
||||
11.0 * k,
|
||||
1.4 * k,
|
||||
fg(0.45),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@
|
||||
//! chases it, and the focus pop (scale/brightness/fade) reads off the LIVE sprung
|
||||
//! distance so the look always matches the strip mid-motion.
|
||||
|
||||
use crate::anim::Spring;
|
||||
use crate::anim::{entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K};
|
||||
use crate::library::{
|
||||
step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, ENTER_RISE, ENTER_SCALE, SPRING_C, SPRING_K,
|
||||
};
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
|
||||
@@ -23,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,
|
||||
@@ -34,6 +58,11 @@ pub(crate) struct HomeScreen {
|
||||
/// carousel culled. Scaled to match: side tiles draw at 0.88, and a press near their
|
||||
/// edge would otherwise pick a neighbour.
|
||||
geom: Vec<Rect>,
|
||||
/// The mount entrance, playing out. `None` before the first frame (the shell clock
|
||||
/// isn't in scope until then) and again once it has finished, so the steady state pays
|
||||
/// nothing at all for it; [`Self::entrance_armed`] is what stops it re-arming.
|
||||
entrance: Option<Entrance>,
|
||||
entrance_armed: bool,
|
||||
}
|
||||
|
||||
impl HomeScreen {
|
||||
@@ -44,6 +73,8 @@ impl HomeScreen {
|
||||
bump: Spring::rest(0.0),
|
||||
keys: Vec::new(),
|
||||
geom: Vec::new(),
|
||||
entrance: None,
|
||||
entrance_armed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,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
|
||||
@@ -70,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,
|
||||
@@ -77,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);
|
||||
@@ -167,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);
|
||||
@@ -209,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"));
|
||||
@@ -246,6 +292,27 @@ impl HomeScreen {
|
||||
self.anim.settle(f64::from(self.cursor), 0.001, 0.01);
|
||||
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
|
||||
self.bump.settle(0.0, 0.3, 4.0);
|
||||
// Reduced motion drops the recoil TRAVEL but not its meaning: the refusal is
|
||||
// already reported as a `MenuPulse::Boundary` haptic, which is the half that says
|
||||
// "there is nothing that way". The cursor chase itself stays sprung — that is
|
||||
// navigation, not decoration, and freezing it would make the strip jump.
|
||||
if crate::theme::reduce_motion() {
|
||||
self.bump = Spring::rest(0.0);
|
||||
}
|
||||
// Arm the mount entrance on the first frame — the constructor has no clock, and
|
||||
// "first frame after a push" is the same moment. Anchored on the CURSOR, so a
|
||||
// restored selection assembles around the eye rather than sweeping in from an end.
|
||||
if !self.entrance_armed {
|
||||
self.entrance_armed = true;
|
||||
self.entrance = Some(Entrance::new(
|
||||
entrances::CARDS,
|
||||
self.cursor.max(0) as usize,
|
||||
ctx.t,
|
||||
));
|
||||
}
|
||||
if self.entrance.is_some_and(|e| e.done(ctx.t)) {
|
||||
self.entrance = None;
|
||||
}
|
||||
|
||||
let w = f64::from(rect.width());
|
||||
let tile_w = (TILE_W * k).min(w * 0.84);
|
||||
@@ -256,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 {
|
||||
@@ -265,15 +332,25 @@ impl HomeScreen {
|
||||
continue;
|
||||
}
|
||||
let f = 1.0 - d.abs().min(1.0); // 1 at focus → 0 one slot out
|
||||
let scale = 0.88 + 0.12 * f;
|
||||
let alpha = 0.78 + 0.22 * f;
|
||||
// The entrance folds into the transform and the layer alpha this tile already
|
||||
// applies — no extra pass, and once it's over the arithmetic is `* 1.0`.
|
||||
let ent = self
|
||||
.entrance
|
||||
.map_or(EntranceAt::SETTLED, |e| e.at(i, ctx.t));
|
||||
let arrive = ENTER_SCALE + (1.0 - ENTER_SCALE) * ent.travel;
|
||||
let scale = (0.88 + 0.12 * f) * arrive;
|
||||
let alpha = (0.78 + 0.22 * f) * ent.fade;
|
||||
let cx = cx0 + d * pitch;
|
||||
let cy = cy + (1.0 - ent.travel) * ENTER_RISE * k;
|
||||
let tile = Rect::from_xywh(
|
||||
(cx - tile_w / 2.0) as f32,
|
||||
(cy - tile_h / 2.0) as f32,
|
||||
tile_w as f32,
|
||||
tile_h as f32,
|
||||
);
|
||||
// Hit boxes track the DRAWN geometry, entrance included: the tile is off its
|
||||
// berth by up to 34 dp while arriving, and a press in that second must land on
|
||||
// the card the eye sees, not on where it is about to be.
|
||||
self.geom[i] = Rect::from_xywh(
|
||||
(cx - tile_w * scale / 2.0) as f32,
|
||||
(cy - tile_h * scale / 2.0) as f32,
|
||||
@@ -284,7 +361,21 @@ impl HomeScreen {
|
||||
canvas.translate((cx as f32, cy as f32));
|
||||
canvas.scale((scale as f32, scale as f32));
|
||||
canvas.translate((-cx as f32, -cy as f32));
|
||||
canvas.save_layer_alpha_f(None, alpha as f32);
|
||||
// The layer carries the fade AND the colour recede: one matrix per card, built
|
||||
// and thrown away here, which is free next to the aurora behind it.
|
||||
let recede = 1.0 - f;
|
||||
let mut lp = Paint::default();
|
||||
lp.set_alpha_f(alpha as f32);
|
||||
if recede > 0.001 {
|
||||
lp.set_color_filter(skia_safe::color_filters::matrix_row_major(
|
||||
&crate::theme::recede_matrix(recede),
|
||||
None,
|
||||
));
|
||||
}
|
||||
canvas.save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&lp));
|
||||
// The focused tile gets a palette-tinted glow UNDER its shadow — the mark that
|
||||
// reads from a sofa, where a 12 % scale difference does not.
|
||||
crate::theme::focus_halo(canvas, tile, TILE_CORNER as f32, k as f32, f as f32);
|
||||
if f > 0.4 {
|
||||
crate::theme::drop_shadow(
|
||||
canvas,
|
||||
@@ -294,13 +385,17 @@ 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 brightness recede: an opaque veil, matching the coverflow's rule.
|
||||
// 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
|
||||
// light, and this is left doing the one job a flat darkening is actually good
|
||||
// at — separating cards that overlap.
|
||||
if f < 1.0 {
|
||||
let veil = (1.0 - f) as f32 * 0.24;
|
||||
let veil = (1.0 - f) as f32 * 0.12;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(tile, (TILE_CORNER * k) as f32, (TILE_CORNER * k) as f32),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, veil), None),
|
||||
@@ -338,9 +433,10 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
},
|
||||
k as f32,
|
||||
);
|
||||
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;
|
||||
@@ -454,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,
|
||||
@@ -463,9 +568,10 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
|
||||
PanelStroke::GradientDashed,
|
||||
k as f32,
|
||||
);
|
||||
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),
|
||||
@@ -486,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,
|
||||
@@ -511,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,
|
||||
@@ -521,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 {
|
||||
@@ -551,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()
|
||||
@@ -566,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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,16 @@ enum RowId {
|
||||
/// backdrop behind this very row re-colours as it steps, which is the whole reason the
|
||||
/// picker lives on a screen rather than in a dialog.
|
||||
Palette,
|
||||
/// Freeze the console's decorative motion — see `trust::Settings::reduce_motion`. Sits
|
||||
/// beside the palette row for the same reason it does: both are presentation, and the
|
||||
/// effect of stepping this one is visible on the backdrop behind it.
|
||||
ReduceMotion,
|
||||
/// How the game library arranges its titles — see `library::LibraryView`. Lives beside
|
||||
/// the other presentation rows rather than on a face button in the library itself: the
|
||||
/// library's five hints are already spoken for (A play, X copy link, Y collections,
|
||||
/// L1/R1 jump, B back), and this is a preference you set once, not a thing you toggle
|
||||
/// while browsing.
|
||||
LibraryView,
|
||||
}
|
||||
|
||||
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
|
||||
@@ -136,6 +146,8 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
"Interface",
|
||||
&[
|
||||
RowId::Palette,
|
||||
RowId::ReduceMotion,
|
||||
RowId::LibraryView,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
@@ -722,6 +734,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Background",
|
||||
crate::library::palette(&s.ui_palette).name.into(),
|
||||
),
|
||||
// Phrased as the thing that is ON, not as the suppression, so "On" means the
|
||||
// reduction is in effect — the same way every other toggle on this screen reads.
|
||||
RowId::ReduceMotion => (None, "Reduce motion", on_off(s.reduce_motion).into()),
|
||||
RowId::LibraryView => (
|
||||
None,
|
||||
"Library view",
|
||||
crate::library::LibraryView::parse(&s.library_view)
|
||||
.label()
|
||||
.into(),
|
||||
),
|
||||
RowId::Stats => (
|
||||
None,
|
||||
"Statistics overlay",
|
||||
@@ -839,6 +861,15 @@ fn detail(id: RowId) -> &'static str {
|
||||
"The colour family this backdrop drifts through — it changes as you step, so \
|
||||
pick by looking. Appearance only; nothing about a stream depends on it."
|
||||
}
|
||||
RowId::ReduceMotion => {
|
||||
"Freezes the backdrop and replaces the console's slides and pops with plain \
|
||||
fades. Also the gentler choice on an OLED, where a still field can sit for \
|
||||
hours."
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
"Shelf shows one cover at a time, big. Grid shows about eighteen at once — \
|
||||
for when you already know what you are looking for."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
@@ -1041,6 +1072,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
let cur = all.iter().position(|p| p.id == s.ui_palette);
|
||||
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
|
||||
}
|
||||
RowId::ReduceMotion => toggle(&mut s.reduce_motion, delta, wrap),
|
||||
RowId::LibraryView => {
|
||||
let all = &crate::library::LibraryView::ALL;
|
||||
let cur = crate::library::LibraryView::parse(&s.library_view);
|
||||
let at = all.iter().position(|v| *v == cur);
|
||||
step_option(at, all.len(), delta, wrap).map(|i| s.library_view = all[i].id().into())
|
||||
}
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
@@ -1669,9 +1707,11 @@ mod tests {
|
||||
seen.push(*id);
|
||||
}
|
||||
}
|
||||
// The pre-tab flat list, plus the palette row and the lossless-audio row later passes added.
|
||||
assert_eq!(seen.len(), 31, "{seen:?}");
|
||||
// The pre-tab flat list, plus the palette row, the lossless-audio row and the
|
||||
// reduce-motion row later passes added.
|
||||
assert_eq!(seen.len(), 33, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
assert!(seen.contains(&RowId::AudioFormat));
|
||||
// The catalog rows belong to the trailing tab, which builds them at render time.
|
||||
assert!(TABS[PROFILES_TAB].1.is_empty());
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The
|
||||
//! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
|
||||
|
||||
use crate::anim::Progress;
|
||||
use crate::anim::{springs, Spring};
|
||||
use crate::glyphs::GlyphStyle;
|
||||
use crate::library::{mesh_sksl, palette, LibraryShared};
|
||||
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||
@@ -26,20 +26,110 @@ use std::time::Instant;
|
||||
mod overlays;
|
||||
mod render;
|
||||
|
||||
const TRANSITION_S: f64 = 0.26;
|
||||
/// The reduced-motion transition: quick and critically damped, drawn as a pure crossfade
|
||||
/// (no slide, no scale — see `render.rs`). Still a transition and not a cut, because the
|
||||
/// screen stack needs to stay legible: an instant swap loses the "you went somewhere"
|
||||
/// reading that is the only spatial cue a console shell has.
|
||||
const REDUCED_NAV: crate::anim::SpringSpec = crate::anim::SpringSpec {
|
||||
response: 0.22,
|
||||
damping: 1.0,
|
||||
};
|
||||
/// The push/pop choreography, in design units. Named rather than inlined at the paint
|
||||
/// sites because `console-vectors.json` claims to pin them for all three clients, and a
|
||||
/// literal buried in a paint recipe is a literal no test can reach.
|
||||
const NAV_SLIDE_DP: f64 = 36.0;
|
||||
/// The incoming screen's starting scale on a push…
|
||||
const NAV_ENTER_SCALE: f64 = 0.985;
|
||||
/// …and the outgoing/revealed one's, which is the deeper of the two because the screen
|
||||
/// being left behind should read as further away than the one arriving.
|
||||
const NAV_EXIT_SCALE: f64 = 0.96;
|
||||
/// How visible the revealed screen is at the START of a pop — not 0, because it was
|
||||
/// already there behind the screen coming off.
|
||||
const NAV_REVEAL_ALPHA: f64 = 0.4;
|
||||
/// How far a transition must have travelled before it accepts anything other than Back.
|
||||
/// Not a time: with a sprung transition "how far along" and "how long ago" are different
|
||||
/// questions, and the one that matters for input is whether the screen under the cursor is
|
||||
/// the one the user is aiming at.
|
||||
const NAV_INPUT_OPENS: f64 = 0.85;
|
||||
/// Chrome bands (design units): the pinned title above, hints below.
|
||||
const TOP_BAND: f64 = 64.0;
|
||||
const BOTTOM_BAND: f64 = 86.0;
|
||||
|
||||
/// Which way a transition is choreographed. The paint recipes differ (a push slides the
|
||||
/// incoming screen up out of a fade; a pop grows the revealed one back while the leaving
|
||||
/// one drops away), so the kind outlives the direction the spring happens to be heading.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum NavKind {
|
||||
Push,
|
||||
Pop,
|
||||
}
|
||||
|
||||
/// One transition, as a single sprung scalar rather than a timer.
|
||||
///
|
||||
/// The scalar is what makes it INTERRUPTIBLE. A Back pressed mid-push retargets this very
|
||||
/// spring from 1.0 to 0.0 — same recipe, same velocity, so the screen visibly turns around
|
||||
/// and goes back where it came from instead of finishing its arrival and then playing a
|
||||
/// separate dismissal. A tween could not do that: its progress is a function of elapsed
|
||||
/// time, and reversing it means either a snap or a second animation.
|
||||
enum Motion {
|
||||
None,
|
||||
Push(Progress),
|
||||
Pop { leaving: Box<Screen>, t: Progress },
|
||||
Nav {
|
||||
spring: Spring,
|
||||
/// Where the spring is heading: 1.0 = the transition completing, 0.0 = it being
|
||||
/// undone. Only a push is ever retargeted to 0.0 (see [`Shell::nav_back`]).
|
||||
target: f64,
|
||||
kind: NavKind,
|
||||
/// The screen being dismissed. `Some` only for a pop — a push leaves its parent on
|
||||
/// the stack, so there is nothing to carry.
|
||||
leaving: Option<Box<Screen>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a toast is REPORTING, which is the thing the old single style couldn't say: a
|
||||
/// pairing that worked and a connect that failed were the same grey pill, so the only way
|
||||
/// to tell them apart was to read. Each kind carries a mark and a hairline colour.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ToastKind {
|
||||
/// Something happened (a session ended, a scan started). The default.
|
||||
Info,
|
||||
/// Something the user asked for succeeded.
|
||||
Success,
|
||||
/// Something failed. The one kind that does NOT take its colour from the palette — a
|
||||
/// pale field's accent can be a cheerful mint, and a failure must not read as one.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// The shape drawn ahead of a toast's text. Three marks, deliberately geometric: the
|
||||
/// crate's glyph art is hand-built Skia paths, and a mark that has to survive from 0.75×
|
||||
/// to 3× `k` on a TV across the room can't rely on fine detail.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum ToastMark {
|
||||
Dot,
|
||||
Check,
|
||||
Bang,
|
||||
}
|
||||
|
||||
impl ToastKind {
|
||||
/// The tint and mark this kind draws with.
|
||||
pub(crate) fn look(self) -> (Color4f, ToastMark) {
|
||||
match self {
|
||||
ToastKind::Info => (crate::theme::fg(0.55), ToastMark::Dot),
|
||||
ToastKind::Success => (crate::theme::accent(1.0), ToastMark::Check),
|
||||
// Fixed, not palette-derived, and that is the point: `moss`'s accent is a green
|
||||
// and `ember`'s is an orange — either would report a failure in the colour the
|
||||
// UI uses for "this is fine".
|
||||
ToastKind::Error => (Color4f::new(0.93, 0.31, 0.28, 1.0), ToastMark::Bang),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Toast {
|
||||
text: String,
|
||||
at: f64,
|
||||
kind: ToastKind,
|
||||
/// Slide-in seat, 0 → 1 on the tray spring. The same gesture as the keyboard tray
|
||||
/// (something arriving from off-screen and settling), so it takes the same spec.
|
||||
seat: Spring,
|
||||
}
|
||||
|
||||
struct Connecting {
|
||||
@@ -197,7 +287,7 @@ impl Shell {
|
||||
pub(crate) fn session_failed(&mut self, msg: &str) {
|
||||
self.connecting = None;
|
||||
self.in_stream = false;
|
||||
self.show_toast(format!("Couldn't connect — {msg}"));
|
||||
self.show_toast_kind(format!("Couldn't connect — {msg}"), ToastKind::Error);
|
||||
}
|
||||
|
||||
pub(crate) fn session_streaming(&mut self) {
|
||||
@@ -246,7 +336,16 @@ impl Shell {
|
||||
}
|
||||
|
||||
fn show_toast(&mut self, text: String) {
|
||||
self.toast = Some(Toast { text, at: self.t() });
|
||||
self.show_toast_kind(text, ToastKind::Info);
|
||||
}
|
||||
|
||||
fn show_toast_kind(&mut self, text: String, kind: ToastKind) {
|
||||
self.toast = Some(Toast {
|
||||
text,
|
||||
at: self.t(),
|
||||
kind,
|
||||
seat: Spring::rest(0.0),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
|
||||
@@ -292,7 +391,7 @@ impl Shell {
|
||||
.iter()
|
||||
.find(|h| &h.key == key)
|
||||
.map_or_else(|| "the host".to_string(), |h| h.name.clone());
|
||||
self.show_toast(format!("Paired with {name}"));
|
||||
self.show_toast_kind(format!("Paired with {name}"), ToastKind::Success);
|
||||
self.console.set_pair(PairPhase::Idle);
|
||||
if matches!(self.stack.last(), Some(Screen::Pair(_))) {
|
||||
self.apply_nav(Nav::Pop);
|
||||
@@ -401,10 +500,26 @@ impl Shell {
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
// Mid-transition input is dropped — 0.26 s, and it keeps a double-tapped A
|
||||
// from pushing two screens.
|
||||
// A transition no longer walls input off, it only filters it.
|
||||
//
|
||||
// Back is always heard, and is answered by the TRANSITION rather than the screens
|
||||
// (see `nav_back`): mid-push it reverses the push, mid-pop it starts the next one.
|
||||
// That is the whole point of the work — holding B to back out of a deep stack used
|
||||
// to stutter at every level, because each press landed inside the previous
|
||||
// transition and was thrown away.
|
||||
//
|
||||
// Everything else is still dropped until the incoming screen is nearly seated,
|
||||
// which is what keeps a double-tapped A from pushing two screens. The threshold is
|
||||
// on the spring's POSITION, not on elapsed time, so it means "far enough along to
|
||||
// be the thing you are aiming at" whatever the transition's velocity.
|
||||
if !matches!(self.motion, Motion::None) {
|
||||
return None;
|
||||
if ev == MenuEvent::Back {
|
||||
if self.nav_back() {
|
||||
return Some(MenuPulse::Confirm);
|
||||
}
|
||||
} else if self.nav_pos() < NAV_INPUT_OPENS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
@@ -582,27 +697,49 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
/// The spring the next push/pop runs on. Read at NAV time rather than per frame so a
|
||||
/// transition can't change feel under itself if the setting is stepped mid-flight.
|
||||
///
|
||||
/// Reduced motion keeps a spring rather than switching integrators — one code path
|
||||
/// stays one code path — and simply picks a critically damped, quicker one. Combined
|
||||
/// with the flattened geometry in `render.rs` (no slide, no scale) that reads as the
|
||||
/// plain crossfade the setting promises.
|
||||
fn nav_spec(&self) -> crate::anim::SpringSpec {
|
||||
if self.settings.reduce_motion {
|
||||
REDUCED_NAV
|
||||
} else {
|
||||
springs::NAV
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_nav(&mut self, kind: NavKind, leaving: Option<Box<Screen>>) {
|
||||
self.motion = Motion::Nav {
|
||||
spring: Spring::rest(0.0),
|
||||
target: 1.0,
|
||||
kind,
|
||||
leaving,
|
||||
};
|
||||
}
|
||||
|
||||
fn apply_nav(&mut self, nav: Nav) {
|
||||
match nav {
|
||||
Nav::Push(screen) => {
|
||||
self.stack.push(*screen);
|
||||
self.motion = Motion::Push(Progress::new(TRANSITION_S));
|
||||
self.begin_nav(NavKind::Push, None);
|
||||
}
|
||||
Nav::Replace(screen) => {
|
||||
// Swap under the SAME push choreography: the outgoing screen is dropped
|
||||
// rather than parked, so Back from the incoming one lands where the
|
||||
// replaced screen was reached from.
|
||||
// replaced screen was reached from — and so does a Back that REVERSES this
|
||||
// push, which pops to the same parent.
|
||||
self.stack.pop();
|
||||
self.stack.push(*screen);
|
||||
self.motion = Motion::Push(Progress::new(TRANSITION_S));
|
||||
self.begin_nav(NavKind::Push, None);
|
||||
}
|
||||
Nav::Pop => {
|
||||
if self.stack.len() > 1 {
|
||||
let leaving = self.stack.pop().expect("len > 1");
|
||||
self.motion = Motion::Pop {
|
||||
leaving: Box::new(leaving),
|
||||
t: Progress::new(TRANSITION_S),
|
||||
};
|
||||
self.begin_nav(NavKind::Pop, Some(Box::new(leaving)));
|
||||
} else {
|
||||
// Popping the root quits the console (B at home).
|
||||
self.actions.push_back(OverlayAction::Quit);
|
||||
@@ -611,10 +748,119 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the transition in flight has travelled, 0 when there is none.
|
||||
fn nav_pos(&self) -> f64 {
|
||||
match &self.motion {
|
||||
Motion::None => 1.0,
|
||||
Motion::Nav { spring, .. } => spring.pos,
|
||||
}
|
||||
}
|
||||
|
||||
/// Back, pressed while a transition is still in flight. Returns `true` when the
|
||||
/// transition itself answered it, so the event never reaches the screens.
|
||||
///
|
||||
/// This is the method the whole work package exists for. Before it, every mid-flight
|
||||
/// press was swallowed, so holding B to back out of a deep stack stuttered at each
|
||||
/// level: press, wait 0.26 s, press again.
|
||||
fn nav_back(&mut self) -> bool {
|
||||
let Motion::Nav {
|
||||
spring,
|
||||
target,
|
||||
kind,
|
||||
..
|
||||
} = &mut self.motion
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match *kind {
|
||||
// Reverse the push ON THE SAME SPRING. Velocity carries, so the entering screen
|
||||
// decelerates, turns, and goes back down — one continuous motion rather than an
|
||||
// arrival followed by a dismissal. `finish_nav` takes it off the stack when the
|
||||
// spring lands on 0.
|
||||
//
|
||||
// Refused at the root, where there is no parent to fall back to and B means
|
||||
// quit: that press belongs to the normal path.
|
||||
NavKind::Push if *target == 1.0 && self.stack.len() > 1 => {
|
||||
*target = 0.0;
|
||||
true
|
||||
}
|
||||
// Already reversing — nothing further to say, and letting this through would
|
||||
// pop the parent out from under a screen that is still on its way off.
|
||||
NavKind::Push if *target == 0.0 => true,
|
||||
NavKind::Push => false,
|
||||
// A pop already going the right way: honour the press as "and the next one
|
||||
// too". The current pop's bookkeeping is finished on the spot (the stack is
|
||||
// already correct; only `leaving` is still being carried) and a fresh pop
|
||||
// starts, which is exactly what makes a held B walk out of a stack smoothly.
|
||||
NavKind::Pop => {
|
||||
let _ = spring;
|
||||
self.motion = Motion::None;
|
||||
self.apply_nav(Nav::Pop);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the transition by `dt`. Returns how far along it is, or `None` once it has
|
||||
/// settled — by which point [`Self::finish_nav`] has already run.
|
||||
///
|
||||
/// Separate from `render` so it can be driven at a chosen `dt`: the render path takes
|
||||
/// its `dt` from the wall clock, and a test that called it in a tight loop would
|
||||
/// advance the spring by microseconds per frame and never see it move.
|
||||
fn advance_nav(&mut self, dt: f64) -> Option<f64> {
|
||||
let spec = self.nav_spec();
|
||||
let p = match &mut self.motion {
|
||||
Motion::None => None,
|
||||
Motion::Nav { spring, target, .. } => {
|
||||
spring.step_spec(*target, spec, dt);
|
||||
spring.settle(*target, 0.001, 0.01);
|
||||
// `settle` snaps both to rest, so this is an exact test rather than an
|
||||
// epsilon one — and it is the only way out of the state.
|
||||
(spring.pos != *target || spring.vel != 0.0).then_some(spring.pos)
|
||||
}
|
||||
};
|
||||
if p.is_none() {
|
||||
self.finish_nav();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A settled transition's bookkeeping. Called once the spring has landed on its target.
|
||||
fn finish_nav(&mut self) {
|
||||
if let Motion::Nav { kind, target, .. } = &self.motion {
|
||||
// A push that was reversed mid-flight never happened: take its screen back off.
|
||||
// For a `Replace` this lands on the same parent a settled Back would have, so
|
||||
// the two agree. Guarded on length because the root must never be popped here —
|
||||
// `nav_back` refuses to reverse there, and this is the belt to that's braces.
|
||||
if *kind == NavKind::Push && *target == 0.0 && self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
// A completed pop drops the screen it was carrying, exactly as before.
|
||||
self.motion = Motion::None;
|
||||
}
|
||||
|
||||
/// The living backdrop. `calm` 0 = the launcher's aurora, 1 = the quiet field the form
|
||||
/// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the
|
||||
/// former aurora-over-static-form crossfade is now a single uniform.
|
||||
/// The clock the backdrop shader runs on. Reduced motion freezes the field at a fixed
|
||||
/// phase rather than removing it: the colour IS the palette the user picked, and a
|
||||
/// still gradient is also the OLED-friendly thing to leave on a screen for an hour.
|
||||
///
|
||||
/// The CALM mix is deliberately not frozen with it — that tracks which screen is up,
|
||||
/// a state change rather than decoration.
|
||||
fn field_clock(&self, t: f64) -> f64 {
|
||||
if self.settings.reduce_motion {
|
||||
0.0
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) {
|
||||
// Gated at the one place the shader's clock is read, so the takeover's own
|
||||
// `draw_aurora` call inherits it and a third caller can't forget.
|
||||
let t = self.field_clock(t);
|
||||
// Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4),
|
||||
// u_scrim (float4).
|
||||
let uniforms: [f32; 12] = [
|
||||
|
||||
@@ -1,11 +1,54 @@
|
||||
//! The console shell's modal overlays (connecting / waking / toast / full-screen takeover).
|
||||
|
||||
use crate::anim::{approach, ease_out_cubic};
|
||||
use crate::anim::{approach, springs};
|
||||
use crate::glyphs::{hint_bar, Hint, HintKey};
|
||||
use crate::theme::{fg, Fonts, PanelStroke, W};
|
||||
use skia_safe::{gradient, Canvas, Paint, Point, Rect, TileMode};
|
||||
use skia_safe::{gradient, Canvas, Color4f, Paint, PathBuilder, Point, Rect, TileMode};
|
||||
|
||||
use super::{Shell, BOTTOM_BAND};
|
||||
use super::{Shell, ToastMark, BOTTOM_BAND};
|
||||
|
||||
/// The toast's leading mark, centred on `(cx, cy)` in a ~13 dp box.
|
||||
fn draw_toast_mark(canvas: &Canvas, mark: ToastMark, cx: f64, cy: f64, k: f64, ink: Color4f) {
|
||||
let mut p = Paint::new(ink, None);
|
||||
p.set_anti_alias(true);
|
||||
match mark {
|
||||
ToastMark::Dot => {
|
||||
canvas.draw_circle((cx as f32, cy as f32), (3.4 * k) as f32, &p);
|
||||
}
|
||||
ToastMark::Check => {
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.9 * k) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_stroke_join(skia_safe::PaintJoin::Round);
|
||||
let r = 5.0 * k;
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((cx - r) as f32, cy as f32));
|
||||
path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32));
|
||||
path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32));
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
}
|
||||
ToastMark::Bang => {
|
||||
// A stem and a dot rather than a glyph: at 0.75× k an "!" from the text font
|
||||
// would be two pixels of stem and disappear.
|
||||
let r = 5.4 * k;
|
||||
let w = 2.0 * k;
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(
|
||||
Rect::from_xywh(
|
||||
(cx - w / 2.0) as f32,
|
||||
(cy - r) as f32,
|
||||
w as f32,
|
||||
(r * 1.35) as f32,
|
||||
),
|
||||
(w / 2.0) as f32,
|
||||
(w / 2.0) as f32,
|
||||
),
|
||||
&p,
|
||||
);
|
||||
canvas.draw_circle((cx as f32, (cy + r * 0.72) as f32), (w * 0.62) as f32, &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -94,51 +137,81 @@ impl Shell {
|
||||
);
|
||||
}
|
||||
|
||||
// The toast: a transient pill above the hint bar; slides in, fades out.
|
||||
// The toast: a transient pill above the hint bar; springs in, fades out.
|
||||
if self.toast.as_ref().is_some_and(|toast| t - toast.at > 4.0) {
|
||||
self.toast = None;
|
||||
}
|
||||
if let Some(toast) = &self.toast {
|
||||
if let Some(toast) = &mut self.toast {
|
||||
let age = t - toast.at;
|
||||
{
|
||||
let slide = ease_out_cubic((age / 0.25).min(1.0));
|
||||
let fade = if age > 3.4 {
|
||||
(1.0 - (age - 3.4) / 0.6).max(0.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let alpha = (slide * fade) as f32;
|
||||
let size = 13.0 * k;
|
||||
let tw = f64::from(fonts.measure(&toast.text, W::Medium, size));
|
||||
let (pad_x, bh) = (16.0 * k, 34.0 * k);
|
||||
let bw = tw + 2.0 * pad_x;
|
||||
let bx = (w - bw) / 2.0;
|
||||
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
||||
canvas.save_layer_alpha_f(None, alpha);
|
||||
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
|
||||
&Paint::new(crate::theme::shade(0.6), None),
|
||||
);
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
rect,
|
||||
(bh / 2.0 / k) as f32,
|
||||
None,
|
||||
PanelStroke::Plain(0.14),
|
||||
k as f32,
|
||||
);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&toast.text,
|
||||
bx + pad_x,
|
||||
by + bh / 2.0 + size * 0.36,
|
||||
W::Medium,
|
||||
size,
|
||||
fg(0.92),
|
||||
);
|
||||
canvas.restore();
|
||||
if crate::theme::reduce_motion() {
|
||||
toast.seat = crate::anim::Spring::rest(1.0);
|
||||
} else {
|
||||
toast.seat.step_spec(1.0, springs::INDICATOR, dt);
|
||||
toast.seat.settle(1.0, 0.001, 0.01);
|
||||
}
|
||||
// The seat drives the SLIDE; the fade is deliberately still linear-in-time,
|
||||
// because a dismissal is a deadline (4 s) and not a gesture.
|
||||
let slide = toast.seat.pos.clamp(0.0, 1.0);
|
||||
let fade = if age > 3.4 {
|
||||
(1.0 - (age - 3.4) / 0.6).max(0.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let alpha = (slide * fade) as f32;
|
||||
let (tint, mark) = toast.kind.look();
|
||||
let size = 13.0 * k;
|
||||
let tw = f64::from(fonts.measure(&toast.text, W::Medium, size));
|
||||
let (pad_x, bh) = (16.0 * k, 34.0 * k);
|
||||
// Leading run: hairline, air, mark, air — then the text.
|
||||
let (hair_w, mark_w, gap) = (3.0 * k, 13.0 * k, 9.0 * k);
|
||||
let lead = hair_w + gap + mark_w + gap;
|
||||
let bw = lead + tw + 2.0 * pad_x;
|
||||
let bx = (w - bw) / 2.0;
|
||||
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
||||
canvas.save_layer_alpha_f(None, alpha);
|
||||
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
|
||||
&Paint::new(crate::theme::shade(0.6), None),
|
||||
);
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
rect,
|
||||
(bh / 2.0 / k) as f32,
|
||||
None,
|
||||
PanelStroke::Plain(0.14),
|
||||
k as f32,
|
||||
);
|
||||
let cy = by + bh / 2.0;
|
||||
// The hairline: the kind, readable from a couch without reading the words.
|
||||
let hair = Rect::from_xywh(
|
||||
(bx + pad_x) as f32,
|
||||
(cy - 8.0 * k) as f32,
|
||||
hair_w as f32,
|
||||
(16.0 * k) as f32,
|
||||
);
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(hair, (hair_w / 2.0) as f32, (hair_w / 2.0) as f32),
|
||||
&Paint::new(tint, None),
|
||||
);
|
||||
draw_toast_mark(
|
||||
canvas,
|
||||
mark,
|
||||
bx + pad_x + hair_w + gap + mark_w / 2.0,
|
||||
cy,
|
||||
k,
|
||||
tint,
|
||||
);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&toast.text,
|
||||
bx + pad_x + lead,
|
||||
cy + size * 0.36,
|
||||
W::Medium,
|
||||
size,
|
||||
fg(0.92),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
/// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The console shell's per-frame screen compose/transition render path.
|
||||
|
||||
use crate::anim::{approach, ease_out_cubic};
|
||||
use crate::anim::approach;
|
||||
use crate::glyphs::{hint_bar, GlyphStyle};
|
||||
use crate::library::LibraryShared;
|
||||
use crate::model::HostRow;
|
||||
@@ -11,7 +11,10 @@ use pf_client_core::trust;
|
||||
use skia_safe::{Canvas, Rect};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND};
|
||||
use super::{
|
||||
Motion, NavKind, Shell, BOTTOM_BAND, NAV_ENTER_SCALE, NAV_EXIT_SCALE, NAV_REVEAL_ALPHA,
|
||||
NAV_SLIDE_DP, TOP_BAND,
|
||||
};
|
||||
|
||||
impl Shell {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -35,6 +38,12 @@ impl Shell {
|
||||
// the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint
|
||||
// the previous palette's text over the new palette's field.
|
||||
crate::theme::set_ink(self.ink);
|
||||
// Same contract as the ink: published once, before anything draws, so every widget
|
||||
// that has a choice to make about travel this frame reads one answer. Also kept as
|
||||
// a local — `LayerEnv` borrows `self.settings` mutably below, so the transition
|
||||
// arms can no longer reach the field itself.
|
||||
let reduce = self.settings.reduce_motion;
|
||||
crate::theme::set_reduce_motion(reduce);
|
||||
self.pads = pads.to_vec();
|
||||
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
||||
self.chip = Some(pad.map_or_else(
|
||||
@@ -46,30 +55,11 @@ impl Shell {
|
||||
let k = (h / 800.0).clamp(0.75, 3.0);
|
||||
let t = self.t();
|
||||
|
||||
// Advance the transition; a finished pop finally drops its leaving screen.
|
||||
let motion_p = match &mut self.motion {
|
||||
Motion::None => None,
|
||||
Motion::Push(p) => {
|
||||
p.advance(dt);
|
||||
let v = p.value();
|
||||
if p.done() {
|
||||
self.motion = Motion::None;
|
||||
None
|
||||
} else {
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
Motion::Pop { t, .. } => {
|
||||
t.advance(dt);
|
||||
let v = t.value();
|
||||
if t.done() {
|
||||
self.motion = Motion::None;
|
||||
None
|
||||
} else {
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
};
|
||||
// Advance the transition. `None` means "settled" — which is also what makes the
|
||||
// hint-rect invariant below still hold: with a spring, "settled" is
|
||||
// `Motion::None`, exactly as it was with a timer. A reversed push takes its screen
|
||||
// back off the stack here; a completed pop drops the one it was carrying.
|
||||
let motion_p = self.advance_nav(dt);
|
||||
|
||||
// The backdrop settles into (or out of) calm with the screen transition. It is the
|
||||
// SAME living field either way — a form screen quiets it, it doesn't replace it —
|
||||
@@ -117,32 +107,58 @@ impl Shell {
|
||||
// aren't where the pixels are — and the shell drops pointer input during a
|
||||
// transition anyway, exactly as it drops menu events.
|
||||
self.hint_rects.clear();
|
||||
// Reduced motion keeps the CROSSFADE — the stack has to stay legible, and an
|
||||
// instant swap loses the only spatial cue a console shell has — and drops the
|
||||
// travel: no slide, no scale.
|
||||
let slide = |dy: f64| if reduce { 0.0 } else { dy };
|
||||
let zoom = |s: f64| if reduce { 1.0 } else { s };
|
||||
// The geometry below is UNCHANGED from the tween: same 36 dp slide, same
|
||||
// 0.985/0.96 scales, same 0.4 reveal alpha. Only the time-course differs — `p` is
|
||||
// now the spring's position where it used to be `ease_out_cubic(elapsed)`.
|
||||
match (&mut self.motion, motion_p) {
|
||||
(Motion::Push(_), Some(raw)) => {
|
||||
let p = ease_out_cubic(raw);
|
||||
(
|
||||
Motion::Nav {
|
||||
kind: NavKind::Push,
|
||||
..
|
||||
},
|
||||
Some(p),
|
||||
) => {
|
||||
let n = self.stack.len();
|
||||
let enter_scale = zoom(NAV_ENTER_SCALE + (1.0 - NAV_ENTER_SCALE) * p);
|
||||
let enter_slide = slide(NAV_SLIDE_DP * k * (1.0 - p));
|
||||
// Outgoing recedes underneath…
|
||||
if n >= 2 {
|
||||
let (below, top) = self.stack.split_at_mut(n - 1);
|
||||
env.paint(&mut below[n - 2], 1.0 - p, 0.0, 1.0 - 0.04 * p);
|
||||
// …while the incoming slides up out of a fade.
|
||||
env.paint(&mut top[0], p, 36.0 * k * (1.0 - p), 0.985 + 0.015 * p);
|
||||
} else {
|
||||
env.paint(
|
||||
&mut self.stack[0],
|
||||
p,
|
||||
36.0 * k * (1.0 - p),
|
||||
0.985 + 0.015 * p,
|
||||
&mut below[n - 2],
|
||||
1.0 - p,
|
||||
0.0,
|
||||
zoom(1.0 - (1.0 - NAV_EXIT_SCALE) * p),
|
||||
);
|
||||
// …while the incoming slides up out of a fade.
|
||||
env.paint(&mut top[0], p, enter_slide, enter_scale);
|
||||
} else {
|
||||
env.paint(&mut self.stack[0], p, enter_slide, enter_scale);
|
||||
}
|
||||
}
|
||||
(Motion::Pop { leaving, .. }, Some(raw)) => {
|
||||
let p = ease_out_cubic(raw);
|
||||
(
|
||||
Motion::Nav {
|
||||
kind: NavKind::Pop,
|
||||
leaving: Some(leaving),
|
||||
..
|
||||
},
|
||||
Some(p),
|
||||
) => {
|
||||
// The revealed screen grows back in…
|
||||
let n = self.stack.len();
|
||||
env.paint(&mut self.stack[n - 1], 0.4 + 0.6 * p, 0.0, 0.96 + 0.04 * p);
|
||||
env.paint(
|
||||
&mut self.stack[n - 1],
|
||||
NAV_REVEAL_ALPHA + (1.0 - NAV_REVEAL_ALPHA) * p,
|
||||
0.0,
|
||||
zoom(NAV_EXIT_SCALE + (1.0 - NAV_EXIT_SCALE) * p),
|
||||
);
|
||||
// …while the leaving one slides down into a fade.
|
||||
env.paint(leaving.as_mut(), 1.0 - p, 36.0 * k * p, 1.0);
|
||||
env.paint(leaving.as_mut(), 1.0 - p, slide(NAV_SLIDE_DP * k * p), 1.0);
|
||||
}
|
||||
_ => {
|
||||
let n = self.stack.len();
|
||||
@@ -150,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,
|
||||
@@ -170,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);
|
||||
|
||||
@@ -6,42 +6,58 @@ use punktfunk_core::config::GamepadPref;
|
||||
|
||||
/// The screen-transition contract, against the shared vectors. Every client re-implements this
|
||||
/// motion in its own animation system, so the numbers exist in three places and drifted in two of
|
||||
/// them before this test.
|
||||
/// them before there was a test.
|
||||
///
|
||||
/// The EASING is sampled rather than compared as control points, and that is the point of it:
|
||||
/// this side is the analytic `1 − (1−t)³`, Android reproduces it exactly, and SwiftUI can only
|
||||
/// approximate it with a Bézier. Worse, two different Béziers are published under the name
|
||||
/// "easeOutCubic" — `(0.215, 0.61, 0.355, 1)` and `(0.33, 1, 0.68, 1)` — and neither IS this
|
||||
/// curve; they differ from it by up to ~0.08 at the midpoint, which is visible on a 260 ms
|
||||
/// transition. Samples with a tolerance are the only form all three runtimes can meet.
|
||||
/// This side reads `motion_spring` (vectors version 2). The v1 `motion` block is still in the
|
||||
/// file and still correct — the Android client's `ConsoleVectorsTest` pins it, and the Apple
|
||||
/// client's `GamepadShell` mirrors its constants — but the desktop console's transition is a
|
||||
/// damped spring now rather than a 0.26 s ease-out-cubic, so it no longer implements that block
|
||||
/// and says so here rather than quietly passing a test about a curve it does not run.
|
||||
///
|
||||
/// Springs are INTEGRATOR-dependent, which is why v2 pins parameters where v1 pinned sampled
|
||||
/// positions: two runtimes that both honour `response`/`damping` agree to the eye and disagree
|
||||
/// in the third decimal, and sampling would pin the disagreement instead of the feel.
|
||||
#[test]
|
||||
fn motion_matches_the_shared_vectors() {
|
||||
let raw = include_str!("../../../../clients/shared/console-vectors.json");
|
||||
let file: serde_json::Value =
|
||||
serde_json::from_str(raw).expect("console-vectors.json must parse");
|
||||
let motion = &file["motion"];
|
||||
let want_s = motion["transition_s"].as_f64().expect("transition_s");
|
||||
assert!(
|
||||
(TRANSITION_S - want_s).abs() < 1e-9,
|
||||
"TRANSITION_S is {TRANSITION_S}, vectors say {want_s}"
|
||||
assert_eq!(
|
||||
file["version"].as_u64(),
|
||||
Some(2),
|
||||
"the spring block arrived with version 2"
|
||||
);
|
||||
|
||||
let curve = &motion["ease_out_cubic"];
|
||||
let tol = curve["tolerance"].as_f64().expect("tolerance");
|
||||
let samples = curve["samples"].as_array().expect("samples");
|
||||
assert!(
|
||||
samples.len() >= 5,
|
||||
"the curve needs enough samples to pin it"
|
||||
);
|
||||
for s in samples {
|
||||
let t = s["t"].as_f64().expect("t");
|
||||
let want = s["p"].as_f64().expect("p");
|
||||
let got = crate::anim::ease_out_cubic(t);
|
||||
let m = &file["motion_spring"];
|
||||
let num = |key: &str| m[key].as_f64().unwrap_or_else(|| panic!("{key} missing"));
|
||||
let close = |what: &str, got: f64, want: f64| {
|
||||
assert!(
|
||||
(got - want).abs() <= tol,
|
||||
"ease_out_cubic({t}) is {got}, vectors say {want} (±{tol})"
|
||||
(got - want).abs() < 1e-9,
|
||||
"{what} is {got}, vectors say {want}"
|
||||
);
|
||||
}
|
||||
};
|
||||
close(
|
||||
"response",
|
||||
crate::anim::springs::NAV.response,
|
||||
num("response"),
|
||||
);
|
||||
close("damping", crate::anim::springs::NAV.damping, num("damping"));
|
||||
close("push slide", NAV_SLIDE_DP, num("push_slide_dp"));
|
||||
close("enter scale", NAV_ENTER_SCALE, num("enter_scale"));
|
||||
close("exit scale", NAV_EXIT_SCALE, num("exit_scale"));
|
||||
close("reveal alpha", NAV_REVEAL_ALPHA, num("reveal_alpha"));
|
||||
assert_eq!(
|
||||
m["interruptible"].as_bool(),
|
||||
Some(true),
|
||||
"this client's transitions accept Back mid-flight; the block must say so"
|
||||
);
|
||||
|
||||
// The v1 block stays put until the last client migrates, and stays MARKED so nobody
|
||||
// reads it as live. Deleting it here would silently red Android's test instead.
|
||||
assert!(
|
||||
file["motion"]["$deprecated"].is_string(),
|
||||
"the v1 motion block must carry its deprecation note while other clients read it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Point the settings/known-hosts stores at a throwaway HOME — the settings screen
|
||||
@@ -179,8 +195,28 @@ fn connect_flow_raises_launch_and_cancel() {
|
||||
}
|
||||
|
||||
fn finish_motion(s: &mut Shell) {
|
||||
// Transitions block input; tests fast-forward them.
|
||||
s.motion = Motion::None;
|
||||
// Tests fast-forward transitions. Seats the spring on its target and runs the REAL
|
||||
// settle, rather than wishing the motion away — otherwise a reversed push would skip
|
||||
// the bookkeeping that takes its screen back off the stack.
|
||||
if let Motion::Nav { spring, target, .. } = &mut s.motion {
|
||||
spring.pos = *target;
|
||||
spring.vel = 0.0;
|
||||
}
|
||||
s.finish_nav();
|
||||
}
|
||||
|
||||
/// Step the transition at a fixed `dt` until it settles, collecting every position it
|
||||
/// passed through. Bounded so a spring that never settles fails the test instead of
|
||||
/// hanging it.
|
||||
fn run_motion(s: &mut Shell) -> Vec<f64> {
|
||||
let mut path = Vec::new();
|
||||
for _ in 0..600 {
|
||||
match s.advance_nav(1.0 / 120.0) {
|
||||
Some(p) => path.push(p),
|
||||
None => return path,
|
||||
}
|
||||
}
|
||||
panic!("transition never settled");
|
||||
}
|
||||
|
||||
/// A pinned host+profile card's library launches with THAT profile (design §5.2a).
|
||||
@@ -225,6 +261,7 @@ fn a_pinned_cards_library_launches_with_its_profile() {
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
platform: None,
|
||||
}]);
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
match s.take_action() {
|
||||
@@ -256,6 +293,7 @@ fn a_primary_tiles_library_leaves_the_profile_to_the_binding() {
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
platform: None,
|
||||
}]);
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
assert!(matches!(
|
||||
@@ -335,7 +373,13 @@ fn a_secondary_press_goes_back() {
|
||||
kind: crate::pointer::PointerKind::Back,
|
||||
}));
|
||||
// The pop runs through the same transition a B press does.
|
||||
assert!(matches!(s.motion, Motion::Pop { .. }));
|
||||
assert!(matches!(
|
||||
s.motion,
|
||||
Motion::Nav {
|
||||
kind: NavKind::Pop,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// Up on a saved tile opens that host's menu; a discovered-but-unsaved one has none.
|
||||
@@ -397,6 +441,377 @@ fn every_settings_tab_rasters() {
|
||||
s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads);
|
||||
}
|
||||
|
||||
/// The work package's whole reason for existing: Back pressed mid-push is HEARD, and it
|
||||
/// turns the screen around rather than queuing a second animation behind the first.
|
||||
///
|
||||
/// The continuity assertion is the important half. A naive "cancel and play a pop" reads
|
||||
/// as a snap because the two recipes disagree about where things are; retargeting the same
|
||||
/// spring cannot snap, because position is carried and only the target moved. This asserts
|
||||
/// the position never jumps by more than a frame's worth of the travel it was already
|
||||
/// doing.
|
||||
#[test]
|
||||
fn back_mid_push_turns_the_screen_around() {
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
s.handle_menu(MenuEvent::Tertiary); // X → Settings
|
||||
assert_eq!(s.stack.len(), 2);
|
||||
|
||||
// Let it get properly under way, then interrupt.
|
||||
let mut before = 0.0;
|
||||
for _ in 0..12 {
|
||||
before = s.advance_nav(1.0 / 120.0).expect("still in flight");
|
||||
}
|
||||
assert!(before > 0.05 && before < 0.95, "mid-flight, got {before}");
|
||||
assert!(s.nav_back(), "Back is answered by the transition itself");
|
||||
assert_eq!(
|
||||
s.stack.len(),
|
||||
2,
|
||||
"the screen is still on the stack while it flies back"
|
||||
);
|
||||
|
||||
let path = run_motion(&mut s);
|
||||
assert!(!path.is_empty(), "the reversal actually animated");
|
||||
// No snap: the first sample after the retarget continues from where it was.
|
||||
assert!(
|
||||
(path[0] - before).abs() < 0.05,
|
||||
"jumped from {before} to {}",
|
||||
path[0]
|
||||
);
|
||||
// And it goes DOWN — the screen is leaving, having briefly been arriving.
|
||||
assert!(*path.last().expect("non-empty") < before);
|
||||
assert_eq!(
|
||||
s.stack.len(),
|
||||
1,
|
||||
"the reversed push took its screen back off"
|
||||
);
|
||||
assert!(matches!(s.motion, Motion::None));
|
||||
}
|
||||
|
||||
/// Back at the ROOT is not a reversal — there is no parent to fall back to, and B there
|
||||
/// means quit. The transition declines it so the normal path can answer.
|
||||
#[test]
|
||||
fn back_mid_push_at_the_root_is_left_to_the_normal_path() {
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
// A Replace at the root pushes without deepening the stack.
|
||||
s.apply_nav(crate::screens::Nav::Replace(Box::new(Screen::Home(
|
||||
HomeScreen::new(),
|
||||
))));
|
||||
assert_eq!(s.stack.len(), 1);
|
||||
s.advance_nav(1.0 / 120.0);
|
||||
assert!(!s.nav_back(), "nothing to reverse into");
|
||||
finish_motion(&mut s);
|
||||
assert_eq!(s.stack.len(), 1, "and the root survived");
|
||||
}
|
||||
|
||||
/// A mid-pop A is refused: activating a half-dismissed screen is a mis-tap, not intent.
|
||||
/// A mid-pop BACK, on the other hand, is exactly what a held B is — it starts the next pop
|
||||
/// at once, which is the stutter this work package removes.
|
||||
#[test]
|
||||
fn mid_pop_refuses_confirm_but_honours_another_back() {
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
s.handle_menu(MenuEvent::Tertiary); // → Settings
|
||||
finish_motion(&mut s);
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down)); // a row that would push if activated
|
||||
s.handle_menu(MenuEvent::Back); // start the pop
|
||||
assert!(matches!(s.motion, Motion::Nav { .. }));
|
||||
let mid = s.advance_nav(1.0 / 120.0).expect("in flight");
|
||||
assert!(mid < NAV_INPUT_OPENS, "the test needs an early sample");
|
||||
|
||||
let depth = s.stack.len();
|
||||
assert!(
|
||||
s.handle_menu(MenuEvent::Confirm).is_none(),
|
||||
"A mid-pop does nothing"
|
||||
);
|
||||
assert_eq!(s.stack.len(), depth, "and pushes nothing");
|
||||
|
||||
// Back again, though, walks out another level — here that is the root, so it quits.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
finish_motion(&mut s);
|
||||
assert!(matches!(s.take_action(), Some(OverlayAction::Quit)));
|
||||
}
|
||||
|
||||
/// A completed pop frees the screen it was carrying, and hint rects are published only
|
||||
/// once the shell is settled — the invariant that predates springs and survives them,
|
||||
/// because "settled" is still exactly `Motion::None`.
|
||||
#[test]
|
||||
fn a_completed_pop_frees_its_screen_and_republishes_hints() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let pads: Vec<PadInfo> = Vec::new();
|
||||
let (w, h) = (640u32, 400u32);
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
s.handle_menu(MenuEvent::Tertiary);
|
||||
finish_motion(&mut s);
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
|
||||
// Mid-pop: a screen is being carried, and the legend is not clickable.
|
||||
assert!(
|
||||
matches!(
|
||||
&s.motion,
|
||||
Motion::Nav {
|
||||
leaving: Some(_),
|
||||
..
|
||||
}
|
||||
),
|
||||
"the popped screen is parked on the motion"
|
||||
);
|
||||
s.render(surface.canvas(), w, h, &fonts, None, None, &pads);
|
||||
assert!(
|
||||
s.hint_rects.is_empty(),
|
||||
"mid-transition the drawn rects are slid and scaled, so none are published"
|
||||
);
|
||||
|
||||
run_motion(&mut s);
|
||||
assert!(matches!(s.motion, Motion::None));
|
||||
s.render(surface.canvas(), w, h, &fonts, None, None, &pads);
|
||||
assert!(
|
||||
!s.hint_rects.is_empty(),
|
||||
"settled, the legend is clickable again"
|
||||
);
|
||||
}
|
||||
|
||||
/// Draw one frame at a small size. A freshly pushed screen has not seen the shared model
|
||||
/// yet — it adopts it on its first sync — so a test that asserts on a screen's CONTENT
|
||||
/// straight after pushing it is asking before the answer exists. The app always renders;
|
||||
/// so does this.
|
||||
fn frame(s: &mut Shell) {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let pads: Vec<PadInfo> = Vec::new();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((480, 300)).unwrap();
|
||||
s.render(surface.canvas(), 480, 300, &fonts, None, None, &pads);
|
||||
}
|
||||
|
||||
/// A library with more than one group, for the collections flow.
|
||||
fn mixed_library(library: &LibraryShared) {
|
||||
let g = |id: &str, title: &str, store: &str, platform: Option<&str>, launcher: bool| {
|
||||
crate::library::LibraryGame {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
store: store.into(),
|
||||
launcher,
|
||||
icon: String::new(),
|
||||
platform: platform.map(str::to_string),
|
||||
}
|
||||
};
|
||||
library.set_games(vec![
|
||||
g("l1", "Steam Big Picture", "steam", None, true),
|
||||
g("s1", "Dota 2", "steam", None, false),
|
||||
g("s2", "Half-Life", "steam", None, false),
|
||||
g("p1", "Demon's Souls", "custom", Some("PS3"), false),
|
||||
g("p2", "The Last of Us", "custom", Some("PS3"), false),
|
||||
g("n1", "Super Metroid", "custom", Some("SNES"), false),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The user's flow, verbatim: group by platform, walk the platforms, pick PS3, see its
|
||||
/// games — and get back out again. This is the whole point of Part C, so it is asserted
|
||||
/// end to end rather than in pieces.
|
||||
#[test]
|
||||
fn collections_drill_in_reaches_one_platform_and_backs_out() {
|
||||
let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
mixed_library(&library);
|
||||
s.handle_menu(MenuEvent::Secondary); // Y at home → this host's library
|
||||
finish_motion(&mut s);
|
||||
assert!(matches!(s.stack.last(), Some(Screen::Library(_))));
|
||||
|
||||
// Y again → Collections.
|
||||
s.handle_menu(MenuEvent::Secondary);
|
||||
finish_motion(&mut s);
|
||||
assert!(
|
||||
matches!(s.stack.last(), Some(Screen::Collections(_))),
|
||||
"Y on a multi-group library opens the collections"
|
||||
);
|
||||
|
||||
// Walk to the PS3 tile. Groups sort A–Z with launchers pinned first, so the strip
|
||||
// reads: Launchers, PS3, SNES, Steam.
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||
|
||||
// A opens that collection as a filtered shelf.
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
finish_motion(&mut s);
|
||||
frame(&mut s); // the new shelf adopts the shared model on its first sync
|
||||
let Some(Screen::Library(shelf)) = s.stack.last() else {
|
||||
panic!("A on a collection tile opens a shelf");
|
||||
};
|
||||
assert_eq!(shelf.len_for_test(), 2, "PS3 has exactly its two titles");
|
||||
assert!(
|
||||
shelf.title().ends_with("PS3"),
|
||||
"the breadcrumb names the collection: {}",
|
||||
shelf.title()
|
||||
);
|
||||
|
||||
// B B walks back out to the unfiltered shelf.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
finish_motion(&mut s);
|
||||
assert!(matches!(s.stack.last(), Some(Screen::Collections(_))));
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
finish_motion(&mut s);
|
||||
frame(&mut s);
|
||||
let Some(Screen::Library(shelf)) = s.stack.last() else {
|
||||
panic!("back to the library");
|
||||
};
|
||||
assert_eq!(shelf.len_for_test(), 6, "the whole library again");
|
||||
}
|
||||
|
||||
/// The gate: a library with nothing to collect must not offer the button, and must not
|
||||
/// answer it either — a hint and its press have to agree.
|
||||
#[test]
|
||||
fn collections_is_offered_only_when_there_is_something_to_browse() {
|
||||
let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
// One store, no platforms: a single group.
|
||||
library.set_games(vec![
|
||||
crate::library::LibraryGame {
|
||||
id: "a".into(),
|
||||
title: "Dota 2".into(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
platform: None,
|
||||
},
|
||||
crate::library::LibraryGame {
|
||||
id: "b".into(),
|
||||
title: "Half-Life".into(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
platform: None,
|
||||
},
|
||||
]);
|
||||
s.handle_menu(MenuEvent::Secondary);
|
||||
finish_motion(&mut s);
|
||||
assert!(matches!(s.stack.last(), Some(Screen::Library(_))));
|
||||
let depth = s.stack.len();
|
||||
assert!(matches!(
|
||||
s.handle_menu(MenuEvent::Secondary),
|
||||
Some(MenuPulse::Boundary)
|
||||
));
|
||||
assert_eq!(s.stack.len(), depth, "and pushed nothing");
|
||||
|
||||
// Now give it a second group and the same press works.
|
||||
mixed_library(&library);
|
||||
s.handle_menu(MenuEvent::Secondary);
|
||||
finish_motion(&mut s);
|
||||
assert!(matches!(s.stack.last(), Some(Screen::Collections(_))));
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// failure in the colour the rest of the UI uses for "this is fine" is exactly the bug.
|
||||
#[test]
|
||||
fn toast_kinds_are_visually_distinct() {
|
||||
use crate::shell::{ToastKind, ToastMark};
|
||||
let (info_c, info_m) = ToastKind::Info.look();
|
||||
let (ok_c, ok_m) = ToastKind::Success.look();
|
||||
let (err_c, err_m) = ToastKind::Error.look();
|
||||
assert_eq!(info_m, ToastMark::Dot);
|
||||
assert_eq!(ok_m, ToastMark::Check);
|
||||
assert_eq!(err_m, ToastMark::Bang);
|
||||
let rgb = |c: skia_safe::Color4f| (c.r, c.g, c.b);
|
||||
assert_ne!(rgb(info_c), rgb(ok_c));
|
||||
assert_ne!(rgb(ok_c), rgb(err_c));
|
||||
|
||||
// Swap in a green-accented palette: Success follows it, Error must not.
|
||||
crate::theme::set_ink(crate::theme::Ink::of(crate::library::palette("moss")));
|
||||
let (ok_moss, _) = ToastKind::Success.look();
|
||||
let (err_moss, _) = ToastKind::Error.look();
|
||||
assert_ne!(
|
||||
rgb(ok_moss),
|
||||
rgb(ok_c),
|
||||
"Success takes the palette's accent, so it moved"
|
||||
);
|
||||
assert_eq!(
|
||||
rgb(err_moss),
|
||||
rgb(err_c),
|
||||
"Error is fixed and must NOT follow the palette"
|
||||
);
|
||||
}
|
||||
|
||||
/// Reduced motion: the setting round-trips through the store, the backdrop shader's clock
|
||||
/// freezes, and the transition shortens. The clock is asserted through `field_clock`
|
||||
/// rather than by diffing pixels because that IS the decision — `draw_aurora` has exactly
|
||||
/// one place it reads time, and both callers (the screens and the connect takeover) go
|
||||
/// through it.
|
||||
#[test]
|
||||
fn reduce_motion_freezes_the_field_and_shortens_the_transition() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let pads: Vec<PadInfo> = Vec::new();
|
||||
let (w, h) = (1280u32, 800u32);
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
|
||||
assert!(!s.settings.reduce_motion, "off by default");
|
||||
assert_eq!(s.field_clock(12.5), 12.5);
|
||||
assert_eq!(s.nav_spec().damping, crate::anim::springs::NAV.damping);
|
||||
|
||||
s.settings.reduce_motion = true;
|
||||
assert_eq!(s.field_clock(12.5), 0.0, "the field stops drifting");
|
||||
let spec = s.nav_spec();
|
||||
assert_eq!(
|
||||
spec.damping, 1.0,
|
||||
"critically damped: it arrives, never bounces"
|
||||
);
|
||||
assert!(
|
||||
spec.response < crate::anim::springs::NAV.response,
|
||||
"and quicker"
|
||||
);
|
||||
// …and a frame still draws (the shader runs at t = 0 like any other phase).
|
||||
s.render(surface.canvas(), w, h, &fonts, None, None, &pads);
|
||||
|
||||
// Round-trip through the settings file, which is what makes it survive a restart.
|
||||
s.settings.save();
|
||||
let back = pf_client_core::trust::Settings::load();
|
||||
assert!(back.reduce_motion, "persisted");
|
||||
s.settings.reduce_motion = false;
|
||||
s.settings.save();
|
||||
assert!(
|
||||
!pf_client_core::trust::Settings::load().reduce_motion,
|
||||
"and back off again"
|
||||
);
|
||||
}
|
||||
|
||||
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
|
||||
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
|
||||
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
|
||||
@@ -519,6 +934,7 @@ fn dump_console_screens() {
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
platform: None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -83,6 +83,13 @@ thread_local! {
|
||||
/// one thread — threading an `Ink` through ~90 call sites would be all cost and no safety.
|
||||
/// [`crate::shell::Shell::render`] sets it once per frame, before anything draws.
|
||||
static INK: std::cell::Cell<Ink> = const { std::cell::Cell::new(DARK_INK) };
|
||||
|
||||
/// Whether this frame draws in reduced-motion mode (`trust::Settings::reduce_motion`).
|
||||
/// Published here for the same reason the ink is: motion is decided in ~15 places
|
||||
/// scattered across widgets, screens and the shell, and every one of them already reads
|
||||
/// a thread-local to know how to paint. Also set once per frame by
|
||||
/// [`crate::shell::Shell::render`].
|
||||
static REDUCE_MOTION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(crate) fn set_ink(ink: Ink) {
|
||||
@@ -93,6 +100,18 @@ pub(crate) fn ink() -> Ink {
|
||||
INK.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn set_reduce_motion(on: bool) {
|
||||
REDUCE_MOTION.with(|r| r.set(on));
|
||||
}
|
||||
|
||||
/// Is travel suppressed this frame? Callers keep the STATE change and drop the journey:
|
||||
/// a focused row is still focused, a stepped value still stepped — they simply arrive
|
||||
/// instead of gliding. Never used to skip a haptic; the pulse is the feedback that
|
||||
/// replaces the motion, not another thing to take away.
|
||||
pub(crate) fn reduce_motion() -> bool {
|
||||
REDUCE_MOTION.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The foreground at `alpha` — white on a dark palette, near-black on a pale one.
|
||||
pub(crate) fn fg(alpha: f32) -> Color4f {
|
||||
let c = ink().fg;
|
||||
@@ -187,6 +206,111 @@ pub(crate) fn panel(
|
||||
}
|
||||
|
||||
/// The soft drop shadow under a focused tile — a blurred black round-rect behind it.
|
||||
/// The colour half of the focus recede: neighbours lose SATURATION and BRIGHTNESS with
|
||||
/// distance `d` (0 = focused, 1 = fully receded), as one 4×5 row-major matrix.
|
||||
///
|
||||
/// This is what the flat black veil could never do. A veil only darkens, so a receded card
|
||||
/// stays as colourful as the focused one and the eye keeps reading it as a competing
|
||||
/// subject; draining the colour is what makes it read as DEPTH. The veil survives at half
|
||||
/// its old strength, doing the job it is actually good at — separating overlapping cards.
|
||||
///
|
||||
/// Row-major `[r…, g…, b…, a…]`, each row `[R G B A offset]`. The RGB rows are a standard
|
||||
/// luminance-weighted saturation matrix (Rec. 709 weights) scaled by `sat`, with the
|
||||
/// brightness shift in the offset column — SwiftUI's `.brightness()` is additive, and
|
||||
/// matching it keeps the two codebases' recede comparable by eye.
|
||||
pub(crate) fn recede_matrix(d: f64) -> [f32; 20] {
|
||||
let d = d.clamp(0.0, 1.0);
|
||||
let sat = (1.0 - RECEDE_SATURATION * d) as f32;
|
||||
// Toward the GROUND, not simply darker. Apple's `.brightness(-0.24·d)` is dark-mode
|
||||
// arithmetic: on a dark field, down is away. On one of this crate's six PALE palettes
|
||||
// it is exactly backwards — a darkened tile gains contrast against a light ground and
|
||||
// the UNFOCUSED card becomes the heaviest thing on screen. The scrim already knows
|
||||
// which way the field leans (it tends to black on a dark palette, white on a pale
|
||||
// one), so the recede borrows its direction.
|
||||
let toward_light = ink().scrim.r > 0.5;
|
||||
let bright = (RECEDE_BRIGHTNESS * d) as f32 * if toward_light { 1.0 } else { -1.0 };
|
||||
const LR: f32 = 0.2126;
|
||||
const LG: f32 = 0.7152;
|
||||
const LB: f32 = 0.0722;
|
||||
let (ir, ig, ib) = (LR * (1.0 - sat), LG * (1.0 - sat), LB * (1.0 - sat));
|
||||
[
|
||||
ir + sat,
|
||||
ig,
|
||||
ib,
|
||||
0.0,
|
||||
bright,
|
||||
ir,
|
||||
ig + sat,
|
||||
ib,
|
||||
0.0,
|
||||
bright,
|
||||
ir,
|
||||
ig,
|
||||
ib + sat,
|
||||
0.0,
|
||||
bright,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
|
||||
/// How much colour a fully receded neighbour loses…
|
||||
const RECEDE_SATURATION: f64 = 0.42;
|
||||
/// …and how much light. Both ported from the Apple gamepad UI's focus recede.
|
||||
const RECEDE_BRIGHTNESS: f64 = 0.24;
|
||||
|
||||
/// The lit top edge that makes glass read as a material rather than as a tinted rectangle:
|
||||
/// a 1 px inner stroke fading from `fg(0.10)` to nothing over the top 40 % of the panel,
|
||||
/// so the highlight sits on the top arc and dies away down the sides.
|
||||
///
|
||||
/// Deliberately a separate call rather than a flag on [`panel`]: it is worth drawing on
|
||||
/// tiles and on the ONE focused row, and not worth it on the dozens of resting rows a
|
||||
/// settings screen paints every frame. Making the caller ask keeps that discipline visible
|
||||
/// instead of hiding it behind a default.
|
||||
pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32) {
|
||||
let inset = rect.with_inset((0.5 * k, 0.5 * k));
|
||||
let mut p = Paint::default();
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width(k.max(1.0));
|
||||
p.set_anti_alias(true);
|
||||
let colors = [fg(0.10), fg(0.0)];
|
||||
p.set_shader(gradient::shaders::linear_gradient(
|
||||
(
|
||||
Point::new(rect.left, rect.top),
|
||||
Point::new(rect.left, rect.top + rect.height() * 0.4),
|
||||
),
|
||||
&gradient::Gradient::new(
|
||||
gradient::Colors::new_evenly_spaced(&colors, TileMode::Clamp, None),
|
||||
gradient::Interpolation::default(),
|
||||
),
|
||||
None,
|
||||
));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
|
||||
}
|
||||
|
||||
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
|
||||
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
|
||||
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
|
||||
/// blurred round-rect per frame.
|
||||
pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f32) {
|
||||
if f <= 0.01 {
|
||||
return;
|
||||
}
|
||||
let mut p = Paint::new(accent(0.28 * f), None);
|
||||
p.set_mask_filter(MaskFilter::blur(
|
||||
skia_safe::BlurStyle::Normal,
|
||||
18.0 * k,
|
||||
None,
|
||||
));
|
||||
// Grown slightly rather than offset: a halo is light spilling out of the card on every
|
||||
// side, where the shadow below it is the card's weight falling in one direction.
|
||||
let spread = rect.with_outset((6.0 * k, 6.0 * k));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
|
||||
}
|
||||
|
||||
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
|
||||
let mut p = Paint::new(Color4f::new(0.0, 0.0, 0.0, alpha), None);
|
||||
p.set_mask_filter(MaskFilter::blur(
|
||||
@@ -472,4 +596,81 @@ mod tests {
|
||||
> fonts.measure("Punktfunk", W::Regular, 16.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Apply the 4×5 row-major matrix to one unpremultiplied RGBA colour, the way Skia
|
||||
/// does — without the clamp, so the maths is testable at the edges.
|
||||
fn apply(m: &[f32; 20], c: [f32; 4]) -> [f32; 4] {
|
||||
core::array::from_fn(|row| {
|
||||
let o = row * 5;
|
||||
m[o] * c[0] + m[o + 1] * c[1] + m[o + 2] * c[2] + m[o + 3] * c[3] + m[o + 4]
|
||||
})
|
||||
}
|
||||
|
||||
/// The focused card must come out EXACTLY as it went in. This is the assertion that
|
||||
/// makes it safe to hand every card the same code path — a matrix that tinted the focus
|
||||
/// by half a percent would be invisible in review and wrong in every screenshot.
|
||||
#[test]
|
||||
fn recede_matrix_is_identity_at_the_focus() {
|
||||
let m = recede_matrix(0.0);
|
||||
for c in [
|
||||
[1.0, 0.2, 0.4, 1.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
[0.3, 0.9, 0.1, 0.5],
|
||||
] {
|
||||
let out = apply(&m, c);
|
||||
for i in 0..4 {
|
||||
assert!((out[i] - c[i]).abs() < 1e-5, "{c:?} became {out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recede_matrix_drains_colour_and_light_but_never_alpha() {
|
||||
let m = recede_matrix(1.0);
|
||||
let c = [0.9f32, 0.2, 0.15, 1.0]; // a saturated red poster
|
||||
let out = apply(&m, c);
|
||||
let spread = |v: [f32; 4]| v[0].max(v[1]).max(v[2]) - v[0].min(v[1]).min(v[2]);
|
||||
assert!(
|
||||
spread(out) < spread(c) * 0.7,
|
||||
"colour did not drain: {out:?}"
|
||||
);
|
||||
let lum = |v: [f32; 4]| 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
|
||||
assert!(lum(out) < lum(c), "it did not darken: {out:?}");
|
||||
// ALPHA IS UNTOUCHED, and that is load-bearing: coverflow side cards overlap, so a
|
||||
// recede that reached alpha would let them show through each other.
|
||||
assert_eq!(out[3], 1.0);
|
||||
}
|
||||
|
||||
/// The recede must move a card TOWARD ITS GROUND, which is the opposite direction on a
|
||||
/// pale palette. Getting this wrong is not subtle and is not caught by any dark-palette
|
||||
/// screenshot: on `mint` or `holo` a darkened neighbour gains contrast against the
|
||||
/// light field, so the UNFOCUSED tile becomes the loudest thing on screen.
|
||||
#[test]
|
||||
fn recede_moves_toward_the_ground_on_a_pale_palette() {
|
||||
let lum = |v: [f32; 4]| 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
|
||||
let card = [0.55f32, 0.55, 0.6, 1.0];
|
||||
|
||||
set_ink(Ink::of(crate::library::palette("violet")));
|
||||
assert!(ink().scrim.r < 0.5, "violet is a dark field");
|
||||
let dark_side = apply(&recede_matrix(1.0), card);
|
||||
|
||||
set_ink(Ink::of(crate::library::palette("mint")));
|
||||
assert!(ink().scrim.r > 0.5, "mint is a pale field");
|
||||
let pale_side = apply(&recede_matrix(1.0), card);
|
||||
|
||||
assert!(
|
||||
lum(dark_side) < lum(card),
|
||||
"on a dark field a receded card sinks"
|
||||
);
|
||||
assert!(
|
||||
lum(pale_side) > lum(card),
|
||||
"on a pale field it must LIFT, not sink: {pale_side:?}"
|
||||
);
|
||||
// Both drain colour, whichever way the light goes — saturation has no handedness.
|
||||
let spread = |v: [f32; 4]| v[0].max(v[1]).max(v[2]) - v[0].min(v[1]).min(v[2]);
|
||||
assert!(spread(dark_side) < spread(card));
|
||||
assert!(spread(pale_side) < spread(card));
|
||||
|
||||
set_ink(DARK_INK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! springs and scroll, the SCREEN owns the domain (row content and what an activation
|
||||
//! means), and every frame the screen hands the widget fresh row specs.
|
||||
|
||||
use crate::anim::{approach, Spring, TRAY_C, TRAY_K};
|
||||
use crate::anim::{approach, entrances, springs, Entrance, EntranceAt, Spring, TRAY_C, TRAY_K};
|
||||
use crate::library::{BUMP_C, BUMP_K};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
|
||||
@@ -78,13 +78,60 @@ const ROW_GAP: f64 = 6.0;
|
||||
const HEADER_H: f64 = 34.0;
|
||||
pub(crate) const ROW_MAX_W: f64 = 620.0;
|
||||
|
||||
/// How far a stepped value slips before springing back, design units. The affordable 80 %
|
||||
/// of Apple's option-band drum: one sprung position plus a crossfade, rather than
|
||||
/// neighbours rendered in 3-D — our rows draw their value as a single text run, so a real
|
||||
/// drum would mean laying out values we never otherwise measure.
|
||||
const SLIP_DP: f64 = 14.0;
|
||||
/// Accumulated slip is capped here so a held repeat reads as one accelerating travel
|
||||
/// rather than throwing the value off the row.
|
||||
const SLIP_MAX: f64 = 22.0;
|
||||
/// The confirm dip's floor — the visual sibling of the haptic that already fires.
|
||||
const PRESS_DIP: f64 = 0.97;
|
||||
/// How far a row rises into place on mount. A twelfth of the carousel's travel: same
|
||||
/// language, but a settings list that fans open like a shelf of box art is a settings list
|
||||
/// showing off.
|
||||
const ROW_RISE: f64 = 12.0;
|
||||
|
||||
/// The focus list: authoritative cursor, spring recoil at the ends, a scroll offset
|
||||
/// that chases the focused row, and a per-row focus amount for the scale/tint ease.
|
||||
pub(crate) struct MenuList {
|
||||
pub cursor: usize,
|
||||
bump: Spring,
|
||||
scroll: f64,
|
||||
/// The COLOUR channel of focus (tint, alpha, chevrons), eased with `approach`.
|
||||
/// Deliberately not sprung: an overshooting colour would overshoot past the palette's
|
||||
/// accent into a tint that isn't in the palette at all.
|
||||
focus: Vec<f64>,
|
||||
/// The SCALE channel, sprung — the whisker of overshoot is the pop that makes a
|
||||
/// focused row read as picked up rather than merely tinted.
|
||||
focus_pop: Vec<Spring>,
|
||||
/// The activated row's dip, resting at 1.0. One spring, not one per row: only the
|
||||
/// focused row can be activated, so only one can ever be dipping.
|
||||
press: Spring,
|
||||
/// A stepped value's displacement, chasing 0 from ±[`SLIP_DP`]. Never reset on a new
|
||||
/// step — its velocity is what makes held repeats accumulate into one travel instead of
|
||||
/// restarting the crossfade.
|
||||
slip: Spring,
|
||||
/// The value the slip is sliding OUT: `(row, text, offset)`. The offset is where that
|
||||
/// text sits RELATIVE to the incoming one (∓[`SLIP_DP`], set from the step direction),
|
||||
/// so the pair keeps travelling the right way round even after the spring reverses
|
||||
/// through zero on an accumulated repeat.
|
||||
slip_prev: Option<(usize, String, f64)>,
|
||||
/// Direction of the step the list last emitted, consumed by the next render. The list
|
||||
/// arms the slip ITSELF by noticing the value changed, so no screen has to report it —
|
||||
/// and a refused adjust (the value didn't move) correctly produces no slip at all.
|
||||
step_dir: i32,
|
||||
/// Each row's value string as last drawn — the "before" half of the crossfade, and how
|
||||
/// the list detects that an adjust actually landed.
|
||||
shown: Vec<String>,
|
||||
/// The mount entrance and the clock it runs on. Deliberately NOT replayed on a tab
|
||||
/// switch: `jump_to` already seats everything instantly (chasing through rows that no
|
||||
/// longer exist reads as a glitch), and re-fanning the rows on every L1/R1 would turn a
|
||||
/// skim through the sections into a flicker.
|
||||
entrance: Option<Entrance>,
|
||||
entrance_armed: bool,
|
||||
age: f64,
|
||||
/// Next render, seat the scroll and the focus ease instantly instead of chasing — see
|
||||
/// [`MenuList::jump_to`].
|
||||
snap: bool,
|
||||
@@ -101,6 +148,15 @@ impl MenuList {
|
||||
bump: Spring::rest(0.0),
|
||||
scroll: 0.0,
|
||||
focus: Vec::new(),
|
||||
focus_pop: Vec::new(),
|
||||
press: Spring::rest(1.0),
|
||||
slip: Spring::rest(0.0),
|
||||
slip_prev: None,
|
||||
step_dir: 0,
|
||||
shown: Vec::new(),
|
||||
entrance: None,
|
||||
entrance_armed: false,
|
||||
age: 0.0,
|
||||
snap: true,
|
||||
geom: Vec::new(),
|
||||
}
|
||||
@@ -120,13 +176,33 @@ impl MenuList {
|
||||
match ev {
|
||||
MenuEvent::Move(MenuDir::Up) => (ListMsg::None, self.step(-1, len)),
|
||||
MenuEvent::Move(MenuDir::Down) => (ListMsg::None, self.step(1, len)),
|
||||
MenuEvent::Move(MenuDir::Left) => (ListMsg::Adjust(-1), None),
|
||||
MenuEvent::Move(MenuDir::Right) => (ListMsg::Adjust(1), None),
|
||||
MenuEvent::Confirm => (ListMsg::Activate, Some(MenuPulse::Confirm)),
|
||||
MenuEvent::Move(MenuDir::Left) => (ListMsg::Adjust(-1), self.armed(-1)),
|
||||
MenuEvent::Move(MenuDir::Right) => (ListMsg::Adjust(1), self.armed(1)),
|
||||
// A on a value row cycles it FORWARD, so the slip travels the same way a Right
|
||||
// would; on an action row nothing steps and the render simply finds no change.
|
||||
MenuEvent::Confirm => {
|
||||
self.armed(1);
|
||||
self.dip();
|
||||
(ListMsg::Activate, Some(MenuPulse::Confirm))
|
||||
}
|
||||
_ => (ListMsg::None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that a step went out in `dir`. Returns `None` so it drops into the pulse slot of
|
||||
/// the arms above without changing what they report — the SCREEN decides whether the
|
||||
/// step landed, and the next render finds out by looking at the value.
|
||||
fn armed(&mut self, dir: i32) -> Option<MenuPulse> {
|
||||
self.step_dir = dir;
|
||||
None
|
||||
}
|
||||
|
||||
/// Kick the confirm dip. Separate from [`Self::armed`] because a press is worth showing
|
||||
/// even on an action row, where no value will change.
|
||||
fn dip(&mut self) {
|
||||
self.press.pos = PRESS_DIP;
|
||||
}
|
||||
|
||||
/// A row's drawn rect, for tests that assert on what a press can actually reach.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn row_rect(&self, i: usize) -> Option<Rect> {
|
||||
@@ -146,6 +222,9 @@ impl MenuList {
|
||||
PointerKind::Press => match p.pick(&self.geom) {
|
||||
Some(i) if i < len => {
|
||||
self.cursor = i;
|
||||
// Same forward cycle A performs, so a click reads the same as a press.
|
||||
self.armed(1);
|
||||
self.dip();
|
||||
(ListMsg::Activate, Some(MenuPulse::Confirm))
|
||||
}
|
||||
_ => (ListMsg::None, None),
|
||||
@@ -181,12 +260,30 @@ impl MenuList {
|
||||
dt: f64,
|
||||
active: bool,
|
||||
) {
|
||||
let reduce = crate::theme::reduce_motion();
|
||||
// The list keeps its own clock: `render` is handed `dt` but never the shell's `t`,
|
||||
// and a per-MOUNT animation wants a per-mount clock anyway.
|
||||
self.age += dt;
|
||||
if !self.entrance_armed {
|
||||
self.entrance_armed = true;
|
||||
self.entrance = Some(Entrance::new(entrances::ROWS, self.cursor, self.age));
|
||||
}
|
||||
if self.entrance.is_some_and(|e| e.done(self.age)) {
|
||||
self.entrance = None;
|
||||
}
|
||||
if self.snap {
|
||||
// A replaced row set has no shared history with the old one — start every row's
|
||||
// focus ease from scratch so the new cursor is simply THERE.
|
||||
// focus ease from scratch so the new cursor is simply THERE. The slip goes with
|
||||
// it: a value "changing" because the whole row set was swapped is not a step.
|
||||
self.focus.clear();
|
||||
self.focus_pop.clear();
|
||||
self.shown.clear();
|
||||
self.slip = Spring::rest(0.0);
|
||||
self.slip_prev = None;
|
||||
self.step_dir = 0;
|
||||
}
|
||||
self.focus.resize(rows.len(), 0.0);
|
||||
self.focus_pop.resize(rows.len(), Spring::rest(0.0));
|
||||
for (i, f) in self.focus.iter_mut().enumerate() {
|
||||
let target = if active && i == self.cursor { 1.0 } else { 0.0 };
|
||||
*f = if self.snap {
|
||||
@@ -195,8 +292,57 @@ impl MenuList {
|
||||
approach(*f, target, dt, 0.06)
|
||||
};
|
||||
}
|
||||
for (i, s) in self.focus_pop.iter_mut().enumerate() {
|
||||
let target = if active && i == self.cursor { 1.0 } else { 0.0 };
|
||||
if self.snap || reduce {
|
||||
*s = Spring::rest(target);
|
||||
} else {
|
||||
s.step_spec(target, springs::FOCUS, dt);
|
||||
s.settle(target, 0.0005, 0.005);
|
||||
}
|
||||
}
|
||||
self.bump.step(0.0, BUMP_K, BUMP_C, dt);
|
||||
self.bump.settle(0.0, 0.3, 4.0);
|
||||
if reduce {
|
||||
// Reduced motion keeps the recoil's MEANING (the move was refused) in the
|
||||
// haptic the screen already fired, and drops the travel.
|
||||
self.bump = Spring::rest(0.0);
|
||||
self.press = Spring::rest(1.0);
|
||||
} else {
|
||||
self.press.step_spec(1.0, springs::PRESS, dt);
|
||||
self.press.settle(1.0, 0.0005, 0.005);
|
||||
}
|
||||
|
||||
// Arm the value slip. A step went out (`step_dir`) AND the value under the cursor
|
||||
// actually changed — comparing what we DREW last frame against what the screen just
|
||||
// handed us is what makes a refused adjust produce no motion at all, without any
|
||||
// screen having to report whether its edit landed.
|
||||
let dir = std::mem::take(&mut self.step_dir);
|
||||
if dir != 0 && !reduce {
|
||||
let now = rows
|
||||
.get(self.cursor)
|
||||
.and_then(|r| r.value.as_deref())
|
||||
.unwrap_or_default();
|
||||
if self.shown.get(self.cursor).is_some_and(|p| p != now) {
|
||||
let prev = self.shown[self.cursor].clone();
|
||||
// ADD rather than set, and never touch `vel`: two fast presses accumulate
|
||||
// into one accelerating travel instead of restarting the crossfade.
|
||||
self.slip.pos =
|
||||
(self.slip.pos + SLIP_DP * f64::from(dir)).clamp(-SLIP_MAX, SLIP_MAX);
|
||||
self.slip_prev = Some((self.cursor, prev, -SLIP_DP * f64::from(dir)));
|
||||
}
|
||||
}
|
||||
self.slip.step_spec(0.0, springs::FOCUS, dt);
|
||||
self.slip.settle(0.0, 0.02, 0.2);
|
||||
if self.slip.pos == 0.0 {
|
||||
self.slip_prev = None;
|
||||
}
|
||||
// This frame's values become the "before" the next step compares against. Recorded
|
||||
// for EVERY row, not just the drawn ones: a row scrolled out of view still has a
|
||||
// value, and reading a stale one back later would fake a change.
|
||||
self.shown.clear();
|
||||
self.shown
|
||||
.extend(rows.iter().map(|r| r.value.clone().unwrap_or_default()));
|
||||
|
||||
// Row tops (design units) incl. headers, so scroll math and drawing agree.
|
||||
let mut tops = Vec::with_capacity(rows.len());
|
||||
@@ -235,6 +381,12 @@ impl MenuList {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Culled first, then the entrance: an off-screen row costs nothing to not
|
||||
// animate, and the rise is far smaller than the 8 dp cull margin either way.
|
||||
let ent = self
|
||||
.entrance
|
||||
.map_or(EntranceAt::SETTLED, |e| e.at(i, self.age));
|
||||
let top = top + (1.0 - ent.travel) * ROW_RISE * k;
|
||||
if let Some(header) = row.header {
|
||||
fonts.draw_tracked(
|
||||
canvas,
|
||||
@@ -247,13 +399,30 @@ impl MenuList {
|
||||
fg(0.45),
|
||||
);
|
||||
}
|
||||
// Focus scale eases 0.98 → 1.0 about the row center.
|
||||
let scale = 0.98 + 0.02 * f;
|
||||
// Focus scale springs 0.98 → 1.0 about the row center, times the confirm dip.
|
||||
// Two channels rather than one because they answer different questions: the pop
|
||||
// says "this is the row", the dip says "and you just pressed it".
|
||||
let pop = self.focus_pop.get(i).map_or(f, |s| s.pos);
|
||||
let dip = if i == self.cursor {
|
||||
self.press.pos
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let scale = (0.98 + 0.02 * pop) * dip;
|
||||
let (cx, cy) = (x0 + row_w / 2.0, top + ROW_H * k / 2.0);
|
||||
canvas.save();
|
||||
canvas.translate((cx as f32, cy as f32));
|
||||
canvas.scale((scale as f32, scale as f32));
|
||||
canvas.translate((-cx as f32, -cy as f32));
|
||||
// One layer per row, only while it is still arriving — a row is a panel plus
|
||||
// two text runs, so a whole-row fade genuinely needs one. Bounded to the row's
|
||||
// own rect so it never becomes a full-screen pass.
|
||||
let fading = ent.fade < 1.0;
|
||||
if fading {
|
||||
let bounds =
|
||||
Rect::from_xywh(x0 as f32, top as f32, row_w as f32, (ROW_H * k) as f32);
|
||||
canvas.save_layer_alpha_f(bounds, ent.fade as f32);
|
||||
}
|
||||
let r = Rect::from_xywh(x0 as f32, top as f32, row_w as f32, (ROW_H * k) as f32);
|
||||
// The untransformed rect: the focus scale is a 2 % breath about the centre, far
|
||||
// inside the slop a finger brings, and clicking must not depend on the ease.
|
||||
@@ -271,6 +440,11 @@ impl MenuList {
|
||||
None
|
||||
};
|
||||
crate::theme::panel(canvas, r, 14.0, tint, stroke, k as f32);
|
||||
// The lit edge, for the focused row only — a settings screen paints dozens of
|
||||
// resting rows every frame and none of them need a specular highlight.
|
||||
if f > 0.5 {
|
||||
crate::theme::panel_highlight(canvas, r, 14.0, k as f32);
|
||||
}
|
||||
|
||||
let baseline = cy + 16.0 * k * 0.36;
|
||||
if row.value.is_none() {
|
||||
@@ -309,9 +483,42 @@ impl MenuList {
|
||||
let vmax = row_w * 0.55;
|
||||
let vw = (fonts.measure(value, W::Medium, 15.0 * k) as f64).min(vmax);
|
||||
let vx = x0 + row_w - 16.0 * k - chevron_w - caret_w - vw;
|
||||
// The sprung slip + crossfade. `slip_prev` names its ROW, so a value that
|
||||
// changed somewhere else (a profile row appearing, a dependent row
|
||||
// re-enabling) can't drag this one's text sideways.
|
||||
let slipping = self.slip_prev.as_ref().filter(|(row, ..)| *row == i);
|
||||
let dx = if slipping.is_some() {
|
||||
self.slip.pos * k
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let gone = (self.slip.pos.abs() / SLIP_DP).clamp(0.0, 1.0) as f32;
|
||||
let alpha =
|
||||
|c: skia_safe::Color4f, a: f32| skia_safe::Color4f::new(c.r, c.g, c.b, c.a * a);
|
||||
if let Some((_, prev, offset)) = slipping {
|
||||
// The value being left, sliding out the way the step came from.
|
||||
let prev_text = truncate_head(fonts, prev, W::Medium, 15.0 * k, vmax);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&prev_text,
|
||||
vx + dx + offset * k,
|
||||
baseline,
|
||||
W::Medium,
|
||||
15.0 * k,
|
||||
alpha(vcolor, gone),
|
||||
);
|
||||
}
|
||||
// Head-truncate: keep the END of a long address visible while typing.
|
||||
let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax);
|
||||
fonts.draw(canvas, &shown, vx, baseline, W::Medium, 15.0 * k, vcolor);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&shown,
|
||||
vx + dx,
|
||||
baseline,
|
||||
W::Medium,
|
||||
15.0 * k,
|
||||
alpha(vcolor, 1.0 - gone),
|
||||
);
|
||||
if row.caret {
|
||||
canvas.draw_rect(
|
||||
Rect::from_xywh(
|
||||
@@ -329,6 +536,9 @@ impl MenuList {
|
||||
chevron(canvas, x0 + row_w - 16.0 * k, cy, 4.0 * k, false, alpha);
|
||||
}
|
||||
}
|
||||
if fading {
|
||||
canvas.restore(); // the entrance layer
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
canvas.restore();
|
||||
@@ -344,9 +554,13 @@ pub(crate) const TAB_STRIP_H: f64 = 46.0;
|
||||
/// owns which tab is selected and what the shoulders do; this draws the pills and slides
|
||||
/// one highlight between them, so switching sections reads as travel rather than a swap.
|
||||
pub(crate) struct TabStrip {
|
||||
/// Chased highlight geometry `(x, width)` in device px. `None` until the first render,
|
||||
/// so a freshly opened screen doesn't animate its highlight in from x = 0.
|
||||
indicator: Option<(f64, f64)>,
|
||||
/// Chased highlight geometry `(x, width)` in device px, SPRUNG rather than eased so
|
||||
/// velocity carries across rapid L1/R1: a fast skim through the sections reads as one
|
||||
/// accelerating travel instead of a series of restarted eases that each begin at zero
|
||||
/// speed. (The option-band lesson from the Apple gamepad UI, applied to the one widget
|
||||
/// here that gets hammered.) `None` until the first render, so a freshly opened screen
|
||||
/// doesn't animate its highlight in from x = 0.
|
||||
indicator: Option<(Spring, Spring)>,
|
||||
/// Each pill's rect as last drawn, device px — the strip is the one part of a settings
|
||||
/// screen a pointer can reach directly, so it hit-tests against what it drew.
|
||||
pills: Vec<Rect>,
|
||||
@@ -408,14 +622,24 @@ impl TabStrip {
|
||||
x + widths[..sel].iter().sum::<f64>() + gap * sel as f64,
|
||||
widths[sel],
|
||||
);
|
||||
let (ix, iw) = match self.indicator {
|
||||
None => target,
|
||||
Some((cx, cw)) => (
|
||||
approach(cx, target.0, dt, 0.07),
|
||||
approach(cw, target.1, dt, 0.07),
|
||||
),
|
||||
if self.indicator.is_none() {
|
||||
self.indicator = Some((Spring::rest(target.0), Spring::rest(target.1)));
|
||||
}
|
||||
let (ix, iw) = {
|
||||
let (sx, sw) = self.indicator.as_mut().expect("seeded just above");
|
||||
if crate::theme::reduce_motion() {
|
||||
*sx = Spring::rest(target.0);
|
||||
*sw = Spring::rest(target.1);
|
||||
} else {
|
||||
sx.step_spec(target.0, springs::INDICATOR, dt);
|
||||
sw.step_spec(target.1, springs::INDICATOR, dt);
|
||||
// Epsilons in DEVICE PX (this geometry is already scaled by `k`), so the
|
||||
// pill stops sub-pixel-jittering rather than at some design-unit fraction.
|
||||
sx.settle(target.0, 0.05, 0.5);
|
||||
sw.settle(target.1, 0.05, 0.5);
|
||||
}
|
||||
(sx.pos, sw.pos)
|
||||
};
|
||||
self.indicator = Some((ix, iw));
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32),
|
||||
@@ -908,6 +1132,196 @@ mod tests {
|
||||
assert_eq!(l.menu(MenuEvent::Confirm, 3).0, ListMsg::Activate);
|
||||
}
|
||||
|
||||
const TABS: [&str; 7] = [
|
||||
"Stream",
|
||||
"Video",
|
||||
"Audio",
|
||||
"Controller",
|
||||
"Input",
|
||||
"Interface",
|
||||
"Profiles",
|
||||
];
|
||||
|
||||
/// The sprung tab pill must accelerate through a burst without ever leaving the strip.
|
||||
/// A spring that carries velocity CAN fly past its target, and the failure mode is a
|
||||
/// highlight that shoots off the end of the section list — this pins that it doesn't,
|
||||
/// and that it still lands exactly on the selected pill afterwards.
|
||||
#[test]
|
||||
fn tab_indicator_rides_a_burst_without_leaving_the_strip() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((900, 120)).unwrap();
|
||||
let rect = Rect::from_xywh(0.0, 0.0, 900.0, TAB_STRIP_H as f32);
|
||||
let mut strip = TabStrip::new();
|
||||
let dt = 1.0 / 60.0;
|
||||
// Seat on the first tab, then a 5-step burst at one press per frame — far faster
|
||||
// than the spring can settle, which is the whole point.
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, 1.0, dt);
|
||||
let mut worst_left = f64::MAX;
|
||||
let mut worst_right = f64::MIN;
|
||||
for sel in 1..=5 {
|
||||
strip.render(surface.canvas(), rect, &TABS, sel, &fonts, 1.0, dt);
|
||||
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
|
||||
worst_left = worst_left.min(ix);
|
||||
worst_right = worst_right.max(ix + iw);
|
||||
}
|
||||
// Then let it land.
|
||||
for _ in 0..240 {
|
||||
strip.render(surface.canvas(), rect, &TABS, 5, &fonts, 1.0, dt);
|
||||
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
|
||||
worst_left = worst_left.min(ix);
|
||||
worst_right = worst_right.max(ix + iw);
|
||||
}
|
||||
assert!(
|
||||
worst_left >= f64::from(rect.left) - 0.5,
|
||||
"pill ran off the left: {worst_left}"
|
||||
);
|
||||
assert!(
|
||||
worst_right <= f64::from(rect.right) + 0.5,
|
||||
"pill ran off the right: {worst_right}"
|
||||
);
|
||||
let pill = strip.pill(5).expect("the selected pill was drawn");
|
||||
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
|
||||
assert!(
|
||||
(ix - f64::from(pill.left)).abs() < 0.5 && (iw - f64::from(pill.width())).abs() < 0.5,
|
||||
"settled at ({ix}, {iw}), pill is at ({}, {})",
|
||||
pill.left,
|
||||
pill.width()
|
||||
);
|
||||
}
|
||||
|
||||
fn value_row(value: &str) -> Vec<RowSpec> {
|
||||
vec![RowSpec {
|
||||
header: None,
|
||||
label: "Bitrate".into(),
|
||||
value: Some(value.into()),
|
||||
value_dim: false,
|
||||
caret: false,
|
||||
adjustable: true,
|
||||
enabled: true,
|
||||
}]
|
||||
}
|
||||
|
||||
/// The value slip: armed only when a step actually CHANGED the value, and always
|
||||
/// settling back onto the row's own position. A slip that never returns to identity
|
||||
/// leaves the value permanently offset, which is the bug this shape can have.
|
||||
#[test]
|
||||
fn value_slip_arms_on_a_real_step_and_settles_to_identity() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((900, 600)).unwrap();
|
||||
let rect = Rect::from_xywh(0.0, 0.0, 900.0, 600.0);
|
||||
let dt = 1.0 / 60.0;
|
||||
let mut list = MenuList::new();
|
||||
let mut frame = |list: &mut MenuList, rows: &[RowSpec]| {
|
||||
list.render(surface.canvas(), rect, rows, &fonts, 1.0, dt, true);
|
||||
};
|
||||
|
||||
frame(&mut list, &value_row("10 Mbps"));
|
||||
assert_eq!(list.slip.pos, 0.0, "nothing has stepped yet");
|
||||
|
||||
// A step the screen HONOURED: the value it hands back next frame differs.
|
||||
assert_eq!(
|
||||
list.menu(MenuEvent::Move(MenuDir::Right), 1).0,
|
||||
ListMsg::Adjust(1)
|
||||
);
|
||||
frame(&mut list, &value_row("20 Mbps"));
|
||||
assert!(list.slip.pos.abs() > 1.0, "armed: {}", list.slip.pos);
|
||||
assert!(
|
||||
list.slip_prev.is_some(),
|
||||
"the old value is held for the fade"
|
||||
);
|
||||
|
||||
for _ in 0..240 {
|
||||
frame(&mut list, &value_row("20 Mbps"));
|
||||
}
|
||||
assert_eq!(list.slip.pos, 0.0, "settled back onto the row");
|
||||
assert!(list.slip_prev.is_none(), "and forgot the old value");
|
||||
|
||||
// A step the screen REFUSED (value unchanged) must not move anything — this is why
|
||||
// the list detects the change itself instead of trusting the event.
|
||||
list.menu(MenuEvent::Move(MenuDir::Right), 1);
|
||||
frame(&mut list, &value_row("20 Mbps"));
|
||||
assert_eq!(list.slip.pos, 0.0);
|
||||
assert!(list.slip_prev.is_none());
|
||||
}
|
||||
|
||||
/// Reduced motion keeps the STATE and drops the journey: the value is simply the new
|
||||
/// one, the focus is simply on the row, and a refused move leaves no recoil behind.
|
||||
#[test]
|
||||
fn reduce_motion_drops_travel_but_not_state() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((900, 600)).unwrap();
|
||||
let rect = Rect::from_xywh(0.0, 0.0, 900.0, 600.0);
|
||||
let dt = 1.0 / 60.0;
|
||||
crate::theme::set_reduce_motion(true);
|
||||
let mut list = MenuList::new();
|
||||
list.render(
|
||||
surface.canvas(),
|
||||
rect,
|
||||
&value_row("10 Mbps"),
|
||||
&fonts,
|
||||
1.0,
|
||||
dt,
|
||||
true,
|
||||
);
|
||||
list.menu(MenuEvent::Move(MenuDir::Right), 1);
|
||||
list.render(
|
||||
surface.canvas(),
|
||||
rect,
|
||||
&value_row("20 Mbps"),
|
||||
&fonts,
|
||||
1.0,
|
||||
dt,
|
||||
true,
|
||||
);
|
||||
assert_eq!(list.slip.pos, 0.0, "no slip under reduced motion");
|
||||
assert!(list.slip_prev.is_none());
|
||||
// A refused move still pulses (the screen's job) but leaves no recoil travel.
|
||||
assert!(matches!(
|
||||
list.menu(MenuEvent::Move(MenuDir::Up), 1).1,
|
||||
Some(MenuPulse::Boundary)
|
||||
));
|
||||
list.render(
|
||||
surface.canvas(),
|
||||
rect,
|
||||
&value_row("20 Mbps"),
|
||||
&fonts,
|
||||
1.0,
|
||||
dt,
|
||||
true,
|
||||
);
|
||||
assert_eq!(list.bump.pos, 0.0, "recoil travel suppressed");
|
||||
// The focus channel still ARRIVES — reduced motion is not "unfocused".
|
||||
assert_eq!(list.focus_pop[0].pos, 1.0);
|
||||
crate::theme::set_reduce_motion(false);
|
||||
}
|
||||
|
||||
/// The mount entrance arms once, retires when it is over (so the steady state pays
|
||||
/// nothing for it at all), and is NOT replayed by a tab switch — re-fanning the rows on
|
||||
/// every L1/R1 would turn a skim through the sections into a flicker.
|
||||
#[test]
|
||||
fn menu_list_entrance_plays_once_and_retires() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((900, 600)).unwrap();
|
||||
let rect = Rect::from_xywh(0.0, 0.0, 900.0, 600.0);
|
||||
let dt = 1.0 / 60.0;
|
||||
let rows: Vec<RowSpec> = (0..6)
|
||||
.map(|i| RowSpec::action(format!("Row {i}"), true))
|
||||
.collect();
|
||||
let mut list = MenuList::new();
|
||||
|
||||
list.render(surface.canvas(), rect, &rows, &fonts, 1.0, dt, true);
|
||||
assert!(list.entrance.is_some(), "armed on the first frame");
|
||||
|
||||
for _ in 0..90 {
|
||||
list.render(surface.canvas(), rect, &rows, &fonts, 1.0, dt, true);
|
||||
}
|
||||
assert!(list.entrance.is_none(), "retired once it played out");
|
||||
|
||||
list.jump_to(3);
|
||||
list.render(surface.canvas(), rect, &rows, &fonts, 1.0, dt, true);
|
||||
assert!(list.entrance.is_none(), "a tab switch must not replay it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_truncation_keeps_the_tail() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
|
||||
+12
-1
@@ -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.)"
|
||||
|
||||
@@ -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)")
|
||||
Reference in New Issue
Block a user