Compare commits

..
Author SHA1 Message Date
enricobuehler d92266a402 Merge pull request 'The console UI overhaul: motion, look, and a library you can actually navigate' (#269) from worktree-console-ui-overhaul into main
Reviewed-on: unom/punktfunk#269
2026-08-16 15:06:19 +00:00
enricobuehler 451f95079e fix(console): the focus recede ran backwards on all six pale palettes
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.
2026-08-16 16:21:10 +02:00
enricobuehler 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.
2026-08-16 16:04:14 +02:00
enricobuehler 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.
2026-08-16 15:55:32 +02:00
enricobuehler 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.
2026-08-16 15:44:17 +02:00
enricobuehler 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.
2026-08-16 15:36:36 +02:00
enricobuehler 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.
2026-08-16 15:25:13 +02:00
enricobuehler 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.
2026-08-16 15:19:25 +02:00
enricobuehler 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.
2026-08-16 15:11:55 +02:00
enricobuehler 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.
2026-08-16 15:03:00 +02:00
enricobuehler adf895ced1 Merge pull request 'main is red: the PyroWave worker proxy never wrote distrust_references' (#268) from worktree-pyrowave-distrust-refs into main
Reviewed-on: unom/punktfunk#268
2026-08-16 11:56:30 +00:00
enricobuehler 18fdc4cc6b fix(encode): the PyroWave worker proxy never wrote distrust_references, so its own guard test failed
Pre-existing on main, inherited by merging it. `distrust_references` arrived with the RFI
anchor work in `93c1ed07`; `RemotePyroWave` did not gain it, and the trait-coverage test
beside the impl caught exactly that.

The answer is a no-op, for the same reason `invalidate_ref_frames` returns false and
`request_keyframe` does nothing: PyroWave is intra-only, so every AU is already a
keyframe and there is no RFI anchor trust to withdraw. The in-process encoder reaches
the same answer by inheriting the trait default, and that is fine there.

It is not fine HERE, which is the point the guard test is making. This type is a proxy:
an inherited default means the worker never hears the call, so a method that does
something is silently dead on every worker-backed session while the in-process path
keeps working. The test therefore requires the method to be WRITTEN, so that "nothing to
do" is a visible decision rather than an omission. Written, with the reason.

Verified with CI's exact command — `cargo test -p pf-encode --features
nvenc,vulkan-encode,pyrowave` — 122 passed, and clippy clean under the same features.
2026-08-16 13:39:15 +02:00
enricobuehler 7ba344cf4e Merge pull request 'Lossless audio: the 0xD3 PCM plane, 44.1–176.4 kHz, surround, verified on glass (ABI 24)' (#263) from worktree-hires-audio-pcm into main
Reviewed-on: unom/punktfunk#263
2026-08-16 11:18:28 +00:00
25 changed files with 4774 additions and 438 deletions
+10
View File
@@ -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é" }
]
+2
View File
@@ -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(),
);
+13 -1
View File
@@ -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."
}
}
+105 -2
View File
@@ -96,6 +96,8 @@ const MENU_DEADZONE: u16 = 16384;
const MENU_REPEAT_DELAY: Duration = Duration::from_millis(380);
/// …and then repeats at this cadence until released or changed.
const MENU_REPEAT_INTERVAL: Duration = Duration::from_millis(160);
/// How often the open pad's battery is re-read. See [`GamepadWorker::battery_poll`].
const BATTERY_POLL: Duration = Duration::from_secs(15);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuDir {
@@ -266,6 +268,47 @@ pub struct PadInfo {
/// physical controller and has no sensors/touchpad, so auto-selection skips it while a real
/// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode.
pub steam_virtual: bool,
/// The pad's own power state, when it reports one. Purely LOCAL SDL state — nothing about
/// this crosses the wire, so it is additive with no ABI implication whatever.
///
/// `None` is the common case, not an error: a wired pad has nothing to report, and Steam's
/// virtual gamepad reports nothing about the physical device behind it. Anything reading
/// this must degrade to "no battery shown" rather than to "0 %".
pub battery: Option<PadBattery>,
}
/// SDL's power report for an OPEN pad, reduced to the two facts a UI can act on.
///
/// `None` folds together every "nothing useful to say" case: a wired pad with no battery at
/// all, an error, an unknown state, and the `-1` percentage SDL returns for "powered, level
/// unknown". A caller must draw NO battery for `None` — never 0 %, which is the one reading
/// that would send someone hunting for a charger.
fn battery_of(pad: &sdl3::gamepad::Gamepad) -> Option<PadBattery> {
use sdl3::joystick::PowerLevel;
let info = pad.power_info();
let charging = match info.state {
PowerLevel::OnBattery => false,
PowerLevel::Charging | PowerLevel::Charged => true,
PowerLevel::NoBattery | PowerLevel::Error | PowerLevel::Unknown => return None,
};
if info.percentage < 0 {
return None;
}
Some(PadBattery {
percent: info.percentage.min(100) as u8,
charging,
})
}
/// A controller's power state, as SDL reports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PadBattery {
/// 0100. SDL gives 1 for "on power but level unknown", which callers map to `None`
/// rather than storing here.
pub percent: u8,
/// On the cable (or dock) right now. Worth showing separately: a pad at 4 % that is
/// charging is not the problem a pad at 4 % that is not.
pub charging: bool,
}
impl PadInfo {
@@ -1100,6 +1143,11 @@ struct Worker {
/// The ONE device held open for menu navigation while menu mode is on and NO session is
/// attached (`active_id`); mutually exclusive with `slots` (a session supersedes the menu).
menu_open: Option<(u32, sdl3::gamepad::Gamepad)>,
/// The menu pad's last-read power state, `(id, level)`. Cached rather than read in
/// [`publish`](Self::publish) because publish runs on every hotplug and pin change,
/// while the battery only wants looking at every few seconds.
battery: Option<(u32, PadBattery)>,
battery_at: Option<Instant>,
/// Connected pad ids in connection order (metadata only, no device open); the most
/// recently connected is the auto selection.
order: Vec<u32>,
@@ -1211,6 +1259,9 @@ impl Worker {
|| name.starts_with("Steam Virtual Gamepad"),
name,
pref,
// Unknowable from an ID-based getter — SDL reports power only for an OPEN
// device. `publish` fills it in for the one pad this service holds open.
battery: None,
})
}
@@ -1897,14 +1948,63 @@ impl Worker {
/// Publish the pad list, active pad, and pin to the UI-facing mutexes.
fn publish(&self) {
// `pad_info` is deliberately open-free, and SDL only reports power for an OPEN
// device — so the battery is attached here, from the cache
// [`battery_poll`](Self::battery_poll) keeps for the one pad this service holds
// open. Every other pad publishes `None`, which is the honest answer: we cannot
// know without grabbing hardware that isn't ours to grab.
let with_battery = |id: u32| -> Option<PadInfo> {
let mut info = self.pad_info(id)?;
if let Some((bid, b)) = self.battery {
if bid == id {
info.battery = Some(b);
}
}
Some(info)
};
let mut list: Vec<PadInfo> = self
.order
.iter()
.filter_map(|&id| self.pad_info(id))
.copied()
.filter_map(with_battery)
.collect();
list.reverse(); // most recent first — the Settings list order
*self.pads_out.lock().unwrap() = list;
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
*self.active_out.lock().unwrap() = self.active_id().and_then(with_battery);
}
/// Re-read the open pad's battery on a slow cadence, republishing only when it moved.
///
/// Polled rather than event-driven because nothing reports a battery CHANGING — it
/// drifts, so the only way to show it is to look now and then. 15 s is far finer than a
/// percent takes to move and far coarser than anything the service's 10 ms loop would
/// notice; the read itself is a cached HID report, not a device transaction.
///
/// Only ever the menu pad, which is the only one open while a console is on screen —
/// and the only one any UI asks about.
fn battery_poll(&mut self) {
let Some((id, pad)) = &self.menu_open else {
// Nothing open: forget the level rather than publish a stale one for a pad that
// may since have been unplugged.
if self.battery.take().is_some() {
self.publish();
}
self.battery_at = None;
return;
};
let now = Instant::now();
if self
.battery_at
.is_some_and(|t| now.duration_since(t) < BATTERY_POLL)
{
return;
}
self.battery_at = Some(now);
let fresh = battery_of(pad).map(|b| (*id, b));
if fresh != self.battery {
self.battery = fresh;
self.publish();
}
}
/// Apply queued control-plane messages from the UI thread. Returns false when the
@@ -2533,6 +2633,8 @@ impl Worker {
active_out,
slots: Vec::new(),
menu_open: None,
battery: None,
battery_at: None,
order: Vec::new(),
pinned: None,
forwarding: true,
@@ -2617,6 +2719,7 @@ fn run(
w.maybe_fire_disconnect();
w.menu_poll();
w.battery_poll();
w.render_feedback();
}
}
+30
View File
@@ -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(),
+320 -35
View File
@@ -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.2e16 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"
);
}
}
+441
View File
@@ -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,
/// AZ, case- and diacritic-relaxed, with the leading article folded away.
Title,
/// Platform label AZ, then title within it.
Platform,
/// Store label AZ, 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 => "AZ",
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 AZ. "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 AZ 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);
}
}
+122 -11
View File
@@ -31,6 +31,112 @@ impl GlyphStyle {
}
}
/// A compact mark for WHAT is driving the console, drawn from `(x, cy)` across `w`: a
/// controller silhouette, or a keycap when there is no pad and the keyboard is doing the
/// work. Says at a glance which glyph set the legend below is speaking in.
pub(crate) fn pad_mark(
canvas: &Canvas,
style: GlyphStyle,
x: f64,
cy: f64,
w: f64,
k: f64,
ink: skia_safe::Color4f,
) {
let mut p = Paint::new(ink, None);
p.set_anti_alias(true);
if style == GlyphStyle::Keyboard {
// A keycap: the same shape the hint bar draws for a key, at chip size.
let h = w * 0.72;
let r = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.3 * k) as f32);
canvas.draw_rrect(
RRect::new_rect_xy(r, (3.0 * k) as f32, (3.0 * k) as f32),
&p,
);
return;
}
// A gamepad: a wide rounded body with a grip under each end. Detail beyond the
// silhouette is invisible at 15 dp, so there is none — the outline IS the glyph.
let h = w * 0.52;
let body = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(body, (h / 2.2) as f32, (h / 2.2) as f32),
&p,
);
let grip = (w * 0.17) as f32;
canvas.draw_circle(((x + w * 0.2) as f32, (cy + h * 0.36) as f32), grip, &p);
canvas.draw_circle(((x + w * 0.8) as f32, (cy + h * 0.36) as f32), grip, &p);
}
/// A four-segment charge pip, drawn from `(x, cy)` across `w`. Filled segments are the
/// charge; the outline is always the full cell, so "one bar" and "four bars" occupy the
/// same width and the chip never reflows as the pad drains.
///
/// Three states, in priority order. CHARGING takes the palette's accent and outranks the
/// low warning, because a pad at 4 % on the cable is not the problem a pad at 4 % off it
/// is. Otherwise under 20 % goes red — a fixed red, not the accent, for the same reason the
/// error toast uses one: on a `moss` or `mint` field the accent is a colour that means
/// "fine". Everything else is plain foreground.
pub(crate) fn battery_pip(
canvas: &Canvas,
x: f64,
cy: f64,
w: f64,
k: f64,
b: pf_client_core::gamepad::PadBattery,
) {
let h = w * 0.5;
let cell = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, (w * 0.86) as f32, h as f32);
let ink = if b.charging {
crate::theme::accent(1.0)
} else if b.percent < 20 {
skia_safe::Color4f::new(0.93, 0.31, 0.28, 1.0)
} else {
crate::theme::fg(0.7)
};
let mut outline = Paint::new(ink, None);
outline.set_anti_alias(true);
outline.set_style(skia_safe::PaintStyle::Stroke);
outline.set_stroke_width((1.2 * k) as f32);
let r = (2.0 * k) as f32;
canvas.draw_rrect(RRect::new_rect_xy(cell, r, r), &outline);
// The terminal nub, so the cell reads as a battery and not as a text field.
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(
cell.right + (1.5 * k) as f32,
(cy - h * 0.22) as f32,
(1.8 * k) as f32,
(h * 0.44) as f32,
),
r,
r,
),
&Paint::new(ink, None),
);
// Four segments, rounded UP so a pad with any charge left always shows at least one —
// an empty-looking cell on a pad that still works reads as broken.
let filled = ((f32::from(b.percent) / 100.0) * 4.0)
.ceil()
.clamp(0.0, 4.0) as i32;
let pad = (1.6 * k) as f32;
let seg_w = (cell.width() - 2.0 * pad) / 4.0;
for i in 0..filled {
let sx = cell.left + pad + i as f32 * seg_w;
canvas.draw_rect(
Rect::from_xywh(
sx + 0.4 * k as f32,
cell.top + pad,
seg_w - 0.8 * k as f32,
cell.height() - 2.0 * pad,
),
&Paint::new(ink, None),
);
}
}
/// What a hint's glyph depicts. `Key` renders a literal keycap chip in any style (used
/// for keyboard fallbacks and the Deck's "Steam + X" keyboard chord).
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -107,22 +213,27 @@ pub(crate) fn hint_bar(
let h = BADGE_D * k + 2.0 * pad;
let w = content_w + 2.0 * pad;
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
// A scrim under the glass, then the SHARED glass recipe — the legend used to mix its
// own (a flat `fg(0.06)` wash and a hand-rolled stroke), which meant it was the one
// floating surface in the console that didn't pick up the palette's glass. The scrim
// stays because this pill sits over the aurora at full contrast, where glass alone has
// little to separate it from the field. Same construction as the toast.
let corner = (h / 2.0 / k) as f32;
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(crate::theme::shade(0.30), None),
);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(fg(0.06), None),
);
let mut sp = Paint::new(fg(0.12), None);
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&sp,
crate::theme::panel(
canvas,
rect,
corner,
None,
crate::theme::PanelStroke::Plain(0.12),
k as f32,
);
// One lit edge per frame for the whole legend — the cost discipline the highlight is
// rationed by counts ROWS, and this is chrome.
crate::theme::panel_highlight(canvas, rect, corner, k as f32);
let cy = bottom - h / 2.0;
let mut pen = x + pad;
+4
View File
@@ -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;
+195 -2
View File
@@ -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(),
);
+140
View File
@@ -0,0 +1,140 @@
//! GENERATED by scripts/gen_os_mark_table.py from the assets/os-icons masters.
//! Do not edit by hand — re-run `bash scripts/gen-os-icons.sh` instead.
//! Per-mark provenance and licensing: assets/os-icons/README.md.
//!
//! The OS mark a host tile draws, resolved from the host's advertised OS-identity chain.
//!
//! The RESOLUTION is not ours: [`pf_client_core::os::os_icon_tokens`] walks the chain
//! most-specific-first and applies the brand aliases (`macos` → `apple`, `steamos` →
//! `steam`), and every front-end — GTK, WinUI, Swift, Kotlin, the web console — walks the
//! same list. That is the whole point of it living in the shared crate: a Bazzite host must
//! not draw Tux here and a Fedora hat there. All this module owns is which tokens it has
//! art for, and how the art is fitted.
use skia_safe::{Matrix, Path, Rect};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
/// A parsed mark and the viewport its coordinates are in.
type Glyph = (Path, f32, f32);
/// Token → parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not
/// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this
/// file is generated — an inline type would fail the `-D warnings` gate on every regeneration.
type GlyphCache = HashMap<String, Option<Glyph>>;
/// `(token, viewport width, viewport height, path data)` — the masters, verbatim.
const GLYPHS: &[(&str, f32, f32, &str)] = &[
("apple", 384.0, 512.0, "M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"),
("arch", 24.0, 24.0, "M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"),
("bazzite", 24.0, 24.0, "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"),
("cachyos", 24.0, 24.0, "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"),
("debian", 24.0, 24.0, "M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"),
("fedora", 448.0, 512.0, "M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"),
("linux", 448.0, 512.0, "M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"),
("nixos", 24.0, 24.0, "M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"),
("nobara", 24.0, 24.0, "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"),
("opensuse", 640.0, 512.0, "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"),
("steam", 496.0, 512.0, "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"),
("ubuntu", 496.0, 512.0, "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"),
("windows", 24.0, 24.0, "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"),
];
/// The parsed path for a token plus the viewport it was authored in, or `None` when the token is
/// absent, unknown, or (defensively) unparseable.
///
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the home
/// carousel re-renders every frame while the cursor springs.
fn glyph(token: &str) -> Option<Glyph> {
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().ok()?;
if let Some(hit) = cache.get(token) {
return hit.clone();
}
let built = GLYPHS
.iter()
.find(|(t, ..)| *t == token)
.and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h)));
cache.insert(token.to_string(), built.clone());
built
}
/// The mark for an OS-identity `chain` (`"linux/fedora/bazzite"`), scaled to fit `dst` and
/// centred in it — aspect ratio preserved, because the masters' viewports are not all square.
///
/// `None` when the chain is empty, unknown, or made of tokens we ship no art for; the tile then
/// draws its monogram, exactly as every tile did before OS marks existed. A chain we only
/// partly know still resolves: `linux/fedora/bazzite` on a build shipping no Bazzite mark falls
/// to Fedora, then to Tux, because that is the order the shared resolver hands back.
pub(crate) fn os_mark(chain: &str, dst: Rect) -> Option<Path> {
let (path, vw, vh) = pf_client_core::os::os_icon_tokens(chain)
.into_iter()
.find_map(|token| glyph(&token))?;
let scale = (dst.width() / vw).min(dst.height() / vh);
let mut m = Matrix::new_identity();
m.set_scale((scale, scale), None);
m.post_translate((
dst.left + (dst.width() - vw * scale) / 2.0,
dst.top + (dst.height() - vh * scale) / 2.0,
));
Some(path.with_transform(&m))
}
#[cfg(test)]
mod tests {
use super::*;
/// Every shipped master parses. A mark that silently fails to parse is a tile that silently
/// loses its icon, which no other test in this crate would notice.
#[test]
fn every_glyph_parses() {
for (token, ..) in GLYPHS {
assert!(glyph(token).is_some(), "{token} failed to parse");
}
}
/// The chain resolves most-specific-first, through the shared resolver. `steamos` reaching
/// the Steam mark is the alias doing its job, not a coincidence of table order.
#[test]
fn chains_resolve_most_specific_first() {
let dst = Rect::from_wh(64.0, 64.0);
for chain in [
"windows",
"linux",
"linux/arch/steamos",
"linux/fedora/bazzite",
"macos",
] {
assert!(os_mark(chain, dst).is_some(), "{chain} resolved nothing");
}
// A distro we ship no art for still lands on its family's mark.
let known = os_mark("linux/debian/raspbian", dst);
assert!(
known.is_some(),
"an unknown leaf must fall back to its family"
);
}
/// An unknown or empty chain draws NOTHING, so the tile keeps its monogram — older hosts
/// advertise no `os` at all, and they must look exactly as they did.
#[test]
fn unknown_chain_draws_nothing() {
let dst = Rect::from_wh(64.0, 64.0);
assert!(os_mark("", dst).is_none());
assert!(os_mark("plan9/glenda", dst).is_none());
// Untrusted mDNS input that sanitizes away entirely is the same case.
assert!(os_mark("!!!/???", dst).is_none());
}
/// The mark is letterboxed into the destination, never stretched past it — the guarantee the
/// non-square viewports (apple is 384x512, windows 24x24) depend on.
#[test]
fn mark_is_contained_and_centred() {
let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
let b = os_mark("apple", dst).unwrap().compute_tight_bounds();
assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
assert!((b.center_x() - dst.center_x()).abs() < 1.0);
assert!((b.center_y() - dst.center_y()).abs() < 1.0);
}
}
+10 -1
View File
@@ -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),
);
}
}
+219 -44
View File
@@ -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
+42 -2
View File
@@ -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());
+263 -17
View File
@@ -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] = [
+116 -43
View File
@@ -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
+88 -51
View File
@@ -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);
+445 -29
View File
@@ -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 (1t)³`, 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 AZ 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(),
);
+201
View File
@@ -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);
}
}
+432 -18
View File
@@ -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
View File
@@ -76,6 +76,16 @@ for t in "${tokens[@]}"; do
JSON
done
echo
# The Skia console parses SVG path data at RUNTIME, so it needs no baked derivative — it
# needs the path string, and gets it as a generated Rust table rather than a hand-kept
# inline registry. Thirteen paths of up to 3.5 kB where one mangled character is a silently
# wrong logo is not transcription work for a human. Always regenerated from EVERY master,
# whatever tokens this script was invoked with: it is one file, and a partial rewrite would
# drop the rest.
log "console Rust table (crates/pf-console-ui/src/os_marks.rs)"
python3 scripts/gen_os_mark_table.py
echo
log "Inline path data (web/src/components/os-icon.tsx, clients/decky/src/os-icon.tsx,"
log " clients/android/.../components/OsIcons.kt — hand-kept, paste from here)"
@@ -94,4 +104,5 @@ echo
log "Remember: a NEW token also has to be added to each client's shipped-token list —"
log " clients/linux/src/ui_hosts.rs, clients/linux/data/resources.gresource.xml,"
log " clients/windows/src/app/os_icons.rs, clients/apple/.../PunktfunkKit/OsIcon.swift,"
log " plus the three inline registries above."
log " plus the three inline registries above. (The console's table is generated above and"
log " needs no list — it ships whatever masters exist.)"
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Emit the Skia console's OS-mark table from the assets/os-icons masters.
crates/pf-console-ui/src/os_marks.rs in-session console UI, Skia Path::from_svg
The console parses SVG path data at runtime, so unlike the GTK/Windows/Apple clients it needs
no baked raster or PDF it needs the path string. And unlike the three hand-kept inline
registries (web, Decky, Android), it is generated outright for the same reason the
launcher-icon tables are: thirteen paths of up to 3.5 kB each, where one mangled character is a
silently wrong logo that no test would catch.
Always emits EVERY master, whatever tokens gen-os-icons.sh was invoked with this is one file,
and a partial rewrite would drop the rest.
Usage: python3 scripts/gen_os_mark_table.py (from anywhere; paths are repo-relative)
"""
from __future__ import annotations
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
MASTERS = ROOT / "assets" / "os-icons"
OUT = "crates/pf-console-ui/src/os_marks.rs"
BANNER = (
"GENERATED by scripts/gen_os_mark_table.py from the assets/os-icons masters.\n"
"Do not edit by hand — re-run `bash scripts/gen-os-icons.sh` instead.\n"
"Per-mark provenance and licensing: assets/os-icons/README.md."
)
def mark(token: str) -> tuple[str, str, float, float]:
"""(token, path data, viewport width, viewport height) for one master."""
svg = (MASTERS / f"{token}.svg").read_text()
box = re.search(r'viewBox="([^"]+)"', svg).group(1)
paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
if len(paths) != 1:
sys.exit(f"{token}: expected exactly one <path>, found {len(paths)}")
d = paths[0]
if any(c in d for c in '\n\t"\\'):
sys.exit(f"{token}: path data must be single-line and free of quotes/backslashes")
_, _, w, h = box.split()
return token, d, float(w), float(h)
# Sorted so the emitted file has a stable order across runs and machines.
TOKENS = sorted(p.stem for p in MASTERS.glob("*.svg"))
MARKS = [mark(t) for t in TOKENS]
# `!r` on a float always writes a decimal point (`384.0`, never `384`), which matters: the
# table's type is `f32` and a bare integer literal is a type error, not a coercion.
rows = "\n".join(f' ("{t}", {w!r}, {h!r}, "{d}"),' for t, d, w, h in MARKS)
banner = "\n".join(f"//! {line}".rstrip() for line in BANNER.splitlines())
body = f"""{banner}
//!
//! The OS mark a host tile draws, resolved from the host's advertised OS-identity chain.
//!
//! The RESOLUTION is not ours: [`pf_client_core::os::os_icon_tokens`] walks the chain
//! most-specific-first and applies the brand aliases (`macos` `apple`, `steamos`
//! `steam`), and every front-end GTK, WinUI, Swift, Kotlin, the web console walks the
//! same list. That is the whole point of it living in the shared crate: a Bazzite host must
//! not draw Tux here and a Fedora hat there. All this module owns is which tokens it has
//! art for, and how the art is fitted.
use skia_safe::{{Matrix, Path, Rect}};
use std::collections::HashMap;
use std::sync::{{Mutex, OnceLock}};
/// A parsed mark and the viewport its coordinates are in.
type Glyph = (Path, f32, f32);
/// Token parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not
/// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this
/// file is generated an inline type would fail the `-D warnings` gate on every regeneration.
type GlyphCache = HashMap<String, Option<Glyph>>;
/// `(token, viewport width, viewport height, path data)` the masters, verbatim.
const GLYPHS: &[(&str, f32, f32, &str)] = &[
{rows}
];
/// The parsed path for a token plus the viewport it was authored in, or `None` when the token is
/// absent, unknown, or (defensively) unparseable.
///
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the home
/// carousel re-renders every frame while the cursor springs.
fn glyph(token: &str) -> Option<Glyph> {{
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().ok()?;
if let Some(hit) = cache.get(token) {{
return hit.clone();
}}
let built = GLYPHS
.iter()
.find(|(t, ..)| *t == token)
.and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h)));
cache.insert(token.to_string(), built.clone());
built
}}
/// The mark for an OS-identity `chain` (`"linux/fedora/bazzite"`), scaled to fit `dst` and
/// centred in it aspect ratio preserved, because the masters' viewports are not all square.
///
/// `None` when the chain is empty, unknown, or made of tokens we ship no art for; the tile then
/// draws its monogram, exactly as every tile did before OS marks existed. A chain we only
/// partly know still resolves: `linux/fedora/bazzite` on a build shipping no Bazzite mark falls
/// to Fedora, then to Tux, because that is the order the shared resolver hands back.
pub(crate) fn os_mark(chain: &str, dst: Rect) -> Option<Path> {{
let (path, vw, vh) = pf_client_core::os::os_icon_tokens(chain)
.into_iter()
.find_map(|token| glyph(&token))?;
let scale = (dst.width() / vw).min(dst.height() / vh);
let mut m = Matrix::new_identity();
m.set_scale((scale, scale), None);
m.post_translate((
dst.left + (dst.width() - vw * scale) / 2.0,
dst.top + (dst.height() - vh * scale) / 2.0,
));
Some(path.with_transform(&m))
}}
#[cfg(test)]
mod tests {{
use super::*;
/// Every shipped master parses. A mark that silently fails to parse is a tile that silently
/// loses its icon, which no other test in this crate would notice.
#[test]
fn every_glyph_parses() {{
for (token, ..) in GLYPHS {{
assert!(glyph(token).is_some(), "{{token}} failed to parse");
}}
}}
/// The chain resolves most-specific-first, through the shared resolver. `steamos` reaching
/// the Steam mark is the alias doing its job, not a coincidence of table order.
#[test]
fn chains_resolve_most_specific_first() {{
let dst = Rect::from_wh(64.0, 64.0);
for chain in [
"windows",
"linux",
"linux/arch/steamos",
"linux/fedora/bazzite",
"macos",
] {{
assert!(os_mark(chain, dst).is_some(), "{{chain}} resolved nothing");
}}
// A distro we ship no art for still lands on its family's mark.
let known = os_mark("linux/debian/raspbian", dst);
assert!(known.is_some(), "an unknown leaf must fall back to its family");
}}
/// An unknown or empty chain draws NOTHING, so the tile keeps its monogram older hosts
/// advertise no `os` at all, and they must look exactly as they did.
#[test]
fn unknown_chain_draws_nothing() {{
let dst = Rect::from_wh(64.0, 64.0);
assert!(os_mark("", dst).is_none());
assert!(os_mark("plan9/glenda", dst).is_none());
// Untrusted mDNS input that sanitizes away entirely is the same case.
assert!(os_mark("!!!/???", dst).is_none());
}}
/// The mark is letterboxed into the destination, never stretched past it the guarantee the
/// non-square viewports (apple is 384x512, windows 24x24) depend on.
#[test]
fn mark_is_contained_and_centred() {{
let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
let b = os_mark("apple", dst).unwrap().compute_tight_bounds();
assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
assert!((b.center_x() - dst.center_x()).abs() < 1.0);
assert!((b.center_y() - dst.center_y()).abs() < 1.0);
}}
}}
"""
path = ROOT / OUT
path.write_text(body)
print(f" {OUT} ({len(body):,} bytes, {len(MARKS)} marks)")