The console UI overhaul: motion, look, and a library you can actually navigate #269
Merged
enricobuehler
merged 9 commits from 2026-08-16 15:06:21 +00:00
worktree-console-ui-overhaul into main
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
451f95079e |
fix(console): the focus recede ran backwards on all six pale palettes
ci / web (pull_request) Successful in 1m11s
ci / bun-nix (pull_request) Successful in 1m30s
ci / docs-site (pull_request) Successful in 5m30s
ci / rust-arm64 (pull_request) Successful in 7m26s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m18s
android / android (pull_request) Successful in 9m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m5s
ci / rust (pull_request) Failing after 13m54s
Caught on the screenshot pass, which is the only place it could have been caught: every unit test and every dark-palette render agreed the recede was fine. `recede_matrix` ported Apple's `.brightness(-0.24·d)` literally, and that is dark-mode arithmetic — on a dark field, "down" is "away". This crate ships thirteen palettes and six of them are PALE, where darkening a card increases its contrast against a light ground. On `holo` the effect was not subtle: the UNFOCUSED host tile rendered as the heaviest object on screen, a grey slab beside the focused card it was supposed to be receding behind. The recede now moves a card toward its GROUND rather than always toward black, taking the direction from the scrim — which already encodes which way the field leans, because it tends to black on a dark palette and white on a pale one. The saturation drain is unchanged in both directions; colour has no handedness. The new test asserts the two poles disagree in SIGN — a receded card sinks on `violet` and lifts on `mint` — because that is the property, and any test written against one palette would have passed before this fix. |
||
|
|
d927fcb2b0 |
feat(console): Collections — group by platform, walk them, open one
The user's ask, verbatim: group by console, navigate the platforms, pick PS3, see its games. Y on a library opens a carousel of collection tiles; A on one pushes the same `LibraryScreen` with a FILTER set, titled "Host · PS3"; B walks back out. The whole flow is asserted end to end rather than in pieces, because it is the flow that was asked for. It borrows the home carousel's tile language on purpose. A collection is a place you go, exactly as a host is, and the console should have one idea of what that looks like — same sprung cursor, same entrance, same recede, same halo. Each tile fans up to three real covers, taken from the posters the library screen has ALREADY decoded rather than fetched again; a group with no art yet shows its monogram, which is also the permanent look of a platform full of art-less ROM entries. Filtering is index-level. The drill-in sets a `GroupKey` and the shelf collates against it, so the shared model, the art pump and the fetch flow never learn that collections exist — the filtered shelf is the same screen, not a copy of the library with things removed. The gate is a real one, and it is a pair. `worth_browsing` is false for a library with a single group, and BOTH the Y binding and the Y hint read it, so the legend can never advertise a press that only thuds. Launchers alone never count toward it: every library has them, and a collections screen offering only "Launchers" is precisely the one-tile screen the rule exists to prevent. Y is also refused from inside a collection — a drill-in from a drill-in would collate a set that is already one group. Sort lives here, on the shoulders, as four pills. The shelf re-reads `library_sort` every frame, so stepping it re-orders the collection tiles AND the shelf waiting behind this screen at once — which is the argument for putting it on this screen rather than in a dialog. `LibraryScreen` stopped unpacking its host into five scalars and keeps the `HostRow` whole, because Collections has to hand one back to `LibraryScreen::new` and two partial copies of the same host is the state that goes stale first. One thing worth recording: the flow test failed on its first run, asserting the filtered shelf held 0 titles instead of 2 — a screen adopts the shared model on its first sync, and the test asked before the app would have rendered. The sequencing was the bug, not the filter, and the test now draws a frame the way the console does. It is the kind of thing that would otherwise have been "fixed" by weakening the assertion. Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings, plain build, 121 console-ui + 213 client-core tests green. |
||
|
|
c354d7cd09 |
feat(console): a grid arrangement for the library, plus art eviction and a skeleton shelf
The coverflow is a browsing surface: one cover at a time, big, artwork doing the talking. It is the wrong shape for "I know what I want, show me everything", which on a 200-title shelf means holding Right and watching. The grid is that other surface — about eighteen covers at once — and it is a VIEW rather than a second screen: same cursor, same collated order, same art cache, same detail band underneath. Only the arrangement differs. `grid_step` is the pure half, and it carries the one real subtlety: the two boundary rules are different on purpose. A row's ENDS refuse, exactly as the shelf's do, because wrapping would make a held Right scan the whole library and the shoulders already do that. But Down into a SHORT last row CLAMPS to the last title rather than refusing — a ragged tail is a layout accident, not a boundary the user chose to hit. Both, plus page jumps and degenerate grids, are pinned by tests. Where the toggle lives is a deviation from the plan, and deliberate. The plan put View on X and Collections on Y, but X is already Copy link in the library and the grid needs Up/Down for rows, which leaves exactly one free face button for two features. Rather than evict a shipped binding, `library_view` becomes a settings row in Interface beside the palette and reduce-motion — which is what that tab is for, and this is a preference you set once, not something you toggle mid-browse. Y stays free for Collections. Art eviction is not cosmetic. `LibraryScreen::art` had no eviction at all, so every cover ever scrolled past stayed decoded for the life of the screen — the shelf touched a dozen and hid it; the grid touches all of them. Posters are now frame-stamped as they are DRAWN and the coldest trimmed past a budget of 160. Stamping after the draw rather than on arrival is the load-bearing detail, and the test says so: an LRU keyed on arrival would cheerfully evict the six covers the cursor is sitting among. Only decodes are trimmed — the encoded bytes are not kept here either way, so the fetch pipeline is untouched and a re-entry costs one frame of grey. Loading becomes a skeleton in the arrangement the real cards will arrive in, with one travelling sheen — the wait is for a LIST whose shape is known before its contents are, so showing that shape is more honest than a blank field that abruptly becomes a coverflow. It costs one extra paint and freezes under reduced motion. Two things fell out of the refactor and are worth naming: the art-less card face is now one function both arrangements call (a launcher without a poster still must not read as a game whose cover failed to load), and the detail band is shared rather than copied — it also gained the platform, which is the whole point of plumbing it, because "Shadow of the Colossus" means something rather different with "PS2" under it. Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings, plain build, 118 console-ui + 213 client-core tests green. One run aborted inside rav1d's CDEF filter; that is environmental (emulated amd64), confirmed by A/B — the immediately preceding run carried the identical pf-client-core sources and passed 213/213, and the re-run passes. |
||
|
|
c804a0dc9f |
feat(console): plumb platform to the shelf and give it a collation module
`GameEntry.platform` ("PC", "PS2", …) has crossed the wire since the model
landed and was thrown away at the console's boundary: both `LibraryGame` mapping
sites in the session binary built the struct without it. It is what the
rom-manager plugin populates, and without it "group by console" has nothing to
group by. Two lines, and the fake-library loader inherits it for free because it
maps the same entry type.
The interesting half is `collate` — a pure module, no Skia, because grouping
rules are exactly the kind of thing that reads obviously correct and is quietly
wrong, and because it is the half that has to be identical on every client. This
file is the portable spec the Apple and Android ports implement.
Two rules earn their tests:
- A platform-less game does NOT go to "Unknown". A Steam library has no
platforms at all, so an "Unknown" bucket would swallow it whole and be a worse
view than no grouping; a store-front game buckets under its STORE ("Steam"),
which is both true and useful, and only an entry with neither lands in
"Other".
- A-Z folds the leading article. "The Witcher 3" belongs under W; left alone
every "The …" piles up under T and the sort is useless exactly where a long
library needs it. English articles only, with a guard so a title that IS an
article doesn't sort as an empty string and float to the front.
Launchers lead by construction rather than by every caller remembering to put
them there, so design D4 survives grouping for free. Everything returns INDICES:
the art cache, the fetch pump and the cursor all key off the shared model's
ordering, and handing back cloned games would fork the identity of every title.
The shelf now routes its display order through `collate::filtered` even
ungrouped and unsorted — which is why this lands green on its own, and more
importantly is the right shape: shelf, sort and the coming drill-in are then one
screen with one cursor arithmetic. `SortKey::HostOrder` is the default and is
asserted byte-identical to the order the host sent, so a user who never touches
a sort pill sees no change whatever. `LibraryScreen` gains a `view: Vec<usize>`
the cursor indexes instead of `games`, and a `filter` the drill-in will set.
`library_sort` joins `trust::Settings` beside `ui_palette` and `reduce_motion` —
presentation only, never in a profile, parsed leniently so a newer client's key
reads as today's shelf. Its pills arrive with the Collections screen; the sort
is honoured now.
`clients/session/fixtures/mixed-platform-library.json` is the standing dev asset
the rest of Part C is built against: launchers, five platforms, several stores,
platform-less entries, and two deliberately awkward titles ("The Witcher 3",
"Émigré") so a broken fold shows up on glass and not only in a unit test.
Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 111 tests green.
|
||
|
|
be8183caab |
feat(console): host tiles get their OS mark, the chip gets a battery, the strip gets Rescan
`HostRow.os` has been plumbed since the model landed, with a comment saying the drawing was a follow-up because "the Skia glyph set doesn't exist yet". It does exist: assets/os-icons ships thirteen licensed masters, and `pf_client_core::os::os_icon_tokens` already resolves a chain to them - walking most-specific-first and applying the brand aliases (`macos` -> `apple`, `steamos` -> `steam`). Every other front-end walks that same list. So the console takes the shared resolver rather than inventing one, and gets its table GENERATED from the masters (`scripts/gen_os_mark_table.py`, hooked into the existing `gen-os-icons.sh`) rather than hand-transcribed. Thirteen paths of up to 3.5 kB where one mangled character is a silently wrong logo is not work for a human, which is precisely the reasoning the launcher-icon tables already carry. A new master now reaches the console for free; the script's closing note says so. Two corrections to the plan this implements, both found in the code: - The chain is SLASH-separated and resolves most-specific-FIRST, not "the first known token of a `;`-chain". A `linux/fedora/bazzite` host draws Bazzite, and falls back through Fedora to Tux - so the console is right about thirteen distros rather than the four the plan scoped. - The hint bar was already a glass pill, not "ink on the field". What it was missing is that it mixed its OWN glass (a flat wash and a hand-rolled stroke), making it the one floating surface that ignored the palette; it now goes through `theme::panel` like the chip and the toast, and picks up the lit edge. `draw_monogram` becomes `draw_badge`: the OS mark when the chain resolves, the initial when it doesn't. A substitution, not an addition - a badge showing both a Tux and an "L" says the same thing twice - and an older host that advertises no `os` keeps its monogram pixel for pixel. The controller chip gains a pad silhouette and a battery pip. `PadInfo` gets an additive `battery: Option<PadBattery>`; nothing crosses the wire, this is local SDL state. The plan expected to poll "on the existing pad-refresh cadence" - there isn't one, `publish()` is entirely event-driven (hotplug, pin change). And `pad_info` is deliberately open-free because an open GRABS the hardware, while SDL only reports power for an OPEN device. So the level is read from the ONE pad the service already holds open - `menu_open`, the nav pad, which is open exactly while a console is on screen and is the only pad any UI asks about - on a 15 s poll inside the loop that already wakes every 10 ms. Every other pad publishes `None`, which is the honest answer. `None` renders as no battery at all, never 0 %: a wired pad, a Steam virtual pad and SDL's `-1` "powered, level unknown" are all the same non-answer, and 0 % is the one reading that sends someone hunting for a charger. Charging outranks the low-charge red, because a pad at 4 % on the cable is not the problem a pad at 4 % off it is. Finally, Rescan: a second sentinel tile trailing Add Host, sending the `ConsoleCmd::Probe` that has existed unsent by any screen since it was written. A controller surface has no pull-to-refresh, so the affordance has to be a tile. The two trailing tiles are actions rather than hosts, so `hosts.get(i)` answering both "which host" and "which action" with one `None` became a `Slot` enum - with a second action tile that ambiguity is a bug waiting, and the test that matters is that an accidental A on the end of the strip can never start a session. Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings, plain build, 104 tests green. |
||
|
|
a289f7b9af |
feat(console): recede by draining colour, not by painting black over it
An unfocused card lost scale and gained a flat black veil. A veil only darkens, so a receded card stayed exactly as colourful as the focused one and the eye went on reading it as a competing subject - which is why the veil had to be heavy to work at all, and why the shelf read as "dimmed" rather than "deep". `theme::recede_matrix` adds the two channels the Apple gamepad UI uses and this crate did not: saturation down 42 % and brightness down 24 % at full recede, as one 4x5 colour matrix on the layer each card composites through. The veil then drops to HALF its old strength on both surfaces (home 0.24 -> 0.12, the coverflow's RECEDE_DIM 0.30 -> 0.15), left doing the one job a flat darkening is good at: separating cards that overlap. Alpha is deliberately untouched by the matrix, and there is a test saying so. The coverflow's side cards overlap, so a recede that reached alpha would let them show through each other - which is the reason the veil exists in the first place. A second test pins the focused card as EXACTLY identity, because a matrix that tinted the focus by half a percent would be invisible in review and wrong in every screenshot. The focused tile also gains an accent-tinted halo under its shadow - the palette-aware mark that says "this one" from a sofa, where a 12 % scale difference says nothing. On the coverflow it is drawn in screen space before the card's own transform, because it is light spilling AROUND the card and cannot live inside the rounded rect the card clips itself to; it fades with the sprung proximity rather than snapping on the integer cursor, so it travels with the strip. Glass gains a lit top edge (`theme::panel_highlight`): a 1 px inner stroke fading out over the top 40 %, which is what makes a material read as material rather than as a tinted rectangle. Deliberately a separate call rather than a flag on `panel`, so the cost discipline is visible - tiles and the one focused row get it, the dozens of resting rows a settings screen paints every frame do not. Cost, stated plainly: the coverflow previously had no per-card layer, so the recede adds one per VISIBLE card there (bounded to the card by raising it after the clip, and skipped entirely for the focused, settled one). That is the plan's named O(visible cards) price and the thing to watch on a Deck frame graph. The home carousel already composited each tile through a layer, so it pays nothing extra. The typography sweep found less than expected, which is worth recording: the crate is already almost entirely on the 0.55 / 0.7 / 0.85 ladder. Two genuine outliers, both in the library - a group heading a shade louder than the identical role in `MenuList` (0.5 -> 0.45) and a detail subtitle off the ladder (0.5 -> 0.55). The `fg(0.92)` runs are NOT outliers: they are letters on a button badge and text on a toast pill, sitting on their own ground rather than on the field. `ROW_MAX_W` is already Apple's 620, and the 30 dp title against 16 dp row labels is within a hair of Apple's 34/17 rhythm, so neither moved. Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings, plain build, 99 tests green. |
||
|
|
50ef253e43 |
feat(console): the screen transition becomes an interruptible spring (vectors v2)
Pressing B while a screen was still arriving did nothing at all. The transition was a 0.26 s ease-out-cubic and every event that landed inside it was thrown away, so holding B to back out of a deep stack stuttered at every level: press, wait, press again. That wall is the one place the console felt less alive than the Apple shell it is modelled on. `Motion` becomes one sprung scalar. The paint recipes read `spring.pos` exactly where they read `ease_out_cubic(raw)` - same 36 dp slide, same 0.985/0.96 scales, same 0.4 reveal alpha, so the choreography is untouched and only the time-course changed. Those four numbers stop being literals buried in the paint sites and become named constants, because the vectors file claims to pin them for all three clients and a literal is a thing no test can reach. What the spring buys is the retarget. Back mid-push flips THAT SAME spring from 1.0 to 0.0: velocity carries, so the entering screen decelerates, turns, and goes back down as one continuous motion, and `finish_nav` takes it off the stack when the spring lands. A tween cannot do that - its progress is a function of elapsed time, so reversing means either a snap or a second animation played behind the first. The test asserts the continuity directly (no sample jumps by more than a frame of the travel already underway), because "cancel and play a pop" would pass a weaker test and still look wrong. Back is now always heard; everything else stays queued-dropped until the spring passes 0.85, so the double-tap protection survives. The threshold is on POSITION, not elapsed time - with a spring those are different questions, and the one that matters for input is whether the screen under the cursor is the one being aimed at. Mid-pop, A is still refused (activating a half-dismissed screen is a mis-tap, not intent) while Back starts the next pop immediately, which is the stutter this removes. At the root the transition declines the press, because there is no parent to fall back to and B there means quit. Parity: `console-vectors.json` gains a `motion_spring` block and goes to version 2. Springs are integrator-dependent, so 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. The v1 `motion` block stays in place, marked `$deprecated` and still correct, because the Android client's ConsoleVectorsTest reads it; this crate's test switches to the new block and asserts the note is still there, so deleting v1 here would fail loudly rather than redden Android silently. (Note for the follow-ups: the Apple client mirrors the v1 constants in GamepadShell but does NOT test them - its ConsoleVectorsTests covers the palette table only. The plan assumed otherwise.) `Progress` is deleted. The transition was its last caller, and this crate lints dead code as an error - keeping a timer nothing times behind an allow would be the wrong kind of tidy. Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings, plain build, 97 tests green. |
||
|
|
2314637c0d |
feat(console): screens arrive instead of appearing - the staggered card entrance
Until now a console screen popped into existence fully formed; the only thing that moved was the shell's own push slide. This ports the Apple gamepad UI's `CardEntrance` semantics as `anim::Entrance`: a pure function of the shell clock and an item's distance from an anchor, so a screen holds ONE of them and asks it per item. No per-card state to keep in step with a host list that churns under discovery. Per item: a delay of 0.07 s per step from the anchor (capped at 0.42 s, which is what stops a 400-title shelf still arriving a minute later), then a 0.6 s window of ease-out-BACK travel - it crosses 1.0 and settles back, which is the difference between a card thrown into place and one slid there - with the fade finished in the first third, so what you read is the motion and not a dissolve. The anchor is the CURSOR, so a restored selection assembles around the eye. Three surfaces, each applying the envelope to its own geometry: - The home carousel: tiles arrive 26 % small, 34 dp low, fading, folded into the transform and layer alpha each tile already applies. Hit boxes follow the drawn geometry, because a press during that second must land on the card the eye sees. - The library coverflow: the same, plus a real Y-turn on the card's OWN rotation matrix. Apple had to fake its turn with a cos-squeeze because SwiftUI cannot snapshot a rotated layer to glass; Skia has no such constraint, so the strip genuinely fans open like a book. Cards turn away in the direction they sit from the anchor. - Menu lists: 12 dp rise, 0.03 s stagger, capped at eight rows. Same language, deliberately quieter - a settings list that fans open like a shelf of box art is a settings list showing off. NOT replayed on a tab switch: re-fanning the rows on every L1/R1 would turn a skim through the sections into a flicker. The library waits for content before it plays. Art streams in per title AFTER the list lands, so without a gate the entrance would reliably show off a rank of grey placeholder faces; it arms once the cards around the cursor have posters, or 400 ms have gone by and they clearly are not coming - so an art-less library of custom entries still gets its entrance, just later. On cost: the coverflow's resting frame is byte-for-byte as cheap as it was. It deliberately has no per-card layer - side cards overlap, so it dims them with an opaque veil rather than whole-card alpha - and the entrance raises one only while a card is still translucent, after the clip so `None` bounds mean the CARD and not the screen. A full-screen layer per arriving card is the one way this could have cost real time on a Deck. Under reduce motion the whole thing degrades to a staggerless crossfade, decided once when the entrance is armed so one entrance plays one way. Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings, plain build, 94 tests green. |
||
|
|
46d648cf0f |
feat(console): name the motion, spring the micro-motion, and let it all be turned off
The console's feel was scattered across a dozen hand-picked time constants, so "how a focused row arrives" was a tau someone chose in a hurry rather than a decision anyone could find. `anim.rs` grows a `SpringSpec` table (FOCUS, INDICATOR, PRESS) with a const `kc()` that performs SwiftUI's response/damping -> k/c conversion; a test pins it against TRAY_K/TRAY_C, which were hand-derived from exactly that arithmetic, and a second states the damping choices as behaviour (FOCUS must overshoot, INDICATOR must not). Three widgets stop easing and start springing, each for a reason: - Row focus splits into two channels. Scale is sprung, because the whisker of overshoot is the pop that makes a row read as picked up; tint and alpha keep `approach`, because an overshooting colour lands on a tint that is not in the palette. - The tab pill's x and width are sprung, so a fast skim through the sections accumulates into one accelerating travel instead of a series of eases that each restart at zero speed. - A stepped value slips 14 dp and springs back, crossfading the old value out. The list detects the change ITSELF by comparing what it drew against what the screen hands back, so no screen has to report whether its edit landed - and a refused adjust correctly produces no motion at all. Toasts gain a kind. "Paired with X", "Session ended" and "Couldn't connect" were the same grey pill, so the only way to tell them apart was to read them; each now carries a mark and a hairline. Error is the one tint that is fixed rather than palette-derived: moss's accent is a green, and reporting a failure in the colour the UI uses for "this is fine" is the bug. And a Reduce motion setting, under Interface beside the palette. It freezes the backdrop shader's clock (gated in `draw_aurora`, so the connect takeover inherits it), flattens push/pop to a 0.2 s crossfade with no slide or scale, and drops the recoil and slip travel - while keeping every haptic, because the pulse is what replaces the motion, not another thing to take away. A console setting rather than an OS mirror: there is no system reduce-motion SDL can read portably. It doubles as the OLED-friendly mode. Verified in the pf-gtkflow container (this crate is invisible to a Mac checkout - `cargo check -p pf-console-ui` there compiles nothing at all): fmt, clippy --all-targets -D warnings, a plain build, and 90 tests green. |