Compare commits

...
Author SHA1 Message Date
enricobuehler 96959c984f fix(console): the collections setting was wired to nothing, the grid lost its last row, and the sort bar was put down wherever there was room
Three more from the Deck.

"Even if I have the collections enabled in the settings, they don't actually get
shown when opening the library."

`LibraryScreen::collections_upgrade` was written, documented, and unit-tested for
its DECISION — and then nothing ever called it. It carried an
`#[allow(dead_code)]`, which is exactly what stopped the compiler from saying so,
and its own tests passed throughout because they called it directly. The setting
was on, the shelf agreed it should stand aside, and the library opened on the
shelf anyway.

The call belongs in the shell's per-frame sync and nowhere else: a screen cannot
replace ITSELF. The shelf can answer the question, because it holds the library
and the settings; only the shell owns the stack. It is guarded on a settled
transition, because mid-flight the stack's top is not yet what the user is looking
at, and swapping under a push they have already reversed with B would land them on
the collections of a host they just backed out of. The new test drives
`Shell::sync` and asserts on the STACK — the shelf was never the broken part, so a
test that asked the shelf would have gone on passing.

"The grid view is cut off at the bottom."

The mirror of the top inset fixed one commit ago, and that fix is what made it
visible. The content ended at the last row's card bottom, so at maximum scroll
that card sat exactly flush with the viewport's clip and lost its focus scale, its
shadow and its label. The air has to be part of `content_h` rather than of the
viewport, because `content_h` is the only thing the scroll clamp knows about.

"I don't like the top bar for switching the sorting & view type, please redesign
it — one of the worst parts is the not centered view switch, the ugly looking
focus indicator (dark bg, border)."

The arrangement group used to START at the band's midpoint. That is neither
centred nor trailing: it read as a control that had been put down wherever there
was room. Both groups are now anchored to the edge they belong to — the sort leads
at the heading's inset, the arrangement trails at the controller chip's, and the
shoulders that change it are the trailing pair of buttons, so the hand and the eye
agree. The band is the same two-anchor structure as the row above it.

The focus indicator was a glass panel, a brand hairline and a halo — the console's
recipe for a floating SURFACE, applied to a strip that sits flat in the field.
Hence the dark slab with a line round it, worse on the six pale palettes where the
glass turns to frost over an already-light field. It is an accent wash now and
nothing else: no border, no halo, no glass. The accent is palette-derived, so 14 %
reads at both poles without the strip ever becoming an object.

One defect introduced and caught in the same pass, by looking rather than by
testing: the gap between a caption and its pills was never explicit. It came from
`TabStrip`'s leading inset, which only appears when the rect it is handed has
slack — and a trailing group's rect is exactly as wide as its pills, so the gap
silently vanished and "VIEW" ended up touching the first pill, while the leading
group kept a gap by accident. Both groups now space their caption themselves and
both are handed exactly-sized rects, so neither depends on that side effect for
its position.
2026-08-16 23:55:51 +02:00
enricobuehler a4bf4c276e feat(console): one context menu for the whole console, a live view/sort bar, and collections as a place you can start
ci / web (pull_request) Successful in 1m28s
ci / docs-site (pull_request) Successful in 1m34s
apple / swift (pull_request) Successful in 2m1s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m5s
ci / rust (pull_request) Successful in 6m51s
ci / rust-arm64 (pull_request) Successful in 7m51s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m39s
android / android (pull_request) Successful in 17m55s
The last four of the reported issues, landing together because three of them
compete for the same six pad buttons and the fourth is what those buttons lead to.

THE CONTEXT MENU. "The gamepad UI has a shortcut just for Copy link — that's not
really thought through. Let's share the pattern of a context menu, which contains
the copy link action, and possible other coming actions down the road."

The console already had most of one: a saved host's ▲ menu. It is now the
console's ONE menu, generalised over a subject, and the library's X raises it for
the focused title instead of copying a link outright. Adding the next action is a
variant, a row in a list and a label — no new screen, no new binding, and the verb
is worded once for the whole console rather than once per surface.

X and not ▲ in the library, which is the one real design choice here: the grid
arrangement spends Up on row navigation, so ▲ would open a menu in the shelf and
move the cursor in the grid — one gesture meaning two things inside one screen.
The count of bindings does not grow; only the meaning of the one X already had.

A companion defect fell out of it: the hint bar's ▲ entry was not in the pointer
click map, so the Options hint on home was inert for mouse and touch and a host's
link could not be copied without a pad.

THE VIEW AND SORT BAR. "The library view types are buried deep in the settings —
they should be live switchable directly in the library", and "the sorting should
be visible in the actual game views, both the coverflow and the grid, not only in
the collection view".

Both are surfaced in the field rather than on buttons, because after the menu
takes X there are no buttons left: A, B, X, Y, L1 and R1 are all spoken for, and
`MenuEvent` carries exactly those six. A focusable bar shows the current view and
the current sort as VALUES — the sort was already being obeyed by both
arrangements and shown by neither, which is the actual complaint — and the view
switch persists to the same setting the Interface tab writes, so the two agree.

COLLECTIONS AS AN ENTRY. "Instead of hiding the collections view behind a second
click, make it an option in the settings — so if collections are enabled the user
directly gets the collections screen."

A setting, and the shelf hands over the moment it knows there is more than one
collection. The care went into the back stack: opened as the host's library there
is nothing underneath it, so it feeds itself from the poster queue the shelf would
have drained and offers the way to the whole library, where a drill-in from a
shelf has that shelf to fall back to. A library with one group never hands over,
because a collections screen listing a single tile is a press that buys nothing.

FOUR DEFECTS FROM THE DECK, reported against the build cut before this one:

- A collection's covers never loaded. `sync` cleared the decoded art on any list
  it considered new, and a drill-in's first list is new by definition — so the
  snapshot the collections screen hands down was wiped on the frame after it
  arrived, and the shelf then had nothing to re-fetch because the queue those
  bytes came from was drained long before. It now tells "the screen just mounted"
  apart from "the library moved underneath it".
- The Launchers collection fanned blank cards. The fan carried ids and looked them
  up as posters; a launcher has no poster and never will — its cover is a brand
  mark drawn from its icon — so every card found nothing and drew the ghost that
  means "not arrived yet". The fan carries the icon now, draws the launcher face
  with the same recipe the shelf's own placeholder uses, and never queues a fetch
  for art that does not exist.
- The grid's top row was cut off inside a collection. The top inset was spelled as
  the LAUNCHERS heading band, so one term was doing two jobs; a collection filtered
  to a single platform has no launcher prefix, the term went to zero, and row 0 sat
  flush against the viewport clip with its focus scale, entrance lift and halo
  sliced off. The inset is unconditional now and the heading band is not. The
  unfiltered grid's arithmetic is unchanged.
- Choosing "Edit…" in a host's menu flashed the host LIST before the editor
  arrived. A push paints the screen BENEATH the incoming one as its receding
  layer, and a replace had already popped and dropped the screen being swapped
  out — so "beneath" was that screen's parent, one level too far. The replaced
  screen is carried through the transition now, and a reversed replace puts it
  back, because that is the screen the user watched recede and return.
2026-08-16 22:07:01 +02:00
enricobuehler a308ca337f fix(console): headings and tab strips go leading, like every other client's
"The headings: they should be left aligned like on the other platforms, not
centered." And: "the tab bars, also align them left."

Both turned out to live in exactly one place each, which is why this is a
constant and two call sites rather than the eight-screen sweep it sounds like:
every screen's title is drawn by ONE call in the shell's layer paint, from the one
`Screen::title` match, and every strip is one `TabStrip::render` with the centring
on a single line. Nothing else in the crate draws a heading.

`theme::EDGE_INSET` is 24 design units, and it is not a new number. Apple pairs
`.padding(.horizontal, 24)` with the comment "Leading, like a console section
heading — centred read as a floating label"; Android names the same value
`ConsoleEdgeInset = 24.dp`. This console ALREADY used 24 on its right edge for the
controller chip, so the top band becomes symmetric instead of gaining a second
number — the chip's literal is now the same constant, so the symmetry is visible
in the source rather than a coincidence.

A PRE-EXISTING BUG falls out of doing this properly. The centred title was drawn
into a box spanning 15 %..85 % of the width — 192..1088 at 1280 — while the
controller chip begins at x≈1002. They already overlapped; only short strings hid
it. Left-aligning without a width budget would have run a long library title
straight under the chip, so the title now takes a budget computed from the chip's
measured width, and the chip's arithmetic is hoisted into one function that both
callers read. They cannot drift apart again.

A real behaviour change rides along: the heading is one line and ellipsizes.
It used to WRAP, growing a second line downward into the content. Both other
clients cap it at one line with an ellipsis, and a heading that reflows the screen
under it is worse than a truncated one.

The explainer lines directly under a heading (add-host, pair, host-options) go
leading with it, and their width is capped against the row column rather than the
window — at 1280 the old cap was 922 dp, which left-aligned would have run under
the chip as well. Leaving those three centred beneath a left-aligned heading was
the one outcome that would have looked worse than changing nothing.

NOT a motion change: no `anim.rs`, no spring, and nothing in
`clients/shared/console-vectors.json`. The strip's indicator is seeded at rest on
its new position, so no pill animates in from where it used to be.

KNOWN AND DELIBERATE, pending a call: on settings, add-host, pair, host-options
and pin-hosts the heading and strip now sit at 24 while the ROW column is still
centred (620 wide, landing at 330 on a 1280 panel). That is precisely Apple's
shipped layout, and it is why it is what landed. Android instead puts the rows at
the same 24. Matching Android is one line in `MenuList::render`, but it moves five
screens' entire content column 306 px left at 1280 — and needs those screens'
bottom detail bands to go leading in the same pass, or they end up left-aligned
everywhere except their footers. That is a larger change than these two issues
asked for, so it is a separate decision rather than a silent one.
2026-08-16 20:34:55 +02:00
enricobuehler ca1f36ac62 feat(console): the collection tile becomes a deck of covers with real depth
"The collection cards look bad — improve them, maybe giving the 3 preview covers
some depth, aligning them in a fancy way."

The three covers used to sit in a flat row at 42 % overlap, on a tile with its own
metrics, with the title placed after whatever the covers happened to occupy — so a
tile whose art had not decoded put its title somewhere else than the tile beside
it, and "PlayStation 3" clipped to "Play…" on the ones that had.

The tile is now home's tile to the pixel. The module comment claims a collection is
"a place you go, exactly like a host is", and that claim is only checkable if the
two are the same tile. Title and count take the bottom-left rail at full inner
width, so they start in the same column on every tile whatever decoded; the kind
caption takes the top-right, where a host tile keeps its status cluster.

The covers become a DECK: each card further back is smaller, higher, further right
and turned a few more degrees, drawn back to front so the sort-first game lands on
top. Four cues rather than one, because any single one is ambiguous at couch
distance — a smaller card could be a smaller cover, a lifted one a taller cover —
but smaller AND lifted AND turned is unmistakably "behind". Under each card is a
hard contact plate, grown on every side and dropped down-right rather than merely
offset, because the deck fans UP: a plain offset plate would shadow the down-right
seams and leave the top seam, where each card actually lies on its neighbour's
face, with nothing.

No blurs. A blurred shadow per cover would be three mask filters a tile and
fifteen across a live strip, where the whole crate has three blur call sites — and
a sibling change in this same series exists because per-card GPU work took the
grid to a slideshow. At 1280x800 from a sofa a 3 px hard band and a 3 px soft one
are the same mark.

It degrades: covers compact into the front slots, empty slots become ghosts with
no plate (an absent card casts no shadow), and a group with nothing decoded shows
a monogram badge. A one-title group never pretends to be a stack of three.

The badge's face was a hardcoded near-black under palette-derived ink, which on
the pale palettes put its own initials at 1.03:1 — invisible. It is accent-derived
now, measured at 4.9:1 or better across all thirteen.

Two things ride along because they are the same drawing:

- The per-tile layer had NO bounds and was raised unconditionally, so each tile
  allocated a full-SURFACE offscreen and composited it back — including the
  focused tile, whose alpha is 1 and whose recede is 0, i.e. a layer that does
  nothing at all. It is bounded now, and skipped when it has nothing to carry. The
  same change is made to the home carousel, which had the same defect. The bounds
  must clear the halo and the shadow, which are drawn INSIDE the layer; bounding
  to the bare tile would clip both away.
- The drill-in handed its new shelf no art, while sitting on the decoded posters it
  had just fanned on the very tile that was pressed. Since the shared art queue is
  drained by whoever reads it first, that shelf then waited out the full art
  deadline and showed monogram cards permanently. The snapshot is handed down now,
  the way it is already handed up.

Screenshot scenes were added for this: there were none for collections at all, so
none of the above had a witness. The ordering in them is load-bearing — art must
be pushed AND decoded over several frames BEFORE the drill-in is opened, because
the art snapshot is taken at that instant, and a deck of covers photographed too
early looks exactly like a deck that was never built.
2026-08-16 20:19:22 +02:00
enricobuehler 8e82a175ee fix(console): the library screen — a slideshow grid, a focus engine that disagreed with its own layout, and a shelf that showed itself finished before arriving
Three reported defects, landing as one commit because they rewrite the same file
and each one's fix is visible in the others' code. In order of how much they hurt:

================================================================================
=== THE SLIDESHOW ===
The library grid was re-decoding most of its posters every frame

Reported from a Steam Deck: the grid "horribly lags, almost going down to 1-2fps
by feeling — looks like a slideshow". The decisive detail was in the same
sentence: "the lag only starts when the whole screen is filled with cards, at the
top or the bottom it seems to run mostly smooth". A cliff, not a slope — which
rules out per-card drawing cost and points at a budget being crossed.

It was the GPU resource cache. `Image::from_encoded` builds a DEFERRED image:
nothing is decoded at fetch time, the only persistent state is the encoded bytes
plus a generator, and Skia's own documentation says a purge means the next draw
must RE-DECODE. Nothing downscaled the fetched art, so each poster was its source
600x900 (SteamGridDB portraits reach 1000x1500) and `art_sampling` asks for a mip
chain on top — about 2.75 MB of GPU memory each. A filled grid at Deck scale draws
35-42 covers, so the working set was 96-115 MB against a 64 MB budget. Every
submit evicted a third of the screen, and the next frame decoded it again from
JPEG on the render thread. A half-filled screen fits, and is smooth.

The field comment claiming the posters were decoded once and merely uploaded
lazily was simply wrong, and is corrected.

Three changes, in order of how much they matter:

- Decode ONCE, at the size actually sampled, with mipmaps baked in. Arriving bytes
  are decoded in `sync` and scaled into a box twice the grid cell's width at the
  live scale — so a purge now costs an upload rather than a decode, and the
  resident cost falls from ~2.75 MB to ~0.72 MB a poster. Twice the cell, not one:
  the coverflow shares this cache and draws far larger cards, and a texture sized
  for a grid cell would be visibly soft there. It never enlarges, and it keeps the
  original if Skia refuses the scale.
- Bound the work per frame. Without a cap this fix would only move the stall: the
  library arrives in one burst, and decoding a whole shelf on one frame is the
  same hitch in a different place.
- Raise the cache ceiling to 160 MB. A ceiling, not an allocation — Skia grows
  into it under demand, and the demand is now ~30 MB for a full grid. What this
  buys is headroom, so the eviction cliff cannot be reached again by a larger
  panel or a bigger library.

The one arrangement that can still crowd the budget is a 4K panel fed 1000x1500
portraits, where full resolution genuinely is what gets drawn — and that is a
desktop GPU by the time it happens.

NOT VERIFIED ON GLASS. The container gate proves this compiles, lints and passes
144 tests; it cannot measure a frame rate. The claim that the slideshow is gone
needs the Deck.
=== THE FOCUS ENGINE ===
The grid renderer and its navigator disagreed about the grid's own shape

Reported from a Steam Deck: "the focus engine does not work reliable, especially
when jumping from the launcher grid down to the games grid."

That "especially" is the whole diagnosis. The grid is laid out in two sections —
launchers first, then games restarting on a fresh row — and `draw_grid` knew that,
while `grid_step` was plain uniform-grid arithmetic on a flat index (`cursor %
cols` for the column, `± cols` for a row). Two independent descriptions of one
layout, which agree only when the launcher count happens to be an exact multiple
of the column count. Every other library is a grid where Down lands somewhere the
user did not point at, and the crossing between the two sections is where the
divergence is guaranteed rather than merely likely.

So the fix is not to special-case the crossing. Both sides now build the same
`GridShape` — columns, length, split — and ask IT where a cell is. They cannot
disagree, because there is no second description left to drift.

On top of that one rule: horizontal moves walk their own row and refuse at that
row's true ends (a short last row no longer wraps into open space), vertical moves
change row only, and the column is remembered rather than recomputed — a step
sideways chooses a column, a step down merely borrows it, so walking down a ragged
grid no longer drifts leftward one row at a time. A stale out-of-range cursor
reads as the nearest real cell instead of compounding.

`grid_cols_last` was seeded at 4 while a Deck draws 7, so the first press after
entering the grid moved by the wrong stride. It is an Option now, written only by
a frame that actually drew, and navigation declines rather than guessing before
then.

Also here, because it is the same layout code: the entrance fans on the LINEAR
cell index, so with the wider stagger the fan was exhausting partway through the
first row of a seven-wide grid and every cell after it started simultaneously. The
grid now fans on |row - anchor row| + |col - anchor col|, i.e. diagonally out from
the focused cell, which is what the stagger looks like everywhere else.
=== THE FLASH AND THE SKELETON ===
The shelf showed itself finished before animating in, and the skeleton went with it

Two reports against the library, and they turned out to be one wait split across
two phases.

"The libraries have skeletons — I don't like that, remove them and replace them
with a simple loading spinner." And: "the cards render before animating in, so
they instantly appear, get hidden again, and then get animated in."

THE FLASH. The draw code asked one question — is there an entrance? — of a state
that has three answers. `None` meant both "not armed yet" and "already finished",
and both fell through to SETTLED, i.e. fully arrived. Meanwhile arming is
deliberately deferred until a poster decodes or 400 ms elapse, so that the console
does not fan open a rank of grey placeholders. Put together: the finished shelf
was drawn at full opacity for up to 400 ms, then the entrance armed at the current
clock, every card blinked to zero, and the arrival played. Not one bad frame —
most of half a second of finished shelf, then a blink.

It reproduces whenever the game list arrives before its art, which is the normal
case against a real host. The collections drill-in hit it every single time,
because it starts with no decoded art at all. And the fake-library dev hook MASKS
it, by pushing art before the list — so the one tool most likely to be used to
check this shows a shelf that looks perfect.

The three states are now distinguished in one place, and nothing is drawn until
the entrance's first real frame.

THE SKELETON. Which is the same wait. The skeleton existed for the phase where the
list has not arrived; the flash existed because the phase where the ART has not
arrived had nothing to draw at all. One spinner now covers both, with no seam
between them — the shared arc the connect and wake takeovers already use, at their
size and their spacing, rather than a second spinner with its own opinions. The
hit-test geometry is cleared on those frames so a press cannot land on a card that
was never drawn.

One stated regression: the skeleton froze its sheen under reduced motion, and the
spinner does not freeze, because a frozen spinner reads as a hung application. The
crate's three other spinners behave the same way.

CONTRAST, found while in here and fixed because it is the same drawing. The
art-less poster placeholder used two HARDCODED faces under palette-derived ink. On
the six pale palettes the foreground is a near-black tinted toward the ground, so
a game's monogram over a fixed #1E1E25 face measured 1.03:1 — not dim, absent.
There is now one `theme::card_face`, the accent mixed into an opaque base the
field's own lean chooses, which keeps the launcher-versus-game distinction in the
tint. Measured across all thirteen palettes: no worse than 7.6:1 for a game's
monogram, 6.0:1 for a launcher's label. The face has to stay OPAQUE — coverflow
side cards overlap, and a translucent one would show its neighbour through it —
which is why the collections badge's recipe could not simply be reused.
2026-08-16 20:19:14 +02:00
enricobuehler 610f8b9cbe fix(console): the toast said its kind twice, and the entrance stagger was below the threshold that reads as a sequence
Two unrelated reports, kept together only because they are two small files.

THE TOAST LINE. "It seems to have a weird line inside left to the text." The
toast's leading run was hairline, air, mark, air — and the hairline was pure
redundancy: the mark immediately to its right already carries the toast's kind, in
the same tint, from the same `ToastKind::look()`, and for the Info kind that mark
is already a circle. Two elements saying one thing, close together and at
different weights, read as a rendering seam rather than as meaning, which is
exactly what was reported.

So the line goes and the mark stays; nothing replaces it, because nothing was
lost. The leading pad is 13 dp rather than the pill's 16 because the mark's box is
wider than anything drawn inside it — a Dot inks 6.8 dp of a 13 dp cell — so a
symmetric pad would put the mark's optical left edge 17-21 dp in against a 16 dp
trailing pad, and the pill would sit visibly left-heavy. At 13 the optical leading
pad averages 16.4 against 16.0 trailing.

THE STAGGER. "Increase it, it's not visible enough." The nominal 0.07 s flattered
itself twice over. An item is fully opaque a third of the way through its window
and its ease is 89 % done by the same point, so its VISIBLE action is ~0.20 s of a
nominal 0.6 — and every surface culls hard, so the number of staggered steps a
user can actually see is 2 on the home carousel and 5 in the coverflow. The whole
staggered event finished in 0.14 s of visible spread on Home, with 1.8 frames
between neighbours. Below the threshold where a sequence reads as a sequence at
all; it reads as one arrival with soft edges.

`stagger` and `cap` move together, because `cap / stagger` IS the number of
staggered steps: raising the stagger alone would fan fewer cards further apart and
land the rest in a block, which is the complaint, not the cure. `window` is
untouched — the individual card's feel was never the problem, the reduced-motion
crossfade path is defined in terms of it and stays byte-identical, and it is the
one entrance number the Apple client also spells out.

Nothing waits on this: the focused item has delay zero and still lands at
`window`, and no input path consults the entrance at all.

CROSS-CLIENT. These values are NOT in `console-vectors.json`, so no Rust, Android
or Apple test pins them — but Apple hand-copies the same two numbers in
`GamepadCarousel.swift`, in precisely the situation that shared file exists to
prevent. They are mirrored here so the desktop and Apple consoles do not silently
disagree about the same named gesture. Android has no card entrance at all.

Known and queued, not overlooked: the grid fans on the LINEAR cell index, so with
the new ratio its fan exhausts partway through the first row of a seven-wide grid
and everything after starts together. The fix belongs with the grid's own layout
work and is being made there.
2026-08-16 19:04:18 +02:00
enricobuehler 77834be4e4 feat(clients)!: retire the "Show game library" toggle — the library is simply there now
The console's Interface tab still offered an enable/disable switch for the game
library, left over from when the feature was experimental. Removing it turned out
to be the opposite of a one-line deletion.

The console never read the setting. Its row rendered a value and flipped a bool,
and every library affordance in the console — the home Y hint, the Y press, the
`--browse <host>` deep link — is gated on PAIRING and nothing else. So the row
showed "Game library · Off" while the same shell handed you the library on Y. The
docs already said as much.

What the flag actually gated was the other two desktop shells, GTK and WinUI,
which hide "Browse library…" unless it is on — and its stored default is `false`.
So deleting only the console row would have left every user who never found this
switch with a hidden library in the desktop apps, while removing the last place in
Gaming Mode where it could be turned on. The letter of the request, and the exact
opposite of its intent.

So the field goes, not just the row: `trust::Settings::library_enabled` and all
four reads of it. No migration code and no serde alias is needed, and that is by
construction rather than by luck — `Settings` is `#[serde(default)]` with a
`#[serde(flatten)] extra` map, so a stored `"library_enabled": false` parses into
`extra`, round-trips untouched, and is ignored. Everyone who had it off now has
the library, and a downgraded binary still finds its old value under the same key.
A test pins that contract, since it is the whole reason this is safe.

At the two GTK and two WinUI menu sites the flag is replaced by the PAIRING
predicate rather than dropped for an unconditional item. The library fetch
authenticates with the paired identity, and GTK's saved cards include trusted-but-
unpaired hosts, so an unconditional menu entry would promote a documented latent
bug — a fetch that cannot authenticate — into the default experience on every
install.

Apple and Android keep their own independent toggles for now, deliberately: both
already default TRUE, so nobody there loses anything by our not touching them, and
their removal is a follow-up rather than a rushed edit across two more UI
frameworks in this change.

VERIFICATION. The console and GTK legs are gate-green in the Linux container
(clippy `-D warnings`, a plain non-test build, and the test suites), and the GTK
leg needed that plain build: deleting the read orphaned the struct field that held
the settings store, which `--all-targets` hides and a shipping build raises.
The WinUI edits are READ-VERIFIED ONLY — `punktfunk-client-windows` is
`cfg(windows)` and compiles on neither the Mac nor the Linux container, so they
are unproven until a Windows runner sees them.
2026-08-16 19:03:53 +02:00
enricobuehler d2b86b103f fix(console): one value slip repainted every row's value, and long values escaped the row
Two reports against the settings screen, both landing in the same fifty lines of
`MenuList::render`: "the option fields all flicker when changing one option", and
"the select animation is broken in many ways, the biggest issue being text
flashing to the right of the field, completely outside — mainly with longer text".

THE FLICKER. A list has ONE slip spring and one `slip_prev` saying which row it
belongs to. The horizontal displacement correctly consults that identity; the
crossfade alpha derived from the same spring did not. So `gone` was computed from
the global spring once per row and applied to EVERY row's incoming value, blanking
the whole value column for a frame and fading it back over ~0.2 s. On a held
repeat the accumulate clamps past the crossfade's normaliser, so `gone` saturates
at 1.0 and every value sits at alpha 0 for several consecutive frames — the hard
flicker as reported. Labels were never touched, which is why it reads as the
values blinking while the labels stay put.

The existing test could not see this: it drives a ONE-row list, where the slipping
row is the only row, so an ungated alpha is indistinguishable from a gated one.
The new test uses a column of six.

THE ESCAPE. The value was placed from a single measured width shared by both the
outgoing and incoming strings, so a step between values of different lengths
teleported the outgoing text by the difference — and nothing clipped it to the
row, so a long value flew out past the right edge into open field. The field is
now fixed and each string is right-aligned by its OWN width, truncated before it
is placed rather than after, and the whole run is clipped to the row.

Three more defects in the same block, found while reading it and fixed here
because they are the same code:

- The slip armed on rows that cannot be adjusted at all. A/Confirm and a pointer
  press arm unconditionally, so pressing a non-steppable row fabricated a slide on
  a row that has no chevrons and nothing to step.
- Row identity was the index alone, which is not an identity across the per-frame
  rebuild the settings screen does — a list whose rows change shape could carry a
  slip onto whichever row inherited the index.
- A step that exactly cancels one in flight left the arming offset at zero, and
  the crossfade divides by it. That is a NaN alpha, and a NaN alpha draws nothing.
2026-08-16 19:03:30 +02:00
enricobuehler 68486b6ac8 fix(console): receded cards clamped to literal black, and the focus halo lit its neighbours
Reported from a Steam Deck: unfocused cards look "horrible — seemingly darkened,
and maybe even some sort of filter, so dark they have way too much contrast",
and "seemingly every card glows".

THE RECEDE. `recede_matrix` put its brightness term in the colour matrix's OFFSET
column, i.e. additive in sRGB-encoded space, and skia-safe's `matrix_row_major`
clamps. At RECEDE_BRIGHTNESS 0.24 that is -61/255 levels, so any channel below
0.24 clipped to zero. The coverflow's card face is a hardcoded #1E1E25, whose
post-desaturation channels are ~0.12 — every one of them lands negative. Sampling
the shipped screenshot confirms it: the interior of every side card reads exactly
(0,0,0), with a hard step from the aurora straight to black. Not a dim, a clamp.

Worse, `prox` saturates at 1.0, so cards two through five all receive the
identical treatment — the side stack was one black slab with no depth in it,
which is why it read as a filter rather than as distance.

The brightness term is now a multiplicative lerp toward the ground, so it cannot
clip at either pole and a receded poster keeps its full tonal range instead of
crushing everything below 61/255 flat. The palette-aware DIRECTION is unchanged —
receding still means "toward the field", which is down on a dark palette and up on
a pale one, and that was already right.

The veils were the other half. Both were hardcoded black while the matrix had
learned to respect the palette, so on the six pale palettes the veil spent its
alpha cancelling the lift the matrix had just applied — a grey cast over an
already-desaturated card, which is mud. They now wash toward `theme::shade`, which
is black on a dark field and white on a pale one, so all three mechanisms finally
point the same way. Their magnitudes come down as well: three stacked effects were
each tuned as though it were the only one.

THE HALO. It was never drawn for more than one card — all four call sites were
already correctly gated, and adding more gating would have been a no-op. The
"every card glows" reading came from its SHAPE: `BlurStyle::Normal` keeps the
source shape, so a 60 dp accent corona filled the focused card's own interior,
bled through the translucent panel glass, and spilled across the 58 px gap onto
both neighbours — which is precisely what "the neighbours are glowing" looks like.
`Outer` subtracts the source shape, so the halo is now only the light spilling
past the card's edge, and its reach fits inside the gap.

On a pale palette the accent halo and the black drop shadow were both simply the
wrong colour against a bright ground; the halo now mixes toward the scrim and the
shadow lightens, inside the helpers, so neither call site has to know which
palette is up.

Verified by re-deriving every predicted value: a fully receded dark card lands at
#151518 against a focused #1E1E25 — one clear step back, zero clipping — where it
used to land at #000000.
2026-08-16 19:03:09 +02:00
enricobuehler 3bfed02e02 fix(console): nothing was anti-aliased — SkPaint defaults it off, and cover art sampled nearest
Reported from a Steam Deck, where a 1280x800 panel gives sub-pixel error nowhere
to hide. Two independent causes, both API defaults rather than anything the
overhaul got wrong on purpose.

GEOMETRY. `SkPaint`'s default constructor sets `fAntiAlias = false`, and
skia-safe's `Paint::new(colour, None)` is that constructor with a colour on it.
So the terse, natural spelling of a draw call — `&Paint::new(c, None)` passed
inline as an argument — silently produces hard-stepped edges. The crate had 70
paint sites and 15 of them were anti-aliased, in an exact pattern: paints that
got MUTATED for some other reason (a stroke style, a width) picked up a
`set_anti_alias(true)` on the way past, and every inline argument did not. The
console therefore drew smooth 1 px rings on top of jagged fills, which looks
worse than no ring at all — the smooth edge gives the eye a reference for how
wrong the fill is. The padlock on a host tile had a stair-stepped body and a
clean shackle; the online pip was a blocky octagon inside a soft blurred glow.

Text was never affected — glyph anti-aliasing is `SkFont::Edging`, which
defaults to on — which is why the labels always looked right and only the shapes
looked chewed.

COVER ART. `Canvas::draw_image_rect` samples with `SamplingOptions::default()`,
which is `FilterMode::Nearest` and `MipmapMode::None` — no filtering at all.
Every poster in the library is minified hard, a 600x900 cover into a ~180x270
Deck cell, and nearest-neighbour minification simply discards rows and columns:
box-art lettering breaks up and edges crawl as the shelf scrolls. That reads as
"low resolution" whatever the panel is.

The fix is to take the choice away from the call site. `theme::fill`, `stroke`,
`shaded`, `shaded_stroke` and `layer` are now the only sanctioned constructors,
all 70 sites go through them, and `theme::art_sampling` (linear + linear
mipmaps) covers the three image draws.

`shaded` earns its place the hard way. Skia modulates a shader's output by the
PAINT'S alpha, so the obvious way to build a gradient paint — start transparent,
let the shader supply everything — draws nothing whatever. Not dimmer: absent.
`Paint::default()` happened to be opaque black, which is the only reason the
console's gradients and the SkSL aurora never met the rule; building them from a
transparent placeholder erased the backdrop, the badge, the vignette and the
skeleton sheen in one go, and all 124 tests still passed, because a test that
only renders a frame cannot tell a missing layer from a dark one. It was caught
by looking at the pixels. `shaded` is opaque by construction so the trap cannot
be set again.

Three tests, each pinning a different half:

- `geometry_is_anti_aliased` draws one circle on a half-pixel centre and counts
  partially-covered boundary pixels. Asserted on a shape rather than a screen
  because a full render is a poor witness — one jagged corner is a few dozen
  pixels in 1.02 M. Backed out, it reports exactly 0.
- `a_shaded_paint_is_opaque_enough_to_draw` covers the alpha rule above.
- `paints_are_built_by_the_theme_constructors` scans the crate's own source,
  because the aliased spelling is the NATURAL one and will be written again by
  whoever adds the next draw call. Its needles are built with `concat!` so the
  scan does not match itself, which it did on the first run.

All three were verified to fail with the fix backed out, not merely to pass.
2026-08-16 18:04:09 +02:00
31 changed files with 4933 additions and 896 deletions
@@ -288,8 +288,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// landing first and its neighbours fanning outward to either side.
private func entrance(_ idx: Int) -> CardEntrance {
// Capped so a several-hundred-title library never queues a card behind a visibly long
// wait everything past the cap lands together, well off-screen anyway.
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
// wait everything past the cap lands together, well off-screen anyway. The stagger and
// the cap move as a PAIR: their ratio is how many steps actually fan, so a wider offset
// under the same cap would land the outer half of the strip in one block.
// Mirrors `entrances::CARDS` in the desktop console's anim.rs nothing pins the two
// together, so a change here is a change there.
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.12)
return CardEntrance(
progress: entranceProgress,
start: delay / CardEntrance.total,
@@ -499,9 +503,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
/// unstaggered cross-fade.
struct CardEntrance: ViewModifier, Animatable {
/// How long ONE card takes to travel, and the most any card waits before it starts.
/// How long ONE card takes to travel, and the most any card waits before it starts. The
/// pair matches `entrances::CARDS` in the desktop console's anim.rs; `maxDelay` divided by
/// the per-step stagger in `entrance(_:)` is the number of cards that visibly fan, which is
/// why it moves whenever that stagger does.
static let perCard: Double = 0.6
static let maxDelay: Double = 0.42
static let maxDelay: Double = 0.6
/// The master timeline the carousel animates 0 1.
static var total: Double { perCard + maxDelay }
+3 -4
View File
@@ -27,10 +27,9 @@ Built in Rust end to end (no C ABI): the shell shares its plumbing with the sess
First connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on
a pinned identity.
- **Per-host speed test** to pick a bitrate, plus compositor and mode preferences in Settings.
- **Game library browser** *(experimental, off by default)* — "Browse library…" on a saved host
shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
Fetched from the host's management API over mTLS — paired devices are authorized by their
certificate, no extra host setup.
- **Game library browser** — "Browse library…" on a paired host shows its games (Steam + custom)
as a poster grid; click one to launch it in the session. Fetched from the host's management API
over mTLS — paired devices are authorized by their certificate, no extra host setup.
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven library view
of a paired host's games, rendered by the session binary's Skia console UI: A plays the focused
title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; session end
+12 -12
View File
@@ -75,7 +75,6 @@ enum CardKind {
host: KnownHost,
online: bool,
recent: bool,
library_enabled: bool,
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
/// refresh rather than re-read per card.
profiles: Rc<Vec<Profile>>,
@@ -293,7 +292,6 @@ impl relm4::factory::FactoryComponent for HostCard {
host: k,
online,
recent,
library_enabled,
profiles,
pinned,
} => {
@@ -470,7 +468,9 @@ impl relm4::factory::FactoryComponent for HostCard {
// START this card, not a property of the host, so it belongs to a shortcut
// as much as Connect does — and the card's request carries its profile, so
// what launches from that grid is this card's profile, not the binding.
if *library_enabled {
// Paired only: the fetch authenticates as this device, so on a merely
// trusted host it can only come back refused.
if k.paired {
launch.append(Some("Browse library\u{2026}"), Some("card.library"));
}
menu.append_section(None, &launch);
@@ -513,8 +513,10 @@ impl relm4::factory::FactoryComponent for HostCard {
}
let look = gio::Menu::new();
// Experimental (Preferences gate): browse the host's game library.
if *library_enabled {
// Browse the host's game library — offered on any paired host, but only a
// paired one: a saved card can be merely "Trusted" (the pill above), and
// the fetch authenticates as this device, so there it would only be refused.
if k.paired {
look.append(Some("Browse library\u{2026}"), Some("card.library"));
}
look.append(Some("Test network speed\u{2026}"), Some("card.speed"));
@@ -677,7 +679,6 @@ pub struct HostsPage {
/// [`saved_key`]. OR'd with live-advert presence to drive the Online pip.
probed: HashMap<String, bool>,
connecting: Option<String>,
settings: Rc<RefCell<Settings>>,
saved: FactoryVecDeque<HostCard>,
discovered: FactoryVecDeque<HostCard>,
widgets: PageWidgets,
@@ -701,7 +702,7 @@ pub enum HostsMsg {
AdvertRemoved {
fullname: String,
},
/// Reload the disk store and re-render (fresh pairings, renames, the library gate).
/// Reload the disk store and re-render (fresh pairings, renames).
Refresh,
/// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`],
/// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a
@@ -745,7 +746,10 @@ impl SimpleComponent for HostsPage {
}
fn init(
settings: Self::Init,
// The shared settings store, which this page no longer reads: its card menus follow
// pairing alone now that the library is offered on every paired host. It stays in
// `Init` because the shell hands the same store to every page it launches.
_settings: Self::Init,
page: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
@@ -941,7 +945,6 @@ impl SimpleComponent for HostsPage {
adverts: HashMap::new(),
probed: HashMap::new(),
connecting: None,
settings,
saved,
discovered,
widgets: PageWidgets {
@@ -1064,7 +1067,6 @@ impl HostsPage {
.filter_map(|h| h.last_used.map(|t| (h.fp_hex.clone(), t)))
.max_by_key(|&(_, t)| t)
.map(|(fp, _)| fp);
let library_enabled = self.settings.borrow().library_enabled;
// One catalog read per refresh, shared by every card's menus and chip.
let profiles: Rc<Vec<Profile>> = Rc::new(
pf_client_core::profiles::ProfilesFile::load()
@@ -1120,7 +1122,6 @@ impl HostsPage {
online,
profiles: profiles.clone(),
recent: most_recent.as_deref() == Some(k.fp_hex.as_str()),
library_enabled,
pinned: None,
},
});
@@ -1141,7 +1142,6 @@ impl HostsPage {
online,
profiles: profiles.clone(),
recent: false,
library_enabled,
pinned: Some((id, name)),
},
});
-15
View File
@@ -1394,13 +1394,6 @@ pub fn show_scoped(
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
&["Off", "Compact", "Normal", "Detailed"],
);
let library_row = adw::SwitchRow::builder()
.title("Show game library")
.subtitle(
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
and launch one directly. No extra host setup",
)
.build();
// ---- Input ----
let touch_row = ChoiceRow::new(
@@ -1720,7 +1713,6 @@ pub fn show_scoped(
echo_row.set_active(s.echo_cancel);
hdr_row.set_active(s.hdr_enabled);
chroma_row.set_active(s.enable_444);
library_row.set_active(s.library_enabled);
surround_row.set_selected(index::surround(s));
audio_format_row.set_selected(index::audio_format(s));
// `set_selected` never fires the changed hook, so mirror the stereo gate here — the same
@@ -2054,12 +2046,6 @@ pub fn show_scoped(
stats_group.add(stats_row.widget());
general.add(&session_group);
general.add(&stats_group);
// The library browser is an app-level toggle for this device, not a per-profile one.
if !profile_mode {
let library_group = group("Library", "");
library_group.add(&library_row);
general.add(&library_group);
}
let display = page("Display", "video-display-symbolic");
let resolution_group = group("Resolution", "");
@@ -2279,7 +2265,6 @@ pub fn show_scoped(
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
s.vsync = vsync_row.is_active();
s.allow_vrr = vrr_row.is_active();
s.library_enabled = library_row.is_active();
};
match &active {
+3 -1
View File
@@ -1,7 +1,9 @@
//! `--browse [host[:port]]` — the console shell. Bare `--browse` opens the host list
//! (discovery, pairing, settings, wake — the whole couch flow); with a target it opens
//! straight into that host's library (the Decky per-host launch), B backing out to the
//! list. A launches in the SAME window (no gamescope window handoff — the whole point
//! list — one press either way, because with "Start in collections" on it is the shelf
//! that hands over to the collections screen rather than a second screen being stacked on
//! it. A launches in the SAME window (no gamescope window handoff — the whole point
//! of one process), the session's end returns to the console, B at the root quits to
//! Gaming Mode.
//!
+4 -7
View File
@@ -559,8 +559,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
set: props.set_hover.clone(),
};
let known = KnownHosts::load();
// The experimental library gate ("Show game library" in Settings) — GTK/Apple parity.
let library_enabled = ctx.settings.lock().unwrap().library_enabled;
// Responsive column count from the live window width (re-renders on resize): as many
// TILE_MIN_WIDTH columns as fit the page's content width, at least one.
@@ -763,9 +761,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items.push(menu_separator());
// The library surfaces — mouse/KB page and the gamepad console UI — for
// paired hosts only (the mgmt API needs the paired identity); the page
// additionally sits behind the experimental toggle.
if library_enabled && k.paired {
// paired hosts only, because the mgmt API needs the paired identity.
if k.paired {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_SPEED));
@@ -956,8 +953,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.menu_flyout({
let mut items = Vec::new();
// Same gate as the primary tile's: the mgmt API needs the paired
// identity, and the page is behind the experimental toggle.
if library_enabled && k.paired {
// identity, so an unpaired host has nothing to show.
if k.paired {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_COPY_LINK));
+2 -3
View File
@@ -3,9 +3,8 @@
//! `ui_library.rs`, sharing its service layer (mTLS fetch against the host's management
//! API, pre-classified errors, the 3-worker art pipeline) and its four states
//! (loading / error+retry / empty / grid). Reached from a paired host's "…" menu
//! ("Browse library…", behind the Settings "Show game library" experimental toggle);
//! picking a title starts a normal stream carrying `--launch id` — the host launches the
//! app during the connect handshake.
//! ("Browse library…"); picking a title starts a normal stream carrying `--launch id` —
//! the host launches the app during the connect handshake.
//!
//! Poster bytes land in a small disk cache (`%LOCALAPPDATA%\punktfunk\art-cache`) and the
//! `Image` widget loads `file:///` URIs from it — reactor's `ImageSource` has no
+1 -1
View File
@@ -87,7 +87,7 @@ pub(crate) enum Screen {
/// Per-host network speed test (probe burst + recommended bitrate).
SpeedTest,
/// The target host's game library (poster grid; tap-to-launch) — paired hosts only,
/// behind the "Show game library" experimental toggle.
/// since the fetch authenticates with the pairing identity.
Library,
}
-18
View File
@@ -1127,9 +1127,6 @@ pub(crate) fn settings_page(
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
}
});
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
s.library_enabled = on
});
// App identity + version at the top of the About card (the WinUI Settings convention; the About
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
// in at compile time.
@@ -1658,21 +1655,6 @@ pub(crate) fn settings_page(
)],
None,
));
// The library browser is an app-level toggle for this device, not a per-profile one.
out.extend(group(
Some("Library"),
if profile_mode {
Vec::new()
} else {
vec![described_labeled(
"Show game library (experimental)",
library_toggle,
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
their Steam and custom games and launch one directly. No extra host setup.",
)]
},
None,
));
("General", out)
}
};
+30 -5
View File
@@ -1283,9 +1283,6 @@ pub struct Settings {
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
pub fullscreen_on_stream: bool,
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
/// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool,
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
@@ -1322,6 +1319,16 @@ pub struct Settings {
/// [`library_sort`](Self::library_sort).
#[serde(default)]
pub library_view: String,
/// Open a host's library on its COLLECTIONS — platforms and stores as tiles — instead of
/// the whole shelf. Presentation only, same rules as [`library_sort`](Self::library_sort).
///
/// Ignored by a library with fewer than two collections (see `pf-console-ui`'s
/// `collate::worth_browsing`): a screen that opens onto a single tile is a press the user
/// pays for nothing, so that library opens on its shelf whatever this says. Default off,
/// like every key in this family — an existing install must not have the screen its
/// deep links land on changed under it.
#[serde(default)]
pub library_collections: bool,
/// 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
@@ -1525,11 +1532,11 @@ impl Default for Settings {
show_stats: true,
stats_verbosity: None,
fullscreen_on_stream: true,
library_enabled: false,
ui_palette: default_ui_palette(),
reduce_motion: false,
library_sort: String::new(),
library_view: String::new(),
library_collections: false,
auto_wake: true,
invert_scroll: false,
speaker_device: String::new(),
@@ -1791,7 +1798,6 @@ mod tests {
// Fields the old file doesn't carry take this struct's defaults.
assert_eq!(s.forward_pad, "");
assert!(s.fullscreen_on_stream);
assert!(!s.library_enabled);
// Echo cancellation post-dates every stored file: it must load ON, or an upgrade
// would silently turn a user's echo protection off.
assert!(s.echo_cancel);
@@ -1819,6 +1825,25 @@ mod tests {
assert!(!plain.contains("frob"), "{plain}");
}
/// The same contract seen from the other side: a key this build RETIRED. `library_enabled`
/// gated "Browse library…" in the GTK and WinUI shells and defaulted off, so dropping the
/// field is what finally shows the library to everyone who never found the toggle. The
/// stored `false` must not fail the load — that would lock a user out of their whole
/// settings file over a setting that no longer exists — and it must survive the next
/// whole-file write, so a downgrade still reads the value it wrote.
#[test]
fn settings_retired_library_key_loads_and_survives() {
let stored = r#"{"width":1920,"height":1080,"library_enabled":false}"#;
let s: Settings = serde_json::from_str(stored).unwrap();
assert_eq!((s.width, s.height), (1920, 1080));
assert_eq!(
s.extra.get("library_enabled").and_then(|v| v.as_bool()),
Some(false)
);
let out = serde_json::to_string(&s).unwrap();
assert!(out.contains(r#""library_enabled":false"#), "{out}");
}
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
+61 -9
View File
@@ -154,7 +154,10 @@ pub(crate) struct EntranceSpec {
/// 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.
/// minute later; with it, everything past `cap / stagger` items away starts together
/// five or six, for the specs below. Which is why the two move as a pair: that ratio IS
/// the number of steps anyone ever sees fan, so raising `stagger` alone buys a wider
/// offset across fewer steps and lands the far half of a shelf in one block.
pub cap: f64,
}
@@ -162,17 +165,28 @@ pub(crate) mod entrances {
use super::EntranceSpec;
/// Carousel and coverflow cards — the loud one, and the reason this exists.
///
/// The stagger is measured against a card's VISIBLE life, not against `window`: the fade
/// is over at `FADE_SHARE` and [`super::ease_out_back`] is already at 0.89 by that same
/// point, so the last two thirds of the window is a crawl nobody can see. What is left is
/// ~0.2 s of readable action, and a neighbour starting a little past halfway through it is
/// what makes a strip arrive as a sequence rather than as one soft event. Judged against
/// the whole 0.6 s a stagger half this size looks generous; judged against the 0.2 s that
/// reads it is four frames, and since every surface here culls to a handful of items,
/// four frames is the whole event and not the gap between two of its steps.
pub(crate) const CARDS: EntranceSpec = EntranceSpec {
window: 0.6,
stagger: 0.07,
cap: 0.42,
stagger: 0.12,
cap: 0.6,
};
/// Menu rows. Same language, deliberately quieter: a settings list that fans open like
/// a shelf of box art is a settings list showing off.
/// a shelf of box art is a settings list showing off. Quieter still has to be countable,
/// though — under about three frames apart the rows read as one soft arrival rather than
/// as a ripple — so this is a shorter offset, not an absent one.
pub(crate) const ROWS: EntranceSpec = EntranceSpec {
window: 0.42,
stagger: 0.03,
cap: 0.24,
stagger: 0.055,
cap: 0.33,
};
}
@@ -318,9 +332,11 @@ mod tests {
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));
// The cap holds: past `cap / stagger` steps out — five, for CARDS — everything starts
// at once. This is what keeps a 400-title shelf from still arriving a minute later.
// Derived rather than spelled out, so re-tuning the pair re-aims the probe with it.
let beyond = (entrances::CARDS.cap / entrances::CARDS.stagger).ceil() as usize + 1;
assert_eq!(e.at(5 + beyond, 0.3), e.at(5 + 250, 0.3));
// Monotone once started, and never outside 0..=1.
let mut last = 0.0;
@@ -341,6 +357,42 @@ mod tests {
}
}
/// Whether the strip reads as a SEQUENCE, which none of `entrance_envelope`'s shape
/// properties can see: a spec with `stagger` at zero satisfies every one of them and
/// arrives as a single soft event. Three numbers decide it, all derived from the specs
/// so that a re-tune which quietly undoes the fan fails here rather than on a couch.
#[test]
fn entrance_stagger_reads_as_a_sequence() {
// A neighbour must still be visibly behind while the anchor is halfway through its
// FADE — `window` flatters the stagger badly, because an item is perceptually done a
// third of the way through it, so this is measured against the part that reads.
let separation = |spec: EntranceSpec| {
let e = Entrance::new(spec, 5, 0.0);
let t_mid = 0.5 * FADE_SHARE * spec.window;
e.at(5, t_mid).fade - e.at(6, t_mid).fade
};
let cards = separation(entrances::CARDS);
assert!(cards > 0.7, "CARDS neighbours arrive together: {cards}");
let rows = separation(entrances::ROWS);
assert!(rows > 0.5, "ROWS is quieter, not staggerless: {rows}");
for (name, spec, budget) in [
("CARDS", entrances::CARDS, 1.25),
("ROWS", entrances::ROWS, 0.8),
] {
// `cap / stagger` is how many steps ever fan, and every surface culls to a
// handful of items — the coverflow shows five. Let this drop and a wider
// `stagger` buys a bigger offset across fewer steps, which is worse, not better.
let steps = spec.cap / spec.stagger;
assert!(steps >= 4.0, "{name} fans only {steps} steps");
// The other end: "too long" should be a failing test rather than an opinion. The
// item under the cursor is untouched by both — its delay is 0 — so this budget
// buys peripheral polish and never makes anyone wait.
let total = spec.cap + spec.window;
assert!(total <= budget, "{name} takes {total} s to retire");
}
}
/// 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]
+19 -32
View File
@@ -5,9 +5,9 @@
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
//! drivable either way.
use crate::theme::{fg, Fonts, W};
use crate::theme::{fg, fill, stroke, Fonts, W};
use punktfunk_core::config::GamepadPref;
use skia_safe::{Canvas, Paint, PathBuilder, Point, RRect, Rect};
use skia_safe::{Canvas, PathBuilder, Point, RRect, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlyphStyle {
@@ -43,8 +43,7 @@ pub(crate) fn pad_mark(
k: f64,
ink: skia_safe::Color4f,
) {
let mut p = Paint::new(ink, None);
p.set_anti_alias(true);
let mut p = fill(ink);
if style == GlyphStyle::Keyboard {
// A keycap: the same shape the hint bar draws for a key, at chip size.
let h = w * 0.72;
@@ -96,10 +95,7 @@ pub(crate) fn battery_pip(
} 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 outline = stroke(ink, (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.
@@ -114,7 +110,7 @@ pub(crate) fn battery_pip(
r,
r,
),
&Paint::new(ink, None),
&fill(ink),
);
// 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.
@@ -132,7 +128,7 @@ pub(crate) fn battery_pip(
seg_w - 0.8 * k as f32,
cell.height() - 2.0 * pad,
),
&Paint::new(ink, None),
&fill(ink),
);
}
}
@@ -148,7 +144,9 @@ pub(crate) enum HintKey {
Shoulders,
/// ◀ ▶ — left/right adjusts the focused value.
Adjust,
/// ▲ — up opens the focused item's own menu.
/// ▲ — up raises the focused item's context menu, on a screen with up to spare. Where
/// there isn't (the library grid spends up on rows) the same menu hangs off
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
Up,
Key(&'static str),
}
@@ -221,7 +219,7 @@ pub(crate) fn hint_bar(
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),
&fill(crate::theme::shade(0.30)),
);
crate::theme::panel(
canvas,
@@ -348,12 +346,8 @@ fn draw_glyph(
Resolved::Badge(face) => {
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
canvas.draw_circle(center, r as f32, &Paint::new(fg(0.10), None));
let mut ring = Paint::new(fg(0.32), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width((1.2 * k) as f32);
ring.set_anti_alias(true);
canvas.draw_circle(center, r as f32, &ring);
canvas.draw_circle(center, r as f32, &fill(fg(0.10)));
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
if style == GlyphStyle::Shapes {
draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32);
} else {
@@ -384,7 +378,7 @@ fn draw_glyph(
let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32),
&Paint::new(fg(0.10), None),
&fill(fg(0.10)),
);
let size = 10.0 * k;
let tw = fonts.measure(label, W::SemiBold, size) as f64;
@@ -410,7 +404,7 @@ fn draw_glyph(
up.line_to((cx - tw, cyf + th));
up.line_to((cx + tw, cyf + th));
up.close();
canvas.draw_path(&up.detach(), &Paint::new(fg(0.85), None));
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
}
Resolved::Adjust => {
// ◀ ▶ — two small solid triangles.
@@ -418,7 +412,7 @@ fn draw_glyph(
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
let gap = (2.6 * k) as f32;
let paint = Paint::new(fg(0.85), None);
let paint = fill(fg(0.85));
let mut left = PathBuilder::new();
left.move_to((cx - gap, cyf - th));
left.line_to((cx - gap - tw, cyf));
@@ -438,15 +432,11 @@ fn draw_glyph(
let rect = 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(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&Paint::new(fg(0.10), None),
&fill(fg(0.10)),
);
let mut ring = Paint::new(fg(0.28), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&ring,
&stroke(fg(0.28), 1.0),
);
let size = 11.0 * k;
let tw = fonts.measure(text, W::SemiBold, size) as f64;
@@ -465,12 +455,9 @@ fn draw_glyph(
/// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position
/// =□, Y-position=△ (the DualSense's physical layout).
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) {
let mut p = Paint::new(fg(0.92), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(stroke);
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, width: f32) {
let mut p = stroke(fg(0.92), width);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let (cx, cy) = (center.x, center.y);
match face {
Face::A => {
+494 -76
View File
@@ -26,11 +26,16 @@ 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). 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;
/// The recede veil's max opacity (side cards stay opaque — they overlap). Cut twice: first
/// when the colour recede landed and this stopped having to carry the whole "further away"
/// reading on its own, and again when the three stacked mechanisms turned out to be summing
/// to literal black on a receded card. It is left doing the one job a flat wash is good at —
/// separating cards that overlap. See `theme::recede_matrix`.
///
/// No longer necessarily a DARKENING: the call site washes toward `theme::shade`, which is
/// black on a dark palette and white on a pale one, so this reinforces the matrix's direction
/// at both poles instead of greying out the lift on the six pale ones.
pub const RECEDE_DIM: f64 = 0.10;
/// 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
@@ -162,34 +167,166 @@ pub enum GridDir {
/// How many rows a shoulder press jumps.
pub const GRID_PAGE_ROWS: i32 = 3;
/// Cursor arithmetic for a 2-D grid, `cols` wide.
/// The grid's layout, in the one place both the cursor arithmetic and the renderer read it.
///
/// 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.
/// A field of covers is not a uniform grid: the launcher prefix (design D4) is given rows of
/// its own and the games section restarts at column 0 underneath it. While navigation did
/// that sum for itself — index modulo `cols` — the two models agreed only when the launcher
/// count happened to be a multiple of the column count, which with a Deck's seven columns and
/// the usual two launchers means never. Down out of a launcher landed five columns to the
/// right of the tile it sat under, Up out of the games band slid sideways instead of leaving
/// it, and a row end refused mid-row. Sharing the shape is the fix; the arithmetic below is
/// only its consequence.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GridShape {
/// Cells per row. A RENDER fact — it depends on the window — so the shape is built from
/// what the last frame actually drew rather than derived twice from two widths.
pub cols: usize,
/// How many cells there are: the FILTERED count, the one the cursor indexes.
pub len: usize,
/// Where the games section starts, or 0 when the field is one continuous run.
pub split: usize,
}
impl GridShape {
/// `launchers` is the leading launcher run. The section only exists when BOTH halves do —
/// an all-launcher or launcher-less field is a plain grid, and giving it a heading band
/// and a gap it has no second group for would be a rule showing off.
pub fn new(len: usize, cols: usize, launchers: usize) -> GridShape {
let split = if launchers > 0 && launchers < len {
launchers
} else {
0
};
GridShape { cols, len, split }
}
/// The first row of the games section (meaningless when there is no split).
pub fn split_row(&self) -> usize {
self.split.div_ceil(self.cols.max(1))
}
/// Which cell an index is drawn in.
pub fn cell_of(&self, i: usize) -> (usize, usize) {
let cols = self.cols.max(1);
if self.split > 0 && i >= self.split {
let j = i - self.split;
(self.split_row() + j / cols, j % cols)
} else {
(i / cols, i % cols)
}
}
pub fn rows(&self) -> usize {
let cols = self.cols.max(1);
if self.split > 0 {
self.split_row() + (self.len - self.split).div_ceil(cols)
} else {
self.len.div_ceil(cols)
}
}
/// The index of a row's first cell.
pub fn row_start(&self, row: usize) -> usize {
let cols = self.cols.max(1);
if self.split > 0 && row >= self.split_row() {
self.split + (row - self.split_row()) * cols
} else {
row * cols
}
}
/// How many cells a row actually holds — the launcher section's last row stops where the
/// games section begins, and the field's last row stops at the end of the library.
pub fn row_len(&self, row: usize) -> usize {
let start = self.row_start(row);
let end = if self.split > 0 && row + 1 == self.split_row() {
self.split
} else {
self.len
};
end.saturating_sub(start).min(self.cols.max(1))
}
}
/// Cursor arithmetic for the grid, against the shape the renderer is drawing.
///
/// 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 {
/// ONE rule, because an accretion of special cases is how this broke: horizontal moves walk
/// the row and refuse at THAT ROW's true ends; vertical moves and pages change row only,
/// carrying `col_hint` and clamping it into the target row's length. The only boundary is a
/// move that would leave the grid.
///
/// Left/right refusing rather than wrapping is the shelf's rule and the one a thumb already
/// knows — a held Right that wrapped would scan the whole library, and there are shoulders
/// for that. Vertical moves clamp instead of refusing because a short row is a layout
/// accident, not a boundary anyone chose to hit: Down from above the gap should land on the
/// last title, not thud.
///
/// `col_hint` is the column the user last CHOSE (see [`grid_col_hint`]) rather than the one
/// they happen to be standing in, so crossing a two-wide launcher row and coming back returns
/// to the column the crossing started from.
pub fn grid_step(cursor: i32, shape: GridShape, col_hint: usize, dir: GridDir) -> StepResult {
if shape.len == 0 || shape.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),
// A cursor outside the field is a stale one (the library shortened under us); reading it
// as the nearest real cell makes the next press heal it instead of compounding it.
let (row, col) = shape.cell_of((cursor.max(0) as usize).min(shape.len - 1));
let moved = |i: usize| {
if i as i32 == cursor {
StepResult::Boundary
} else {
StepResult::Moved(i as i32)
}
};
if target == cursor || target < 0 || target > max {
StepResult::Boundary
} else {
StepResult::Moved(target)
match dir {
GridDir::Left => {
if col == 0 {
StepResult::Boundary
} else {
moved(shape.row_start(row) + col - 1)
}
}
GridDir::Right => {
if col + 1 >= shape.row_len(row) {
StepResult::Boundary
} else {
moved(shape.row_start(row) + col + 1)
}
}
GridDir::Up | GridDir::Down | GridDir::PageBack | GridDir::PageForward => {
let (d, paging) = match dir {
GridDir::Up => (-1, false),
GridDir::Down => (1, false),
GridDir::PageBack => (-GRID_PAGE_ROWS, true),
_ => (GRID_PAGE_ROWS, true),
};
let target = (row as i32 + d).clamp(0, shape.rows() as i32 - 1) as usize;
if target == row {
// A STEP at the edge refuses. A PAGE is a "take me there", so it lands on the
// end of the row it is already on — the same reading `step_cursor`'s clamped
// mode has, and the one the shoulders have always had here.
if !paging {
return StepResult::Boundary;
}
let c = if d > 0 { shape.row_len(row) - 1 } else { 0 };
return moved(shape.row_start(row) + c);
}
let c = col_hint.min(shape.row_len(target) - 1);
moved(shape.row_start(target) + c)
}
}
}
/// The remembered column after a move.
///
/// A horizontal step CHOOSES a column; a vertical one only borrows it. Keeping the rule here
/// rather than at the call site is what stops the screen from holding a cursor and a column
/// that disagree — the same reason the layout itself is one shared shape.
pub fn grid_col_hint(shape: GridShape, prev: usize, dir: GridDir, landed: i32) -> usize {
match dir {
GridDir::Left | GridDir::Right => shape.cell_of(landed.max(0) as usize).1,
_ => prev,
}
}
@@ -747,8 +884,43 @@ impl LibraryShared {
(s.phase.clone(), s.games.clone(), s.generation)
}
pub(crate) fn drain_art(&self) -> Vec<(String, Vec<u8>)> {
self.0.lock().unwrap().art_in.drain(..).collect()
/// Take at most `max` newly fetched posters, leaving the rest queued.
///
/// Bounded because the renderer DECODES what this hands it, on the render thread. A
/// library whose art all lands at once — the fake-library dev hook reads it off local
/// disk, and a warm host proxy is nearly as fast — would otherwise put two hundred JPEG
/// decodes in one frame. What stays behind costs the queue its ENCODED bytes, two orders
/// of magnitude smaller than the raster they become.
pub(crate) fn drain_art(&self, max: usize) -> Vec<(String, Vec<u8>)> {
let mut s = self.0.lock().unwrap();
let n = max.min(s.art_in.len());
s.art_in.drain(..n).collect()
}
/// Take at most `max` queued posters FROM `want`, leaving every other one where it is.
///
/// The collections screen is the caller, and it is the only screen that draws a handful
/// of named covers rather than whatever arrives. Draining the queue wholesale there
/// would be a quiet disaster: the bytes are pushed once per fetch and never re-sent, so
/// everything it took and could not fan would be gone before the shelf a tile opens ever
/// asked — a library of monograms, one screen further in. This takes the dozen covers
/// that tile the collections and leaves the other four hundred queued for the shelf.
pub(crate) fn take_art_for(
&self,
want: &std::collections::HashSet<String>,
max: usize,
) -> Vec<(String, Vec<u8>)> {
let mut s = self.0.lock().unwrap();
let mut out = Vec::new();
let mut i = 0;
while i < s.art_in.len() && out.len() < max {
if want.contains(&s.art_in[i].0) {
out.extend(s.art_in.remove(i));
} else {
i += 1;
}
}
out
}
}
@@ -858,6 +1030,72 @@ mod tests {
}
}
/// The art queue hands the renderer a BOUNDED batch and keeps the rest, in arrival
/// order. The renderer decodes what it takes, on the render thread, so an unbounded
/// drain is a frame as long as the library is big — and the first frame after a fast
/// host answered is exactly when the whole library lands at once.
#[test]
fn art_drains_in_bounded_batches_and_keeps_the_order() {
let shared = LibraryShared::default();
for i in 0..5 {
shared.push_art(format!("g{i}"), vec![i as u8]);
}
let first: Vec<String> = shared.drain_art(2).into_iter().map(|(id, _)| id).collect();
assert_eq!(first, ["g0", "g1"]);
let rest: Vec<String> = shared.drain_art(9).into_iter().map(|(id, _)| id).collect();
assert_eq!(
rest,
["g2", "g3", "g4"],
"asking for more than is there is fine"
);
assert!(shared.drain_art(2).is_empty());
}
/// A selective take is the collections screen's whole art story: it takes the few covers
/// it fans and LEAVES everything else queued, in order, for the shelf that opens next.
/// The property is what stays behind — the poster bytes are pushed once per fetch and
/// never re-sent, so anything taken by a screen that cannot draw it is lost for good.
#[test]
fn a_selective_take_leaves_everything_it_did_not_ask_for() {
let shared = LibraryShared::default();
for i in 0..6 {
shared.push_art(format!("g{i}"), vec![i as u8]);
}
let want = ["g1".to_string(), "g4".to_string(), "g9".to_string()]
.into_iter()
.collect();
let took: Vec<String> = shared
.take_art_for(&want, 8)
.into_iter()
.map(|(id, _)| id)
.collect();
assert_eq!(
took,
["g1", "g4"],
"an id that never arrived is not an error"
);
let rest: Vec<String> = shared.drain_art(9).into_iter().map(|(id, _)| id).collect();
assert_eq!(rest, ["g0", "g2", "g3", "g5"], "the rest is untouched");
}
/// …and it is bounded the same way the plain drain is: the caller DECODES what it takes,
/// on the render thread, so a library that lands all at once must not become one frame.
#[test]
fn a_selective_take_is_bounded_too() {
let shared = LibraryShared::default();
for i in 0..6 {
shared.push_art(format!("g{i}"), vec![i as u8]);
}
let want: std::collections::HashSet<String> = (0..6).map(|i| format!("g{i}")).collect();
assert_eq!(shared.take_art_for(&want, 2).len(), 2);
assert_eq!(shared.take_art_for(&want, 2).len(), 2);
assert_eq!(
shared.take_art_for(&want, 9).len(),
2,
"and then it is empty"
);
}
/// The GTK launcher's cursor tests, ported with the math.
#[test]
fn step_refuses_the_ends() {
@@ -867,66 +1105,235 @@ 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.
/// Every shape the grid can take: the launcher-less field, a Deck's seven columns with
/// the usual two launchers, a launcher run that fills a row and a half, the degenerate
/// two-cell sections, and a single column.
const SHAPES: [(usize, usize, usize); 9] = [
(11, 4, 0),
(40, 5, 0),
(30, 7, 2),
(20, 4, 6),
(4, 4, 2),
(9, 3, 3),
(7, 3, 7),
(1, 3, 1),
(13, 1, 2),
];
/// The invariant the two old layout models broke: a cell's coordinates and its row's
/// start have to be the same statement. Rows tile `0..len` in order, no index is in two
/// of them, and none is in none — which is what makes "the ring is where the scroll
/// says it is" true by construction rather than by coincidence.
#[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.
fn grid_rows_tile_the_field_exactly_once() {
for (len, cols, launchers) in SHAPES {
let s = GridShape::new(len, cols, launchers);
let mut next = 0usize;
for row in 0..s.rows() {
let n = s.row_len(row);
assert!((1..=cols).contains(&n), "{s:?} row {row} holds {n} cells");
for col in 0..n {
let i = s.row_start(row) + col;
assert_eq!(i, next, "{s:?} row {row} does not follow the one above");
assert_eq!(s.cell_of(i), (row, col), "{s:?} disagrees about index {i}");
next += 1;
}
}
assert_eq!(next, len, "{s:?} left cells in no row at all");
}
}
/// The grid's two different boundary rules, which is the whole subtlety of the
/// horizontal step: a row END refuses (like the shelf), and it is the row's TRUE end —
/// not `cols`, which is a different number in every partial row and in the whole games
/// section under a launcher prefix.
#[test]
fn grid_horizontal_moves_walk_the_row_and_refuse_its_true_ends() {
for (len, cols, launchers) in SHAPES {
let s = GridShape::new(len, cols, launchers);
for i in 0..len {
let (row, col) = s.cell_of(i);
let want = |first: bool, to: i32| {
if first {
StepResult::Boundary
} else {
StepResult::Moved(to)
}
};
let i = i as i32;
// The hint must not reach a horizontal move: it is the column you WOULD
// return to, not the one you are walking out of.
for hint in 0..cols {
assert_eq!(
grid_step(i, s, hint, GridDir::Left),
want(col == 0, i - 1),
"{s:?} Left from {i}"
);
assert_eq!(
grid_step(i, s, hint, GridDir::Right),
want(col + 1 == s.row_len(row), i + 1),
"{s:?} Right from {i}"
);
}
}
}
}
/// Vertical moves change the ROW and nothing else. A move that has a row to go to always
/// goes there — never sideways within the row it is already in, which is what crossing
/// the launcher/games boundary used to do — and it arrives in the remembered column,
/// clamped into whatever that row can hold.
#[test]
fn grid_vertical_moves_change_row_and_carry_the_column() {
const VERTICAL: [(GridDir, i32); 4] = [
(GridDir::Up, -1),
(GridDir::Down, 1),
(GridDir::PageBack, -GRID_PAGE_ROWS),
(GridDir::PageForward, GRID_PAGE_ROWS),
];
for (len, cols, launchers) in SHAPES {
let s = GridShape::new(len, cols, launchers);
for i in 0..len {
let (row, _) = s.cell_of(i);
for hint in 0..cols {
for (dir, d) in VERTICAL {
let want_row = (row as i32 + d).clamp(0, s.rows() as i32 - 1) as usize;
let what = format!("{s:?} {dir:?} from {i} with hint {hint}");
match grid_step(i as i32, s, hint, dir) {
StepResult::Moved(to) => {
let (r, c) = s.cell_of(to as usize);
assert_eq!(r, want_row, "{what} landed in row {r}");
if r != row {
assert_eq!(c, hint.min(s.row_len(r) - 1), "{what} column");
}
}
// Only the field's own edges refuse; a page already at the edge
// still travels along the row it is on, so it refuses only from
// that row's end.
StepResult::Boundary => assert_eq!(want_row, row, "{what} refused"),
}
}
}
}
}
}
/// Both sections stay reachable from anywhere in the other, in a bounded number of
/// presses. This is the user's actual complaint — a launcher row you could see but not
/// get back into — stated as a property.
#[test]
fn every_row_is_reachable_by_stepping() {
for (len, cols, launchers) in SHAPES {
let s = GridShape::new(len, cols, launchers);
for i in 0..len {
for (dir, end) in [(GridDir::Up, 0), (GridDir::Down, s.rows() - 1)] {
let mut cursor = i as i32;
for _ in 0..=s.rows() {
match grid_step(cursor, s, 0, dir) {
StepResult::Moved(to) => cursor = to,
StepResult::Boundary => break,
}
}
let (row, _) = s.cell_of(cursor as usize);
assert_eq!(row, end, "{s:?} {dir:?} from {i} stalled in row {row}");
}
}
}
}
/// The Deck, exactly: 1280×800 gives seven columns, and a host with the usual two
/// launchers puts them alone on row 0 with the games restarting at column 0 under them.
/// Every value here is one the uniform-grid arithmetic got wrong.
#[test]
fn the_launcher_row_sits_squarely_above_the_games() {
let s = GridShape::new(30, 7, 2);
assert_eq!(s.rows(), 5);
assert_eq!((s.row_len(0), s.row_len(1)), (2, 7));
// Down from a launcher lands on the cover UNDER it, not five columns to the right.
assert_eq!(grid_step(0, s, 0, GridDir::Down), StepResult::Moved(2));
assert_eq!(grid_step(1, s, 1, GridDir::Down), StepResult::Moved(3));
// …and Up out of the games band leaves it, rather than sliding along it.
assert_eq!(grid_step(2, s, 0, GridDir::Up), StepResult::Moved(0));
assert_eq!(grid_step(3, s, 1, GridDir::Up), StepResult::Moved(1));
assert_eq!(grid_step(6, s, 4, GridDir::Up), StepResult::Moved(1));
// The games row's true ends are 2 and 8 — 6 and 7 are mid-row, and 8 is the end.
assert_eq!(grid_step(6, s, 4, GridDir::Right), StepResult::Moved(7));
assert_eq!(grid_step(7, s, 5, GridDir::Left), StepResult::Moved(6));
assert_eq!(grid_step(8, s, 6, GridDir::Right), StepResult::Boundary);
assert_eq!(grid_step(2, s, 0, GridDir::Left), StepResult::Boundary);
}
/// The remembered column is what makes a crossing reversible: stepping down through a
/// two-wide launcher row and back must return to the column you set out from, not pin
/// you to the column the narrow row could hold.
#[test]
fn a_crossing_returns_to_the_column_it_started_from() {
use GridDir::{Down, Right, Up};
let s = GridShape::new(30, 7, 2);
// The screen's own rule, in one place: `LibraryScreen::grid_move` steps the cursor
// and re-reads the hint through exactly these two calls.
let walk = |start: i32, dirs: &[GridDir]| {
let (mut cursor, mut hint) = (start, s.cell_of(start.max(0) as usize).1);
for &dir in dirs {
if let StepResult::Moved(to) = grid_step(cursor, s, hint, dir) {
hint = grid_col_hint(s, hint, dir, to);
cursor = to;
}
}
cursor
};
assert_eq!(walk(0, &[Down, Right, Right, Right, Right]), 6);
// Up parks in the launcher row's only reachable column…
assert_eq!(walk(0, &[Down, Right, Right, Right, Right, Up]), 1);
// …and Down restores the column, twice over — a vertical move never spends it.
assert_eq!(walk(0, &[Down, Right, Right, Right, Right, Up, Down]), 6);
assert_eq!(
grid_step(1, len, cols, GridDir::Right),
StepResult::Moved(2)
walk(0, &[Down, Right, Right, Right, Right, Up, Down, Up, Down]),
6
);
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);
let s = GridShape::new(40, 5, 0);
assert_eq!(
grid_step(0, len, cols, GridDir::PageForward),
grid_step(0, s, 0, 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),
grid_step(35, s, 0, 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),
grid_step(39, s, 4, GridDir::PageForward),
StepResult::Boundary
);
assert_eq!(grid_step(3, s, 3, GridDir::PageBack), StepResult::Moved(0));
assert_eq!(grid_step(0, s, 0, GridDir::PageBack), StepResult::Boundary);
}
/// The launcher-less grid, unchanged: this is the field the old arithmetic got right,
/// and the proof that sharing the shape did not move it.
#[test]
fn grid_rows_refuse_at_their_ends_but_the_tail_row_clamps() {
// 11 items, 4 columns: rows of 4, 4, 3.
let s = GridShape::new(11, 4, 0);
assert_eq!(grid_step(1, s, 1, GridDir::Right), StepResult::Moved(2));
assert_eq!(grid_step(2, s, 2, GridDir::Left), StepResult::Moved(1));
// At a row's ends: refused, NOT wrapped onto the neighbouring row.
assert_eq!(grid_step(3, s, 3, GridDir::Right), StepResult::Boundary);
assert_eq!(grid_step(4, s, 0, GridDir::Left), StepResult::Boundary);
// Down from the top row lands directly below.
assert_eq!(grid_step(1, s, 1, GridDir::Down), StepResult::Moved(5));
// Down into the SHORT tail row clamps to the last item rather than thudding —
// column 3 does not exist down there.
assert_eq!(grid_step(7, s, 3, GridDir::Down), StepResult::Moved(10));
// …and once there, Down really is the end.
assert_eq!(grid_step(10, s, 3, GridDir::Down), StepResult::Boundary);
assert_eq!(grid_step(2, s, 2, GridDir::Up), StepResult::Boundary);
assert_eq!(grid_step(6, s, 2, GridDir::Up), StepResult::Moved(2));
}
/// The persisted view name is a FILE FORMAT, and an unknown one must land on the shelf
@@ -945,11 +1352,22 @@ mod tests {
#[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);
let empty = GridShape::new(0, 4, 0);
assert_eq!(grid_step(0, empty, 0, GridDir::Right), StepResult::Boundary);
let colless = GridShape::new(5, 0, 0);
assert_eq!(
grid_step(0, colless, 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));
let thin = GridShape::new(5, 1, 0);
assert_eq!(grid_step(1, thin, 0, GridDir::Right), StepResult::Boundary);
assert_eq!(grid_step(1, thin, 0, GridDir::Down), StepResult::Moved(2));
// A cursor the library outgrew reads as the nearest real cell, so the next press
// heals it rather than compounding it.
let s = GridShape::new(6, 3, 2);
assert_eq!(grid_step(99, s, 0, GridDir::Up), StepResult::Moved(2));
assert_eq!(grid_step(-4, s, 0, GridDir::Right), StepResult::Moved(1));
}
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
+30 -4
View File
@@ -6,12 +6,25 @@
pub(crate) mod add_host;
pub(crate) mod collections;
pub(crate) mod home;
pub(crate) mod host_options;
pub(crate) mod library;
pub(crate) mod options;
pub(crate) mod pair;
pub(crate) mod pin_hosts;
pub(crate) mod settings;
/// The context menu under the name the home carousel still opens it by. Home predates the
/// generalisation and spells both the module and the type "host options"; it is the same
/// screen, and this goes the moment that call site says [`options::OptionsScreen::for_host`].
pub(crate) mod host_options {
pub(crate) use super::options::OptionsScreen as HostOptionsScreen;
impl HostOptionsScreen {
pub(crate) fn new(host: &crate::model::HostRow) -> HostOptionsScreen {
HostOptionsScreen::for_host(host)
}
}
}
use crate::glyphs::Hint;
use crate::library::LibraryShared;
use crate::model::{ConsoleCmd, HostRow};
@@ -102,6 +115,18 @@ impl Outbox {
pub(crate) fn replace(&mut self, screen: Screen) {
self.nav = Some(Nav::Replace(Box::new(screen)));
}
/// Raise the context menu on what the screen is focused on — the console's one door for
/// per-item actions. The screen names the SUBJECT
/// ([`options::OptionsScreen::for_host`], [`options::OptionsScreen::for_game`]) and the
/// menu owns the verbs, so the next action is a row there rather than another button in a
/// legend that already holds six.
///
/// Two callers: the home carousel's ▲, and the library's X. Both hand it a subject and
/// neither names the screen variant, which is the point of the door.
pub(crate) fn options(&mut self, menu: options::OptionsScreen) {
self.push(Screen::HostOptions(menu));
}
}
/// A saved host's `punktfunk://` link, built from the STORE so it carries the fingerprint
@@ -145,9 +170,10 @@ pub(crate) enum Screen {
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
PinHosts(pin_hosts::PinHostsScreen),
/// A saved host's own actions (Wake / Copy link / Edit / Forget) — the console's
/// answer to the touch clients' host-card overflow menu.
HostOptions(host_options::HostOptionsScreen),
/// The context menu: a subject and the actions that apply to it — a host's Wake / Copy
/// link / Edit / Forget, a title's Copy link — raised by [`Outbox::options`]. It still
/// carries the host menu's name because [`host_options`] does; both are one rename.
HostOptions(options::OptionsScreen),
}
impl Screen {
+9 -6
View File
@@ -8,8 +8,8 @@ use crate::glyphs::{Hint, HintKey};
use crate::model::{ConsoleCmd, HostRow};
use crate::pointer::Pointer;
use crate::screens::{Ctx, Outbox};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use crate::theme::{fg, Fonts, EDGE_INSET, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, ROW_MAX_W};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -306,16 +306,19 @@ impl AddHostScreen {
fonts: &Fonts,
ctx: &mut Ctx,
) {
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
fonts.centered(
// Half of the heading's block, so it sits on the heading's column — a centred
// sub-line under a leading title reads as belonging to the rows instead. Its width
// is capped against the ROW column, not the screen: measured off the full width it
// would run all the way under the controller chip.
fonts.leading(
canvas,
"Hosts on this network appear automatically — add one by address for everything else.",
W::Regular,
13.0 * k,
fg(0.55),
cx,
f64::from(rect.left) + EDGE_INSET * k,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
ROW_MAX_W * 0.72 * k,
);
// While the keyboard tray is up (never on Deck) the rows squeeze above it.
File diff suppressed because it is too large Load Diff
+54 -46
View File
@@ -13,9 +13,9 @@ use crate::library::{
use crate::model::{ConsoleCmd, HostRow};
use crate::pointer::{Pointer, PointerKind};
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
use crate::theme::{accent, fg, fill, stroke, Fonts, PanelStroke, ONLINE_GREEN, W};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, PathBuilder, Point, RRect, Rect};
use skia_safe::{Canvas, Color4f, MaskFilter, PathBuilder, Point, RRect, Rect};
const TILE_W: f64 = 340.0;
const TILE_H: f64 = 224.0;
@@ -363,16 +363,32 @@ impl HomeScreen {
canvas.translate((-cx as f32, -cy 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.
//
// BOUNDED, and raised only when it has something to carry. An unbounded
// `save_layer` allocates an offscreen the size of the whole SURFACE and composites
// it back, so the strip was paying several full-screen offscreens a frame — one of
// them for the focused tile, whose alpha is 1 and whose recede is 0, i.e. a layer
// that does nothing at all. The bounds are the tile grown by the reach of what is
// drawn INSIDE the layer: the halo (outset 4 k, sigma 10 k) and the shadow's 10 k
// drop. Bound it to the bare tile instead and the layer would clip both away.
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,
));
let layered = alpha < 0.999 || recede > 0.001;
if layered {
let mut lp = crate::theme::layer();
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,
));
}
let bounds = tile.with_outset(((36.0 * k) as f32, (36.0 * k) as f32));
canvas.save_layer(
&skia_safe::canvas::SaveLayerRec::default()
.bounds(&bounds)
.paint(&lp),
);
}
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);
@@ -390,18 +406,22 @@ impl HomeScreen {
Slot::AddHost => draw_action_tile(canvas, fonts, tile, k, ActionTile::AddHost),
Slot::Rescan => draw_action_tile(canvas, fonts, tile, k, ActionTile::Rescan),
}
// The veil, at HALF its old strength. It used to do the whole recede on its own
// and had to be heavy for it; now the colour matrix above drains saturation and
// light, and this is left doing the one job a flat darkening is actually good
// at — separating cards that overlap.
// The veil, the fourth and lightest of the recede's mechanisms — the transform,
// the layer alpha and the colour matrix above already carry it, and stacking a
// heavy darkening on top of all three is what took an unfocused tile 55 % down
// against the aurora behind it. It goes through the scrim rather than straight
// black so that on a pale palette it pushes the same way the matrix does instead
// of greying back the lift.
if f < 1.0 {
let veil = (1.0 - f) as f32 * 0.12;
let veil = (1.0 - f) as f32 * 0.07;
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),
&fill(crate::theme::shade(veil)),
);
}
canvas.restore(); // layer
if layered {
canvas.restore(); // layer
}
canvas.restore(); // transform
}
@@ -443,17 +463,19 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
if h.online {
let r = 4.5 * k;
let center = Point::new((sx - r) as f32, (t + 9.0 * k) as f32);
let mut glow = Paint::new(
Color4f::new(ONLINE_GREEN.r, ONLINE_GREEN.g, ONLINE_GREEN.b, 0.7),
None,
);
let mut glow = fill(Color4f::new(
ONLINE_GREEN.r,
ONLINE_GREEN.g,
ONLINE_GREEN.b,
0.7,
));
glow.set_mask_filter(MaskFilter::blur(
skia_safe::BlurStyle::Normal,
(5.0 * k) as f32,
None,
));
canvas.draw_circle(center, r as f32, &glow);
canvas.draw_circle(center, r as f32, &Paint::new(ONLINE_GREEN, None));
canvas.draw_circle(center, r as f32, &fill(ONLINE_GREEN));
sx -= 2.0 * r + 9.0 * k;
}
if h.paired {
@@ -575,22 +597,15 @@ fn draw_action_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64, kind: Ac
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),
&Paint::new(accent(0.16), None),
&fill(accent(0.16)),
);
let mut ring = Paint::new(accent(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
canvas.draw_rrect(
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
&ring,
&stroke(accent(0.5), 1.0),
);
let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k);
let mut p = Paint::new(accent(1.0), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((3.0 * k) as f32);
let mut p = stroke(accent(1.0), (3.0 * k) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let r = 9.0 * k;
match kind {
ActionTile::AddHost => {
@@ -628,7 +643,7 @@ fn draw_action_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64, kind: Ac
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));
canvas.draw_path(&tip.detach(), &fill(accent(1.0)));
}
}
@@ -685,7 +700,7 @@ fn draw_badge(
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 {
let mut p = Paint::default();
let mut p = crate::theme::shaded();
let colors = [accent(1.0), accent(0.68)];
p.set_shader(skia_safe::gradient::shaders::linear_gradient(
(
@@ -704,12 +719,8 @@ fn draw_badge(
));
canvas.draw_rrect(rr, &p);
} else {
canvas.draw_rrect(rr, &Paint::new(accent(0.16), None));
let mut ring = Paint::new(accent(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
canvas.draw_rrect(rr, &ring);
canvas.draw_rrect(rr, &fill(accent(0.16)));
canvas.draw_rrect(rr, &stroke(accent(0.5), 1.0));
}
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
@@ -723,7 +734,7 @@ fn draw_badge(
side as f32,
);
if let Some(path) = crate::os_marks::os_mark(os, inner) {
canvas.draw_path(&path, &Paint::new(ink, None));
canvas.draw_path(&path, &fill(ink));
return;
}
let letter: String = name
@@ -757,12 +768,9 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
(2.0 * k) as f32,
(2.0 * k) as f32,
),
&Paint::new(ink, None),
&fill(ink),
);
let mut p = Paint::new(ink, None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.6 * k) as f32);
p.set_anti_alias(true);
let p = stroke(ink, (1.6 * k) as f32);
let mut shackle = PathBuilder::new();
let (cx, r) = (x + body_w / 2.0, 3.2 * k);
shackle.move_to(((cx - r) as f32, body_top as f32));
File diff suppressed because it is too large Load Diff
@@ -1,24 +1,32 @@
//! A saved host's own actions — Wake, Copy link, Edit…, Forget — reached with UP on its
//! carousel tile, and the console's answer to the overflow menu every other client hangs
//! off a host card.
//! The console's context menu: something the user is looking at, and the actions that apply
//! to it. One screen, any subject.
//!
//! Until now the console could add a host and connect to one, and that was all: a renamed
//! machine or a host typed in with a fat-fingered address stayed wrong forever, because
//! the only surfaces that could edit or forget one were the desktop shells. The tile is
//! where a host is, so the tile is where its actions belong.
//! It arrived as the saved host's own menu — Wake, Copy link, Edit…, Forget — because a
//! renamed machine or an address typed in with a fat thumb stayed wrong forever otherwise,
//! and the tile is where a host is, so the tile is where its actions belong. Generalising it
//! is what stops the console growing a second idiom per verb: the library had "Copy link"
//! wired straight to X, so one action had two shapes, and a console that answers every new
//! action with another face button runs out of buttons long before it runs out of actions.
//! Here a screen names a SUBJECT and the menu owns the verbs, which makes the next one — hide
//! a title, add it to Steam, override its profile — a row in [`OptionsScreen::actions`].
//!
//! UP is the gesture because the carousel is horizontal — left/right are spoken for and
//! up is free — and because the Android console already does exactly this, so the two
//! consoles are learned once. A pinned profile card offers only Unpin: it is a shortcut,
//! not a second host, and offering to forget the host from it would blur precisely the
//! distinction a pin exists to draw.
//! Which button raises it is per screen, because the screens differ; only the WORD is
//! load-bearing, and both legends say "Options". Home's carousel is horizontal, so up is the
//! one free direction and ▲ opens it — the gesture the Android console already teaches. The
//! library spends up on grid rows, so there it is X, which is free precisely because copying
//! a link stopped being a button. X also keeps the menu reachable with a mouse: the hint bar
//! turns face-button hints into presses, and ▲ only became one of those alongside this change.
//!
//! A pinned profile card offers only Unpin: it is a shortcut, not a second host, and offering
//! to forget the host from it would blur precisely the distinction a pin exists to draw.
use crate::glyphs::{Hint, HintKey};
use crate::library::LibraryGame;
use crate::model::{ConsoleCmd, HostRow};
use crate::pointer::Pointer;
use crate::screens::{Ctx, Outbox, Screen};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use crate::theme::{fg, Fonts, EDGE_INSET, W};
use crate::widgets::{ListMsg, MenuList, RowSpec, ROW_MAX_W};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -33,11 +41,27 @@ enum Action {
Cancel,
}
pub(crate) struct HostOptionsScreen {
/// The row this menu was opened on, by value. Discovery rewrites the carousel every
/// service pass; holding an index or a borrow would let the menu retarget itself onto
/// whichever host slid into that slot, and "Forget" must never be able to do that.
host: HostRow,
/// What the menu was raised ON. Every difference between two menus in this file is a match on
/// this enum and nothing else, which is what keeps a third kind of menu to a variant and a
/// row list rather than another screen with its own list, dispatch and legend.
pub(crate) enum Subject {
/// A saved host's carousel tile, or a pinned profile card (the pin rides in the row).
Host(HostRow),
/// One title on a shelf, and the host serving it — that host's pin included, so a link
/// taken off a pinned card's shelf still streams the way that card does.
Game {
host: HostRow,
id: String,
title: String,
},
}
pub(crate) struct OptionsScreen {
/// The subject this menu was opened on, by value. Discovery rewrites the carousel and the
/// library re-collates its shelf while the menu is up; holding an index or a borrow would
/// let the menu retarget itself onto whatever slid into that slot, and "Forget" must never
/// be able to do that.
subject: Subject,
list: MenuList,
/// Forget is the one action here with no undo, so the row arms on the first press and
/// only fires on the second. The other clients forget outright; a console is driven by
@@ -46,45 +70,66 @@ pub(crate) struct HostOptionsScreen {
armed: bool,
}
impl HostOptionsScreen {
pub(crate) fn new(host: &HostRow) -> HostOptionsScreen {
HostOptionsScreen {
host: host.clone(),
impl OptionsScreen {
pub(crate) fn for_host(host: &HostRow) -> OptionsScreen {
OptionsScreen::on(Subject::Host(host.clone()))
}
fn on(subject: Subject) -> OptionsScreen {
OptionsScreen {
subject,
list: MenuList::new(),
armed: false,
}
}
/// Is this row worth opening a menu for at all? Only saved hosts have anything to
/// edit or forget; a discovered-but-unsaved one is not ours to change.
/// edit or forget; a discovered-but-unsaved one is not ours to change. A title needs no
/// such gate — its one action fails soft, and a shelf only exists for a saved host.
pub(crate) fn available(host: &HostRow) -> bool {
host.saved
}
/// The host every action here ultimately addresses — a title's is the one serving it.
fn host(&self) -> &HostRow {
match &self.subject {
Subject::Host(h) => h,
Subject::Game { host, .. } => host,
}
}
pub(crate) fn title(&self) -> String {
match &self.host.pin {
Some(p) => format!("{} \u{b7} {}", self.host.name, p.name),
None => self.host.name.clone(),
match &self.subject {
Subject::Host(h) => match &h.pin {
Some(p) => format!("{} \u{b7} {}", h.name, p.name),
None => h.name.clone(),
},
Subject::Game { title, .. } => title.clone(),
}
}
/// A pinned card's key is the host's with the profile id appended past a NUL (see the
/// service's row builder) — every command here addresses the HOST.
fn host_key(&self) -> &str {
self.host
.key
.split('\0')
.next()
.unwrap_or(self.host.key.as_str())
let key = self.host().key.as_str();
key.split('\0').next().unwrap_or(key)
}
fn actions(&self) -> Vec<Action> {
if self.host.pin.is_some() {
let host = match &self.subject {
Subject::Host(h) => h,
// Deliberately not [Play, …]: the host menu does not repeat its tile's own A
// press either, and duplicating the primary action is the one thing a menu about
// consistency should not start life doing. Copy link leads so the cursor, which
// starts on row 0, is already on the row nearly everyone came for.
Subject::Game { .. } => return vec![Action::CopyLink, Action::Cancel],
};
if host.pin.is_some() {
return vec![Action::Unpin, Action::CopyLink, Action::Cancel];
}
let mut a = Vec::new();
// Waking a host that is already answering would just sit there counting seconds.
if self.host.can_wake && !self.host.online {
if host.can_wake && !host.online {
a.push(Action::Wake);
}
// "Send logs" needs a paired identity (the upload authenticates with the streaming
@@ -92,7 +137,7 @@ impl HostOptionsScreen {
// error. This is the log-escape hatch for platforms whose own filesystem the user
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
// its web console next to the host's own logs.
if self.host.paired && self.host.online {
if host.paired && host.online {
a.push(Action::SendLogs);
}
a.extend([
@@ -104,6 +149,8 @@ impl HostOptionsScreen {
a
}
/// One label per action for the whole console, so the same verb cannot end up worded two
/// ways on two screens — which is the mess this menu exists to clear up.
fn label(&self, a: Action) -> String {
match a {
Action::Wake => "Wake host".into(),
@@ -168,6 +215,22 @@ impl HostOptionsScreen {
}
}
/// This subject's `punktfunk://` link, built from the store at ACTIVATION — never at open.
/// The store is what holds the fingerprint and stable id, and the row the menu was raised
/// on may have left it since; a link built early would be a link built from a lie.
fn link(&self) -> Option<String> {
match &self.subject {
Subject::Host(h) => crate::screens::host_link(h),
Subject::Game { host, id, .. } => crate::screens::saved_host_link(
&host.fp_hex,
&host.addr,
host.port,
host.pin.as_ref().map(|p| p.id.as_str()),
Some(id.as_str()),
),
}
}
fn run(&mut self, action: Action, fx: &mut Outbox) {
let key = self.host_key().to_string();
match action {
@@ -179,17 +242,18 @@ impl HostOptionsScreen {
fx.pop();
}
Action::SendLogs => {
let host = self.host();
fx.cmds.push(ConsoleCmd::SendLogs {
addr: self.host.addr.clone(),
mgmt: self.host.mgmt_port,
fp_hex: self.host.fp_hex.clone(),
host_name: self.host.name.clone(),
addr: host.addr.clone(),
mgmt: host.mgmt_port,
fp_hex: host.fp_hex.clone(),
host_name: host.name.clone(),
});
fx.toast = Some(format!("Sending logs to {}\u{2026}", self.host.name));
fx.toast = Some(format!("Sending logs to {}\u{2026}", host.name));
fx.pop();
}
Action::CopyLink => {
match crate::screens::host_link(&self.host) {
match self.link() {
Some(url) => {
fx.copy = Some(url);
fx.toast = Some("Link copied".into());
@@ -200,16 +264,16 @@ impl HostOptionsScreen {
fx.pop();
}
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
&self.host,
self.host(),
))),
Action::Forget if !self.armed => self.armed = true,
Action::Forget => {
fx.cmds.push(ConsoleCmd::ForgetHost { key });
fx.toast = Some(format!("Forgot {}", self.host.name));
fx.toast = Some(format!("Forgot {}", self.host().name));
fx.pop();
}
Action::Unpin => {
if let Some(p) = &self.host.pin {
if let Some(p) = &self.host().pin {
fx.cmds.push(ConsoleCmd::SetPin {
key,
profile_id: p.id.clone(),
@@ -230,6 +294,20 @@ impl HostOptionsScreen {
]
}
/// What this menu is FOR, in a line. Per subject, because a menu that opened from a cover
/// and one that opened from a host tile are answering two different questions.
fn blurb(&self) -> String {
match &self.subject {
Subject::Host(h) if h.pin.is_some() => {
"This card is a shortcut to one profile on this host. Unpinning it changes \
nothing about the host or the profile."
.into()
}
Subject::Host(_) => "Manage this saved host.".into(),
Subject::Game { host, .. } => format!("Actions for this title on {}.", host.name),
}
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
@@ -240,23 +318,16 @@ impl HostOptionsScreen {
_ctx: &mut Ctx,
) {
// The explainer line, as on Add Host — it says what this menu is FOR, and the air it
// takes is what keeps the first row off the pinned title.
let blurb = if self.host.pin.is_some() {
"This card is a shortcut to one profile on this host. Unpinning it changes \
nothing about the host or the profile."
} else {
"Manage this saved host."
};
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
fonts.centered(
// takes is what keeps the first row off the title.
fonts.leading(
canvas,
blurb,
&self.blurb(),
W::Regular,
13.0 * k,
fg(0.55),
cx,
f64::from(rect.left) + EDGE_INSET * k,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
ROW_MAX_W * 0.72 * k,
);
let list_rect = Rect::from_ltrb(
rect.left,
@@ -274,10 +345,23 @@ impl HostOptionsScreen {
}
}
/// The title half of the menu — what the library's X raises, where the host half is what the
/// home carousel's ▲ raises.
impl OptionsScreen {
pub(crate) fn for_game(host: &HostRow, game: &LibraryGame) -> OptionsScreen {
OptionsScreen::on(Subject::Game {
host: host.clone(),
id: game.id.clone(),
title: game.title.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::ProfileChip;
use crate::screens::Nav;
fn host() -> HostRow {
HostRow {
@@ -310,10 +394,21 @@ mod tests {
}
}
fn game() -> LibraryGame {
LibraryGame {
id: "steam:367520".into(),
title: "Hollow Knight".into(),
store: "steam".into(),
launcher: false,
icon: "steam".into(),
platform: None,
}
}
#[test]
fn a_discovered_host_has_no_menu() {
assert!(HostOptionsScreen::available(&host()));
assert!(!HostOptionsScreen::available(&HostRow {
assert!(OptionsScreen::available(&host()));
assert!(!OptionsScreen::available(&HostRow {
saved: false,
..host()
}));
@@ -321,13 +416,13 @@ mod tests {
#[test]
fn wake_is_offered_only_when_it_would_do_something() {
let awake = HostOptionsScreen::new(&HostRow {
let awake = OptionsScreen::for_host(&HostRow {
can_wake: true,
online: true,
..host()
});
assert!(!awake.actions().contains(&Action::Wake));
let asleep = HostOptionsScreen::new(&HostRow {
let asleep = OptionsScreen::for_host(&HostRow {
can_wake: true,
online: false,
..host()
@@ -337,7 +432,7 @@ mod tests {
#[test]
fn a_pinned_card_cannot_forget_or_edit_the_host() {
let s = HostOptionsScreen::new(&pinned());
let s = OptionsScreen::for_host(&pinned());
assert_eq!(
s.actions(),
vec![Action::Unpin, Action::CopyLink, Action::Cancel]
@@ -348,7 +443,7 @@ mod tests {
#[test]
fn forget_needs_two_presses() {
let mut s = HostOptionsScreen::new(&host());
let mut s = OptionsScreen::for_host(&host());
let actions = s.actions();
let i = actions.iter().position(|a| *a == Action::Forget).unwrap();
s.list.cursor = i;
@@ -369,7 +464,7 @@ mod tests {
#[test]
fn leaving_the_forget_row_disarms_it() {
let mut s = HostOptionsScreen::new(&host());
let mut s = OptionsScreen::for_host(&host());
let actions = s.actions();
s.armed = true;
s.list.cursor = actions.iter().position(|a| *a == Action::Cancel).unwrap();
@@ -387,4 +482,58 @@ mod tests {
s.dispatch(ListMsg::None, None, &actions, &mut ctx, &mut fx);
assert!(!s.armed, "a cursor move off the row cancels the arming");
}
#[test]
fn a_title_offers_the_link_and_nothing_its_cover_already_does() {
let s = OptionsScreen::for_game(&host(), &game());
assert_eq!(s.actions(), vec![Action::CopyLink, Action::Cancel]);
// The cursor starts on row 0, so the row nearly everyone opened this for is the row
// the confirm press is already on.
assert_eq!(s.list.cursor, 0);
assert_eq!(s.title(), "Hollow Knight");
}
#[test]
fn every_menu_can_be_left_without_doing_anything() {
for s in [
OptionsScreen::for_host(&host()),
OptionsScreen::for_host(&pinned()),
OptionsScreen::for_game(&host(), &game()),
OptionsScreen::for_game(&pinned(), &game()),
] {
assert_eq!(s.actions().last(), Some(&Action::Cancel));
}
}
#[test]
fn a_titles_menu_keeps_the_shelfs_whole_host_so_a_pinned_cards_profile_survives() {
let s = OptionsScreen::for_game(&pinned(), &game());
let Subject::Game { host, id, .. } = &s.subject else {
panic!("built as a title menu");
};
assert_eq!(id, "steam:367520", "the link's launch id");
assert_eq!(
host.pin.as_ref().map(|p| p.id.as_str()),
Some("prof-1"),
"a link taken off a pinned card's shelf carries that card's profile"
);
// Host-addressed commands still reach past the pin's composite key.
assert_eq!(s.host_key(), "aa");
}
#[test]
fn copy_link_always_closes_the_menu_and_says_what_happened() {
// Whether the store still knows the host decides WHICH of the two things it says,
// and nothing else: a menu that stayed open on failure would leave the user pressing
// a row that can only fail again.
for mut s in [
OptionsScreen::for_host(&host()),
OptionsScreen::for_game(&host(), &game()),
] {
let mut fx = Outbox::default();
s.run(Action::CopyLink, &mut fx);
assert!(matches!(fx.nav, Some(Nav::Pop)));
assert!(fx.toast.is_some());
}
}
}
+5 -5
View File
@@ -8,8 +8,8 @@ use crate::glyphs::{Hint, HintKey};
use crate::model::{ConsoleCmd, HostRow, PairPhase};
use crate::pointer::Pointer;
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{fg, Fonts, ERROR, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use crate::theme::{fg, Fonts, EDGE_INSET, ERROR, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, ROW_MAX_W};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -339,15 +339,15 @@ impl PairScreen {
} else {
"Enter the PIN from the host's web console (Pairing page) or its log."
};
fonts.centered(
fonts.leading(
canvas,
intro,
W::Regular,
13.0 * k,
fg(0.55),
cx,
f64::from(rect.left) + EDGE_INSET * k,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
ROW_MAX_W * 0.72 * k,
);
let seat = self.keyboard.seat(self.editing.is_some() && !ctx.deck, dt);
+103 -19
View File
@@ -63,7 +63,6 @@ enum RowId {
Stats,
Fullscreen,
AutoWake,
Library,
/// The gamepad UI's background colour family — see [`crate::library::PALETTES`]. The
/// 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.
@@ -72,20 +71,25 @@ enum RowId {
/// 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.
/// How the game library arranges its titles — see `library::LibraryView`. The library
/// changes it in place now, from the bar over its own field, which is where an
/// arrangement you want to SEE the effect of belongs; this row stays because both
/// surfaces write the one `library_view` key, so it is the same setting reached from a
/// list, and it is where the explanation of the two arrangements lives.
LibraryView,
/// Whether opening a host's library lands on its collections rather than on the whole
/// shelf — see `trust::Settings::library_collections`. Beside the view row because both
/// answer "what does the library look like when I get there", and this one is the only
/// way to reach the collections screen without the shelf's Y.
LibraryCollections,
}
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
// trailing Profiles tab) but created and edited only in the desktop app (design §5.4).
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake and echo cancellation all
// were). Still deliberately smaller than the desktop dialogs — device pickers
// (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the trailing
// Profiles tab) but created and edited only in the desktop app (design §5.4).
//
// The tab names are shared with the Apple and Android gamepad settings, so a setting is
// found under the same word on every client. Profiles is the trailing tab and is built
@@ -148,10 +152,10 @@ const TABS: [(&str, &[RowId]); 7] = [
RowId::Palette,
RowId::ReduceMotion,
RowId::LibraryView,
RowId::LibraryCollections,
RowId::Stats,
RowId::Fullscreen,
RowId::AutoWake,
RowId::Library,
],
),
("Profiles", &[]),
@@ -744,6 +748,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
.label()
.into(),
),
RowId::LibraryCollections => (
None,
"Start in collections",
on_off(s.library_collections).into(),
),
RowId::Stats => (
None,
"Statistics overlay",
@@ -755,7 +764,6 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
on_off(s.fullscreen_on_stream).into(),
),
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"),
};
RowSpec {
@@ -868,7 +876,13 @@ fn detail(id: RowId) -> &'static str {
}
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."
for when you already know what you are looking for. The library's own bar \
switches it while you browse, along with the sort."
}
RowId::LibraryCollections => {
"Opening a host's library goes straight to its collections — platforms and \
stores as tiles instead of the whole shelf. A library with only one \
collection opens on the shelf as usual."
}
RowId::Stats => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
@@ -879,7 +893,6 @@ fn detail(id: RowId) -> &'static str {
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
reached over a VPN, where the wake wait only adds delay."
}
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
RowId::Profile(_) => {
"Pin this profile to a host and it appears as its own card — one press \
connects with these settings. Profiles are created and edited in the \
@@ -1079,9 +1092,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
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::LibraryCollections => toggle(&mut s.library_collections, delta, wrap),
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),
// Navigation rows, handled before the settings path in `menu` — never a value edit.
RowId::Profile(_) | RowId::NoProfiles => None,
}
@@ -1090,7 +1103,17 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
/// The shared stepping rule: clamp when adjusting, wrap when cycling; an unknown
/// current value snaps to the first option on any step.
fn step_option(current: Option<usize>, len: usize, delta: i32, wrap: bool) -> Option<usize> {
///
/// Reachable from the sibling screens because the library's own view/sort bar edits two of
/// the very values this screen's rows edit, and "◀ ▶ stops at the ends" is a grammar the
/// console states once. A second copy of it there would be a second place for the boundary
/// thud to go missing.
pub(super) fn step_option(
current: Option<usize>,
len: usize,
delta: i32,
wrap: bool,
) -> Option<usize> {
if len == 0 {
return None;
}
@@ -1121,7 +1144,7 @@ fn toggle(value: &mut bool, delta: i32, wrap: bool) -> Option<()> {
}
#[cfg(test)]
mod tests {
pub(super) mod tests {
use super::*;
use pf_client_core::trust::Settings;
@@ -1155,7 +1178,12 @@ mod tests {
/// Point the settings store at a throwaway HOME. `apply_row` rebases on the FILE
/// before a mutating press and saves after it, so a test driving that path against the
/// real `$HOME` would rewrite the developer's own console settings.
fn fake_home() {
///
/// Shared with the library screen's tests, which drive the same `Settings::save` through
/// the library bar: one `OnceLock` for the whole binary is what keeps the write to the
/// environment sound, and a second copy of this would be exactly the race the SAFETY note
/// below rules out.
pub(crate) fn fake_home() {
use std::sync::OnceLock;
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
@@ -1708,7 +1736,8 @@ mod tests {
}
}
// The pre-tab flat list, plus the palette row, the lossless-audio row and the
// reduce-motion row later passes added.
// reduce-motion row later passes added, minus the game-library toggle: this screen
// never read it, and the library is offered on any paired host now.
assert_eq!(seen.len(), 33, "{seen:?}");
assert!(seen.contains(&RowId::Palette));
assert!(seen.contains(&RowId::ReduceMotion));
@@ -1718,6 +1747,61 @@ mod tests {
assert_eq!(TABS[PROFILES_TAB].0, "Profiles");
}
/// The collections entry is a plain off-by-default toggle, and it sits directly under the
/// row that says what the library looks like — the two are one decision read in two
/// halves, and a user who wants the tiles goes looking beside the arrangement.
///
/// Off by default matters more than the placement: this key decides where a deep link
/// lands, so an install that never opens this screen must keep the shelf it has.
#[test]
fn the_collections_entry_sits_with_the_library_view_and_ships_off() {
let (mut settings, pads) = ctx_parts();
assert!(!settings.library_collections, "off by default");
let interface = TABS
.iter()
.find(|(name, _)| *name == "Interface")
.expect("the Interface tab")
.1;
let view = interface
.iter()
.position(|id| *id == RowId::LibraryView)
.expect("the library view row");
assert_eq!(interface.get(view + 1), Some(&RowId::LibraryCollections));
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
assert!(
!adjust(RowId::LibraryCollections, -1, false, &mut ctx),
"already off = thud"
);
assert!(adjust(RowId::LibraryCollections, 1, false, &mut ctx));
assert!(ctx.settings.library_collections);
assert_eq!(
row_spec(RowId::LibraryCollections, &ctx, &[])
.value
.as_deref(),
Some("On"),
"the row says what the key holds"
);
assert!(
!adjust(RowId::LibraryCollections, 1, false, &mut ctx),
"on = thud"
);
assert!(
adjust(RowId::LibraryCollections, 1, true, &mut ctx),
"A flips it back"
);
assert!(!ctx.settings.library_collections);
}
/// L1/R1 wrap around the strip and each tab keeps its own cursor, so a detour into
/// another section doesn't lose your place.
#[test]
+73 -13
View File
@@ -19,7 +19,7 @@ use anyhow::{anyhow, Result};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
use pf_client_core::trust;
use pf_presenter::overlay::OverlayAction;
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
use skia_safe::{Canvas, Color4f, Data, Rect, RuntimeEffect};
use std::collections::VecDeque;
use std::time::Instant;
@@ -79,8 +79,10 @@ enum Motion {
/// 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.
/// The screen leaving the stack, when it is no longer ON the stack to be drawn from.
/// A pop always carries one. A plain push never does — its parent stays put and the
/// renderer finds it at `n - 2` — but a REPLACE does, because the screen it swapped
/// out is gone and `n - 2` is that screen's parent, a level too far.
leaving: Option<Box<Screen>>,
},
}
@@ -451,6 +453,39 @@ impl Shell {
}
}
}
self.collections_handover();
}
/// "Start in collections": a shelf that has just learned it holds more than one collection
/// stands aside for the collections screen.
///
/// It lives here rather than in the screen because a screen cannot replace ITSELF — the
/// decision needs the library model and the settings, which the shelf has, but the swap
/// needs the stack, which only the shell has. The shelf answers the question and hands
/// back a screen; this puts it where the shelf was standing.
///
/// Guarded on a settled transition. Mid-flight the stack's top is not yet what the user
/// is looking at, and swapping under a push the user has already reversed with B would
/// land them on the collections of a host they just backed out of.
fn collections_handover(&mut self) {
if !matches!(self.motion, Motion::None) {
return;
}
// Field borrows rather than clones: this runs every frame for the life of the shelf,
// and `Settings` is a struct of owned Strings. `stack` is borrowed mutably while
// `library` and `settings` are borrowed shared — disjoint fields, so the shelf can
// read both while it is itself being held.
let upgraded = match self.stack.last_mut() {
Some(Screen::Library(shelf)) => {
shelf.collections_upgrade(&self.library, &self.settings)
}
_ => None,
};
if let Some(screen) = upgraded {
let n = self.stack.len();
self.stack[n - 1] = Screen::Collections(screen);
}
}
fn start_connect(&mut self, intent: ConnectIntent) {
@@ -573,7 +608,7 @@ impl Shell {
}
if p.press() {
if let Some((key, _)) = self.hint_rects.iter().find(|(_, r)| p.hits(*r)) {
// Only the face-button hints are actions. Shoulders and Adjust describe a
// A hint is clickable when it names an ACTION. Shoulders and Adjust name a
// DIRECTION, and the thing they steer — the tab strip, a row's value — is
// already under the pointer's finger; inventing a side for a click here
// would just be a worse way to press what it can already press.
@@ -582,6 +617,11 @@ impl Shell {
crate::glyphs::HintKey::Back => Some(MenuEvent::Back),
crate::glyphs::HintKey::Secondary => Some(MenuEvent::Secondary),
crate::glyphs::HintKey::Tertiary => Some(MenuEvent::Tertiary),
// ▲ is drawn as a direction and read as one, but it steers nothing: the
// only screen that publishes it is the home carousel, where up is not
// navigation but "open this tile's menu". Without this the context menu —
// and with it the only way to copy a host's link — is pad-only.
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
_ => None,
};
if let Some(ev) = ev {
@@ -730,11 +770,19 @@ impl Shell {
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 — and so does a Back that REVERSES this
// push, which pops to the same parent.
self.stack.pop();
// replaced screen was reached from.
//
// It is CARRIED through the transition rather than dropped on the spot,
// which is the whole difference between this reading right and reading
// wrong. A push paints the screen BENEATH the incoming one as its receding
// layer; drop the replaced screen first and that is its parent, so choosing
// "Edit…" in a host's menu animated the editor in over HOME — the host list
// flashing up for the length of the transition, as if the menu had been
// dismissed and something else opened. Handing it over as the leaving layer
// means the menu itself recedes, which is what actually happened.
let leaving = self.stack.pop().map(Box::new);
self.stack.push(*screen);
self.begin_nav(NavKind::Push, None);
self.begin_nav(NavKind::Push, leaving);
}
Nav::Pop => {
if self.stack.len() > 1 {
@@ -827,13 +875,25 @@ impl Shell {
/// 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 {
if let Motion::Nav {
kind,
target,
leaving,
..
} = &mut 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.
// 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 reversed REPLACE puts back what it swapped out. The transition showed
// that screen receding and then coming home again, so landing anywhere else
// would contradict what was on glass — and "undo that navigation" means the
// menu you were standing in, not the screen one level further out.
if let Some(back) = leaving.take() {
self.stack.push(*back);
}
}
}
// A completed pop drops the screen it was carrying, exactly as before.
@@ -883,7 +943,7 @@ impl Shell {
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 48) };
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
Some(shader) => {
let mut paint = Paint::default();
let mut paint = crate::theme::shaded();
paint.set_shader(shader);
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
}
+16 -30
View File
@@ -2,15 +2,14 @@
use crate::anim::{approach, springs};
use crate::glyphs::{hint_bar, Hint, HintKey};
use crate::theme::{fg, Fonts, PanelStroke, W};
use skia_safe::{gradient, Canvas, Color4f, Paint, PathBuilder, Point, Rect, TileMode};
use crate::theme::{fg, fill, Fonts, PanelStroke, W};
use skia_safe::{gradient, Canvas, Color4f, PathBuilder, Point, Rect, TileMode};
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);
let mut p = fill(ink);
match mark {
ToastMark::Dot => {
canvas.draw_circle((cx as f32, cy as f32), (3.4 * k) as f32, &p);
@@ -162,17 +161,21 @@ impl Shell {
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;
// Leading run: the kind mark, air, then the text. The mark carries the kind by
// itself — a hairline in the same tint used to stand beside it, saying the same
// thing twice and reading as a rendering seam rather than as meaning. Its pad is
// shy of `pad_x` because nothing drawn in the 13 dp box fills it, and an equal pad
// leaves the pill visibly left-heavy in air.
let (mark_pad, mark_w, gap) = (13.0 * k, 13.0 * k, 9.0 * k);
let lead = mark_pad + mark_w + gap;
let bw = lead + tw + 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),
&fill(crate::theme::shade(0.6)),
);
crate::theme::panel(
canvas,
@@ -183,29 +186,12 @@ impl Shell {
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,
);
// Tinted by kind, so a toast is readable from a couch without reading the words.
draw_toast_mark(canvas, mark, bx + mark_pad + mark_w / 2.0, cy, k, tint);
fonts.draw(
canvas,
&toast.text,
bx + pad_x + lead,
bx + lead,
cy + size * 0.36,
W::Medium,
size,
@@ -242,7 +228,7 @@ impl Shell {
self.draw_aurora(canvas, w, h, t, 0.0);
// A soft pool of shade under the centre seats the text against a bright field —
// dark on a dark palette, light on a pale one, so it always separates.
let mut vignette = Paint::default();
let mut vignette = crate::theme::shaded();
let shades = [crate::theme::shade(0.5), crate::theme::shade(0.0)];
vignette.set_shader(gradient::shaders::radial_gradient(
(
+52 -21
View File
@@ -5,7 +5,7 @@ use crate::glyphs::{hint_bar, GlyphStyle};
use crate::library::LibraryShared;
use crate::model::HostRow;
use crate::screens::{Bg, Ctx, Screen};
use crate::theme::{fg, Fonts, PanelStroke, W};
use crate::theme::{fg, Fonts, PanelStroke, EDGE_INSET, W};
use pf_client_core::gamepad::PadInfo;
use pf_client_core::trust;
use skia_safe::{Canvas, Rect};
@@ -81,6 +81,23 @@ impl Shell {
w as f32,
(h - BOTTOM_BAND * k) as f32,
);
// How much room the heading has before it reaches the controller chip. The chip is
// painted last so it sits above every layer, but its geometry is known now — `chip`
// and `pads` are both set above — and the heading needs it: centred, the title had
// the whole width to spread symmetrically into, where left-aligned it runs AT the
// chip. The 12 is the gap Apple keeps between the two; the floor keeps a
// pathologically long chip string from squeezing the title to nothing.
let title_max_w = {
let chip_w = self.chip.as_ref().map_or(0.0, |c| {
chip_width(
fonts,
c,
self.pads.first().is_some_and(|p| p.battery.is_some()),
k,
)
});
(w - 2.0 * EDGE_INSET * k - chip_w - 12.0 * k).max(w * 0.35)
};
// One paint recipe per layer: (alpha, slide, scale). Everything below borrows
// disjoint fields of `self` per call, so the borrow checker stays happy.
let mut env = LayerEnv {
@@ -89,6 +106,7 @@ impl Shell {
h,
content,
k,
title_max_w,
dt,
fonts,
hosts: &self.hosts,
@@ -119,6 +137,7 @@ impl Shell {
(
Motion::Nav {
kind: NavKind::Push,
leaving,
..
},
Some(p),
@@ -126,15 +145,18 @@ impl Shell {
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));
let recede = zoom(1.0 - (1.0 - NAV_EXIT_SCALE) * p);
// Outgoing recedes underneath…
if n >= 2 {
if let Some(replaced) = leaving.as_mut() {
// A REPLACE carries the screen it swapped out, because that screen is no
// longer on the stack to be found under the incoming one. Painting the
// stack's own n-2 here would recede the replaced screen's PARENT, which
// is how choosing "Edit…" in a host menu used to flash the host list.
env.paint(replaced.as_mut(), 1.0 - p, 0.0, recede);
env.paint(&mut self.stack[n - 1], p, enter_slide, enter_scale);
} else if n >= 2 {
let (below, top) = self.stack.split_at_mut(n - 1);
env.paint(
&mut below[n - 2],
1.0 - p,
0.0,
zoom(1.0 - (1.0 - NAV_EXIT_SCALE) * p),
);
env.paint(&mut below[n - 2], 1.0 - p, 0.0, recede);
// …while the incoming slides up out of a fade.
env.paint(&mut top[0], p, enter_slide, enter_scale);
} else {
@@ -174,17 +196,9 @@ impl Shell {
let tw = f64::from(fonts.measure(chip, W::Medium, size));
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 bw = chip_width(fonts, chip, battery.is_some(), k);
let bx = w - EDGE_INSET * 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(
@@ -222,6 +236,21 @@ impl Shell {
}
}
/// The controller chip's drawn width, device px. Its own function because two things need
/// it — the chip itself, and the heading, which is left-aligned now and so has to stop short
/// of it. A second copy of this arithmetic is a title that slides under the chip the day
/// someone adds a field to it.
///
/// 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.
fn chip_width(fonts: &Fonts, chip: &str, has_battery: bool, k: f64) -> f64 {
let tw = f64::from(fonts.measure(chip, W::Medium, 12.0 * k));
let (pad_x, gap, mark_w) = (12.0 * k, 8.0 * k, 15.0 * k);
let pip_w = if has_battery { 22.0 * k + gap } else { 0.0 };
pad_x + mark_w + gap + tw + pip_w + pad_x
}
/// Everything one screen layer needs to paint — bundled so the transition arms stay
/// readable and each `paint` call borrows `Shell` fields disjointly.
struct LayerEnv<'a> {
@@ -230,6 +259,8 @@ struct LayerEnv<'a> {
h: f64,
content: Rect,
k: f64,
/// The heading's width budget — everything left of the controller chip. See `Shell::render`.
title_max_w: f64,
dt: f64,
fonts: &'a Fonts,
hosts: &'a [HostRow],
@@ -272,15 +303,15 @@ impl LayerEnv<'_> {
device_name: self.device_name,
t: self.t,
};
self.fonts.centered(
self.fonts.heading(
canvas,
&screen.title(&ctx),
W::Bold,
30.0 * self.k,
fg(1.0),
self.w / 2.0,
EDGE_INSET * self.k,
18.0 * self.k,
self.w * 0.7,
self.title_max_w,
);
screen.render(canvas, self.content, self.k, self.dt, self.fonts, &mut ctx);
let rects = if self.show_hints {
+514 -1
View File
@@ -382,6 +382,59 @@ fn a_secondary_press_goes_back() {
));
}
/// A REPLACE recedes the screen it replaced, not that screen's parent.
///
/// Reported from a Deck: choosing "Edit…" in a host's menu flashed the host LIST for the
/// length of the transition before the editor arrived. The cause is that a push paints the
/// screen beneath the incoming one as its receding layer, while a replace had already popped
/// and dropped the screen being swapped out — so "beneath" was the menu's parent, one level
/// too far, and the transition animated the editor in over Home.
///
/// Asserted on the carried screen rather than on pixels: the defect is entirely a question of
/// WHICH screen the motion holds, and a frame diff would pin the particular look of a
/// transition instead of the thing that was wrong with it.
#[test]
fn a_replace_carries_the_screen_it_replaced() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.handle_menu(MenuEvent::Move(MenuDir::Up));
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
finish_motion(&mut s);
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
// quietly landing this test's Confirm on "Forget".
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Confirm);
assert!(
matches!(s.stack.last(), Some(Screen::AddHost(_))),
"Edit… opens the host editor"
);
assert_eq!(s.stack.len(), 2, "the menu was swapped out, not stacked on");
match &s.motion {
Motion::Nav {
kind: NavKind::Push,
leaving: Some(carried),
..
} => assert!(
matches!(carried.as_ref(), Screen::HostOptions(_)),
"the receding layer must be the MENU; carrying nothing leaves the renderer to \
recede the menu's parent, which is the reported flash"
),
_ => panic!("a replace must be a push CARRYING its predecessor"),
}
// …and reversing it puts the menu back, because that is the screen the user watched
// recede and then return.
s.handle_menu(MenuEvent::Back);
finish_motion(&mut s);
assert!(
matches!(s.stack.last(), Some(Screen::HostOptions(_))),
"a reversed replace lands where the user actually was"
);
}
/// Up on a saved tile opens that host's menu; a discovered-but-unsaved one has none.
#[test]
fn up_opens_host_options_for_saved_tiles_only() {
@@ -606,6 +659,84 @@ fn mixed_library(library: &LibraryShared) {
]);
}
/// "Start in collections" actually starts in collections — asserted on the SHELL, because
/// the shelf was never the part that was broken.
///
/// The handover shipped dead: `LibraryScreen::collections_upgrade` was written, documented and
/// unit-tested for its DECISION, and then nothing ever called it. It carried an
/// `#[allow(dead_code)]`, which is precisely what stopped the compiler from saying so, and the
/// shelf's own tests passed throughout because they called it directly. The setting was on,
/// the shelf agreed it should stand aside, and the library opened on the shelf anyway.
///
/// So this drives `Shell::sync` and asserts on the STACK. A screen cannot replace itself —
/// only the shell owns the stack — so the shell is where the wiring has to be witnessed.
#[test]
fn the_setting_hands_a_multi_platform_library_over_to_collections() {
let games: Vec<crate::library::LibraryGame> = platform_games();
for (want_collections, enabled) in [(true, true), (false, false)] {
let (mut s, _console, library) = shell(vec![
Screen::Home(HomeScreen::new()),
Screen::Library(LibraryScreen::new(&hosts()[0])),
]);
s.settings.library_collections = enabled;
// The shelf must see its OWN fetch go out before it will read the model: a library
// that is Ready before the fetch is the PREVIOUS host's, still sitting in the shared
// model. A default library is Loading, so this frame is that proof.
s.sync();
assert!(
matches!(s.stack.last(), Some(Screen::Library(_))),
"nothing to hand over to while the fetch is still out"
);
library.set_games(games.clone());
s.sync();
if want_collections {
assert!(
matches!(s.stack.last(), Some(Screen::Collections(_))),
"the setting is on and the library has four platforms — it must open on them"
);
assert_eq!(
s.stack.len(),
2,
"it REPLACES the shelf, never stacks on it"
);
} else {
assert!(
matches!(s.stack.last(), Some(Screen::Library(_))),
"with the setting off the library opens on its shelf"
);
}
}
}
/// …and a library with only ONE collection opens on its shelf whatever the setting says,
/// because a collections screen listing a single tile is a press that buys nothing.
#[test]
fn one_collection_is_not_worth_a_screen() {
let (mut s, _console, library) = shell(vec![
Screen::Home(HomeScreen::new()),
Screen::Library(LibraryScreen::new(&hosts()[0])),
]);
s.settings.library_collections = true;
s.sync();
library.set_games(
platform_games()
.into_iter()
.map(|mut g| {
g.platform = Some("PlayStation 3".into());
g
})
.collect(),
);
s.sync();
assert!(
matches!(s.stack.last(), Some(Screen::Library(_))),
"one platform is not a set of collections"
);
}
/// 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.
@@ -960,7 +1091,45 @@ fn dump_console_screens() {
};
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
dump(&mut s2, 40, 8, "07-library", true);
// 80 frames, not 40: this shelf carries no art, so it takes the entrance's 400 ms
// art-wait deadline, and until that expires the screen is deliberately the loading
// spinner. At 40×8 ms the dump could finish inside the wait and shoot the SPINNER while
// claiming to be the coverflow — a screenshot that lies is worse than a missing one.
dump(&mut s2, 80, 8, "07-library", true);
// Collections, the drill-in, on a library that actually has PLATFORMS — the scene above
// has none, so collating it would yield one group and witness nothing.
//
// The order below is load-bearing, and the reason there was no collections scene until a
// tile redesign needed one. `adopt_art` is a ONE-SHOT snapshot taken the moment Y is
// pressed, so art has to be pushed AND the shelf given frames to decode it BEFORE the
// press. Press first and every tile renders its monogram, and a deck of covers looks
// exactly like a deck that was never built.
//
// That same ordering — art before the game list — is what the fake-library dev hook does,
// and it MASKS the shelf's entrance defect (art is already decoded on the first Ready
// frame, so the entrance arms immediately). These scenes are evidence about the collection
// TILE and nothing else; do not read them as saying the entrance is well.
for (name, palette) in [
("07b-collections", "violet"),
("07b-collections-mint", "mint"),
] {
let (mut s3, _c3, _l3) = collections_shell();
s3.settings.ui_palette = palette.to_string();
dump(&mut s3, 12, 8, &format!("_{name}-decode"), true);
s3.handle_menu(MenuEvent::Secondary);
dump(&mut s3, 40, 8, name, true);
}
// …and the same screen with NOTHING decoded: the ghost slots and the monogram badge, which
// is the permanent look of a platform full of art-less ROM entries rather than a loading
// state. Pale, because that is where a hardcoded face strands its own initials.
{
let (mut s3, _c3, _l3) = collections_shell_no_art();
s3.settings.ui_palette = "mint".to_string();
dump(&mut s3, 12, 8, "_noart-settle", true);
s3.handle_menu(MenuEvent::Secondary);
dump(&mut s3, 40, 8, "07b-collections-noart", true);
}
// The wake and connecting overlays + a toast.
console.set_wake(Some(WakeStatus {
@@ -988,3 +1157,347 @@ fn dump_console_screens() {
s.session_failed("Connection timed out");
dump(&mut s, 10, 8, "10-toast", true);
}
/// A 2:3 poster, PNG-encoded, in a colour derived from `seed`.
///
/// Real encoded bytes rather than a stub, because the thing under test is the decode path:
/// `LibraryScreen` feeds these to `Image::from_encoded`, and a shape that fails to decode is
/// indistinguishable in a screenshot from a tile that chose to draw no cover.
fn poster_png(seed: usize) -> Vec<u8> {
let mut surface = skia_safe::surfaces::raster_n32_premul((60, 90)).unwrap();
let hue = [
(0.85, 0.30, 0.35),
(0.30, 0.55, 0.85),
(0.35, 0.75, 0.45),
(0.85, 0.65, 0.25),
][seed % 4];
surface
.canvas()
.clear(skia_safe::Color4f::new(hue.0, hue.1, hue.2, 1.0));
// A darker band across the lower third, so a cover is visibly ORIENTED — a flat colour
// would hide a cover drawn upside-down or with its aspect wrong.
surface.canvas().draw_rect(
skia_safe::Rect::from_xywh(0.0, 62.0, 60.0, 28.0),
&crate::theme::fill(skia_safe::Color4f::new(
hue.0 * 0.45,
hue.1 * 0.45,
hue.2 * 0.45,
1.0,
)),
);
surface
.image_snapshot()
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
.unwrap()
.as_bytes()
.to_vec()
}
/// Games across four platforms — what the collections screen is for, and what the flat
/// `platform: None` library above cannot produce.
fn platform_games() -> Vec<crate::library::LibraryGame> {
[
("Gran Turismo 6", "PlayStation 3"),
("The Last of Us", "PlayStation 3"),
("Demon's Souls", "PlayStation 3"),
("Halo 3", "Xbox 360"),
("Fable II", "Xbox 360"),
("Super Metroid", "SNES"),
("Chrono Trigger", "SNES"),
("Sonic 2", "Mega Drive"),
]
.iter()
.enumerate()
.map(|(i, (title, platform))| crate::library::LibraryGame {
id: format!("rom:{i}"),
title: (*title).to_string(),
store: "rom-manager".into(),
launcher: false,
icon: String::new(),
platform: Some((*platform).to_string()),
})
.collect()
}
fn collections_shell_inner(
with_art: bool,
) -> (Shell, ConsoleShared, crate::library::LibraryShared) {
fake_home();
let library = crate::library::LibraryShared::default();
let games = platform_games();
if with_art {
for (i, g) in games.iter().enumerate() {
library.push_art(g.id.clone(), poster_png(i));
}
}
library.set_games(games);
let console = ConsoleShared::default();
console.set_hosts(hosts());
let sh = Shell::new(
console.clone(),
library.clone(),
ConsoleBus::default(),
ConsoleOptions {
device_name: "deck".into(),
deck: false,
},
vec![
Screen::Home(HomeScreen::new()),
Screen::Library(LibraryScreen::new(&hosts()[0])),
],
)
.unwrap();
(sh, console, library)
}
fn collections_shell() -> (Shell, ConsoleShared, crate::library::LibraryShared) {
collections_shell_inner(true)
}
fn collections_shell_no_art() -> (Shell, ConsoleShared, crate::library::LibraryShared) {
collections_shell_inner(false)
}
/// The bounding box of everything lit on a raster surface, in pixels: `(left, right, bottom)`.
/// White ink on a cleared black field, so any channel answers.
fn ink_bounds(surface: &mut skia_safe::Surface, w: i32, h: i32) -> (i32, i32, i32) {
let mut pixels = vec![0u8; (w * h * 4) as usize];
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
assert!(
surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0)),
"raster surface read-back"
);
let (mut left, mut right, mut bottom) = (i32::MAX, i32::MIN, i32::MIN);
for (i, px) in pixels.chunks_exact(4).enumerate() {
if px[0] > 60 {
let (x, y) = (i as i32 % w, i as i32 / w);
left = left.min(x);
right = right.max(x);
bottom = bottom.max(y);
}
}
assert!(left <= right, "nothing was drawn");
(left, right, bottom)
}
/// A screen heading starts on its column and stays on ONE line.
///
/// Both halves are the defect this replaced. The heading used to be centred, which read as a
/// floating label rather than as a section heading — every other punktfunk client anchors it
/// to the leading edge — and, being a wrapping paragraph, a long host name grew a SECOND line
/// downward into the screen's content. Asserted against a control render of the same string
/// with room to spare rather than against a pixel row, so the line box is Geist's to define:
/// the clamped heading must occupy the same one line the unclamped one does.
#[test]
fn a_heading_starts_on_its_column_and_never_takes_a_second_line() {
let fonts = crate::theme::build_fonts().unwrap();
let (w, h) = (1200, 200);
let (x, y, size) = (crate::theme::EDGE_INSET, 18.0, 30.0);
let title = "Living Room PC · Performance · PlayStation 3";
let render = |max_w: f64| {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
surface
.canvas()
.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0));
fonts.heading(
surface.canvas(),
title,
crate::theme::W::Bold,
size,
skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0),
x,
y,
max_w,
);
ink_bounds(&mut surface, w, h)
};
// Room to spare: one line, and the ink begins at the column (a cap's left sidebearing
// puts it a pixel or two right of the paragraph's origin, never left of it).
let (loose_left, loose_right, loose_bottom) = render(1100.0);
assert!(
(loose_left as f64) >= x - 1.0 && (loose_left as f64) < x + 0.1 * 1100.0,
"heading ink starts at {loose_left}, which is not the {x} column"
);
assert!(
loose_right < w,
"the control render was clipped by the surface"
);
// Squeezed: it ellipsizes inside the budget instead of wrapping, so its ink ends where
// the budget does and its bottom stays on the control's single line.
let budget = 300.0;
let (tight_left, tight_right, tight_bottom) = render(budget);
assert_eq!(
tight_left, loose_left,
"clamping the width must not move the heading's left edge"
);
assert!(
(tight_right as f64) <= x + budget + 1.0,
"heading ran to {tight_right}, past its {} budget",
x + budget
);
assert!(
tight_bottom <= loose_bottom + 1,
"heading wrapped to a second line: it reaches {tight_bottom} where one line ends at \
{loose_bottom}"
);
}
/// The console's geometry is ANTI-ALIASED — the defect this pins shipped in the overhaul and
/// was only caught by looking at a Deck.
///
/// Skia defaults `SkPaint::fAntiAlias` to FALSE, so `Paint::new(colour, None)` — the terse and
/// obvious way to write a draw call — produces hard-stepped edges. The console drew nearly
/// everything that way: glass panels, the badge round-rects, the online pip, the D-pad and
/// PlayStation glyph paths. Only paints that happened to be mutated for some other reason (a
/// stroke style, a width) had picked up a `set_anti_alias(true)` along the way, which is why
/// the console shipped smooth 1 px rings sitting on top of jagged fills.
///
/// Asserted on a SHAPE rather than on a screen: a full render is a poor witness here — one
/// jagged corner is a few dozen pixels in 1.02 M, and no threshold that catches it survives an
/// unrelated palette tweak. A lone circle on a blank field is unambiguous. With AA its boundary
/// is a ring of PARTIAL coverage; without it every pixel is one of exactly two values.
#[test]
fn geometry_is_anti_aliased() {
let (w, h) = (64, 64);
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
surface
.canvas()
.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0));
// Deliberately off the pixel grid: a circle centred on a half-pixel has an edge that
// cannot be represented exactly, which is when AA is the whole difference.
surface.canvas().draw_circle(
skia_safe::Point::new(31.5, 31.5),
20.3,
&crate::theme::fill(skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0)),
);
let mut pixels = vec![0u8; (w * h * 4) as usize];
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
assert!(
surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0)),
"raster surface read-back"
);
// Red channel alone — the fill is white on black, so all three agree.
let partial = pixels
.chunks_exact(4)
.filter(|px| (8..248).contains(&px[0]))
.count();
assert!(
partial > 40,
"an anti-aliased circle of r≈20 has a boundary ring of partially covered pixels; found \
{partial}, which is what `Paint::new`'s aliased default looks like"
);
}
/// A shader-painted element actually PAINTS — the second trap in the same corner, and the one
/// that cost a whole screenshot round.
///
/// Skia modulates a shader's output by the paint's ALPHA. `Paint::default` is opaque black, so
/// the console's gradients and the aurora's runtime effect never noticed the rule existed; the
/// moment those paints were rebuilt from a "the shader supplies the colour anyway" transparent
/// placeholder, every one of them drew NOTHING. Not dimmer, not wrong-coloured — absent: the
/// backdrop, the badge, the vignette and the panel's gradient stroke all vanished at once, and
/// every test still passed, because a test that only renders a frame cannot tell a missing layer
/// from a dark one. `theme::shaded` is opaque by construction; this holds it to that.
#[test]
fn a_shaded_paint_is_opaque_enough_to_draw() {
let (w, h) = (32, 32);
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
surface
.canvas()
.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0));
let mut p = crate::theme::shaded();
let stops = [
skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0),
skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0),
];
p.set_shader(skia_safe::gradient::shaders::linear_gradient(
(
skia_safe::Point::new(0.0, 0.0),
skia_safe::Point::new(0.0, h as f32),
),
&skia_safe::gradient::Gradient::new(
skia_safe::gradient::Colors::new_evenly_spaced(
&stops,
skia_safe::TileMode::Clamp,
None,
),
skia_safe::gradient::Interpolation::default(),
),
None,
));
surface
.canvas()
.draw_rect(skia_safe::Rect::from_wh(w as f32, h as f32), &p);
let mut pixels = vec![0u8; (w * h * 4) as usize];
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
assert!(
surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0)),
"raster surface read-back"
);
let lit = pixels.chunks_exact(4).filter(|px| px[0] > 200).count();
assert_eq!(
lit,
(w * h) as usize,
"an opaque white gradient over the whole surface should light every pixel; a paint \
whose own alpha is 0 scales the shader away and leaves the field black"
);
}
/// …and every paint in the crate is built by `theme::fill`/`stroke`/`layer`, so the assertion
/// above keeps holding for code written after it.
///
/// A pixel test can only witness the shapes it happens to draw; this witnesses the CLASS. The
/// trap is that the aliased spelling is the NATURAL one — `&Paint::new(c, None)` passed inline
/// as an argument, no binding, no obvious place to hang a flag — so it reappears whenever a new
/// draw call is written, in whichever file is being worked on that day. Reading the crate's own
/// source is the only check that scales to that.
#[test]
fn paints_are_built_by_the_theme_constructors() {
// Split so the needles do not appear literally in this file — the scan reads its own
// source too, and a self-match is the first thing this test did.
let needles = [concat!("Paint", "::new("), concat!("Paint", "::default()")];
let mut offenders = Vec::new();
let mut stack = vec![std::path::PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src"
))];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("the crate's own src is readable") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
// theme.rs holds the sanctioned constructors, and is the one place the raw ones
// are allowed.
if path.file_name().is_some_and(|f| f == "theme.rs") {
continue;
}
let text = std::fs::read_to_string(&path).expect("source is UTF-8");
for (n, line) in text.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") || code.starts_with('*') {
continue;
}
if needles.iter().any(|needle| code.contains(needle)) {
let name = path.file_name().unwrap_or_default().to_string_lossy();
offenders.push(format!("{name}:{}: {code}", n + 1));
}
}
}
}
assert!(
offenders.is_empty(),
"these build a Skia paint directly, which means anti-aliasing is OFF on whatever they \
draw use `theme::fill`, `theme::stroke`, or `theme::layer` for a `save_layer` \
paint:\n {}",
offenders.join("\n ")
);
}
+30 -16
View File
@@ -11,7 +11,7 @@ use crate::model::{ConsoleBus, ConsoleShared, HostRow};
use crate::pointer::{Pointer, PointerKind};
use crate::screens::Screen;
use crate::shell::{ConsoleOptions, Shell};
use crate::theme::{match_first_family, Fonts};
use crate::theme::{fill, match_first_family, Fonts};
use anyhow::{anyhow, Context as _, Result};
use ash::vk as avk;
use ash::vk::Handle as _;
@@ -22,12 +22,26 @@ use pf_presenter::overlay::{
};
use skia_safe::gpu::vk as skvk;
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Point, RRect, Rect, Surface};
use std::time::Instant;
/// Skia's GPU resource budget — poster art plus a few screen layers; 64 MB fits
/// Deck-class shared memory.
const RESOURCE_CACHE_BYTES: usize = 64 << 20;
/// Skia's GPU resource budget — poster art plus a few screen layers.
///
/// A CEILING, not an allocation: Skia grows into it only under demand, and the console's
/// demand is now small — with the library's posters cached at the size they are drawn
/// (`screens::library::art_cache_size`) a full grid at Deck scale asks for ~30 MB. What
/// matters is the HEADROOM. At 64 MB the budget sat under a full grid's working set: a
/// screenful of full-resolution covers is ~100 MB, so `GrResourceCache` evicted a third of
/// them on every submit and the next frame re-decoded them from JPEG on the render thread.
/// That was the grid's slideshow, and a cliff rather than a slope — which is exactly how it
/// was reported, smooth until the screen filled.
///
/// 160 MB clears the working set several times over at every scale a panel up to 1440p
/// produces, with room for the two render targets and the glyph atlases. The one arrangement
/// that can still crowd it is a 4K panel (`k` 2.7, 33 MB a render target) fed 1000×1500
/// SteamGridDB portraits, where full resolution is genuinely what gets drawn — and that is a
/// desktop GPU by the time it happens.
pub(crate) const RESOURCE_CACHE_BYTES: usize = 160 << 20;
/// How long the start-of-stream banner lingers (fading through the tail).
const BANNER_S: f64 = 6.0;
@@ -751,9 +765,9 @@ fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, sca
let radius = base::OSD_RADIUS * scale;
canvas.draw_rrect(
RRect::new_rect_xy(panel, radius, radius),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62)),
);
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
let text_paint = fill(Color4f::new(1.0, 1.0, 1.0, 0.92));
for (i, line) in lines.iter().enumerate() {
canvas.draw_str(
line,
@@ -789,12 +803,12 @@ fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f3
let (x, y) = (width as f32 - w - margin, margin);
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62)),
);
canvas.draw_circle(
Point::new(x + pad_x + dot_r, y + h / 2.0),
dot_r,
&Paint::new(crate::theme::ERROR, None),
&fill(crate::theme::ERROR),
);
canvas.draw_str(
LABEL,
@@ -803,7 +817,7 @@ fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f3
y + pad_y - metrics.ascent,
),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
&fill(Color4f::new(1.0, 1.0, 1.0, 0.92)),
);
}
@@ -839,13 +853,13 @@ fn draw_access_chip(
let x = width as f32 - w - margin;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62)),
);
canvas.draw_str(
text,
Point::new(x + pad_x, y + pad_y - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
&fill(Color4f::new(1.0, 1.0, 1.0, 0.92)),
);
}
@@ -868,7 +882,7 @@ fn draw_resize_scrim(
let (wf, hf) = (width as f32, height as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
&fill(Color4f::new(0.0, 0.0, 0.0, 0.55)),
);
// Spinner slightly above center; the label sits below it.
let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0);
@@ -881,7 +895,7 @@ fn draw_resize_scrim(
label,
Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
&fill(Color4f::new(1.0, 1.0, 1.0, 0.9)),
);
}
@@ -914,12 +928,12 @@ fn draw_hint_pill(
let y = height as f32 - h - base::PILL_BOTTOM * scale;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha)),
);
canvas.draw_str(
text,
Point::new(x + pad_x, y + pad_y - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha), None),
&fill(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha)),
);
}
+275 -44
View File
@@ -14,6 +14,83 @@ use skia_safe::{
RRect, Rect, TileMode, Typeface,
};
// --- Paint ----------------------------------------------------------------------------------
/// A filled paint, ANTI-ALIASED. Build every fill in this crate here.
///
/// Skia's `SkPaint` defaults `fAntiAlias` to **false**, and `Paint::new(colour, None)` is that
/// default constructor with a colour on it — so the natural, terse way to write a Skia draw call
/// (`canvas.draw_rrect(rr, &Paint::new(c, None))`) silently produces HARD-STEPPED geometry. That
/// is the wrong default for this crate twice over: the console draws almost nothing axis-aligned
/// (round-rects, circles, arcs, D-pad and stick paths), and it is read from a couch on a 1280×800
/// Deck panel, where a stair-stepped 16 px glyph circle reads as a visible octagon.
///
/// The bug this exists to prevent is specific and it already happened: paints that got MUTATED
/// for some other reason (a stroke style, a width) collected a `set_anti_alias(true)` along the
/// way, while every inline `&Paint::new(…)` argument did not. The console therefore shipped
/// smooth 1 px rings drawn on top of jagged fills — which is worse-looking than no ring at all,
/// because the smooth edge gives the eye a reference for how wrong the fill is.
///
/// `theme::fill`/[`stroke`]/[`layer`] are the only sanctioned constructors, and
/// `shell::tests::paints_are_built_by_the_theme_constructors` fails the build if a bare
/// `Paint::new`/`Paint::default` reappears anywhere outside this file.
pub(crate) fn fill(color: Color4f) -> Paint {
let mut p = Paint::new(color, None);
p.set_anti_alias(true);
p
}
/// A stroking paint of `width` DEVICE pixels, anti-aliased. Callers scaling by `k` pass
/// `width * k` — nothing here knows about design units.
pub(crate) fn stroke(color: Color4f, width: f32) -> Paint {
let mut p = fill(color);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(width);
p
}
/// A paint whose colour comes from a SHADER — a gradient, or the aurora's runtime effect.
/// Anti-aliased, and OPAQUE by construction, which is the whole point of it existing.
///
/// The colour channel is unused once a shader is attached, so the obvious thing is to build
/// one of these from a transparent placeholder and let the shader supply everything. That is
/// a trap: Skia modulates a shader's output by the PAINT'S ALPHA, so an alpha-0 placeholder
/// draws nothing whatever the shader says. It is a silent, total failure — the element simply
/// is not there — and it is invisible to a test that only asserts a frame renders without
/// panicking. `Paint::default` happened to be opaque black and so never showed the problem;
/// anything replacing it has to be deliberately opaque, so that is what this is.
pub(crate) fn shaded() -> Paint {
fill(Color4f::new(0.0, 0.0, 0.0, 1.0))
}
/// [`shaded`]'s stroking twin — a gradient hairline, opaque so the gradient survives.
pub(crate) fn shaded_stroke(width: f32) -> Paint {
let mut p = shaded();
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(width);
p
}
/// The paint for a `save_layer` — it carries alpha and colour filters, and never any geometry
/// of its own, so anti-aliasing has nothing to act on. Its own constructor so the AA guard (and
/// a reader) can tell a compositing paint from a drawing one without reading the call site.
pub(crate) fn layer() -> Paint {
Paint::default()
}
/// How the console samples bitmap art (poster/cover images, launcher icons).
///
/// `Canvas::draw_image_rect`'s default is `SamplingOptions::default()` — `FilterMode::Nearest`
/// with `MipmapMode::None`, i.e. NO filtering at all. Every cover in the library is minified
/// hard (a 600×900 poster into a ~180×270 Deck cell), and nearest-neighbour minification drops
/// whole rows and columns of source pixels: box-art lettering breaks up, edges crawl as the
/// shelf scrolls, and the result reads as "low resolution" no matter what the panel is. Linear
/// with a linear mipmap chain is the fix — the mip level does the bulk of the reduction, so the
/// filter is never asked to shrink by more than 2×, which is the one thing bilinear does well.
pub(crate) fn art_sampling() -> skia_safe::SamplingOptions {
skia_safe::SamplingOptions::new(skia_safe::FilterMode::Linear, skia_safe::MipmapMode::Linear)
}
// --- Ink ----------------------------------------------------------------------------------
/// The error/status red (the GTK client's #ff938a). Fixed: a warning must not change meaning
@@ -132,6 +209,26 @@ pub(crate) fn shade(alpha: f32) -> Color4f {
Color4f::new(s.r, s.g, s.b, alpha * s.a)
}
/// An OPAQUE card face, `tint` of the way from the ground side of the field toward the
/// palette's accent — the backdrop for a cover we have no art for.
///
/// Opaque is the constraint that rules the alternatives out: coverflow side cards OVERLAP, so
/// a glass face would show its neighbour through it, and `accent(0.20)` over nothing is
/// exactly that. Mixing the same tint into a base the field's own lean chooses (black under a
/// dark palette, white under a pale one, which is what [`Ink::scrim`] already knows) gets the
/// accent tint with no alpha spent.
///
/// The pairing with [`fg`] is the point of it. A fixed near-black face carrying `fg()` ink was
/// legible on the seven dark palettes and ABSENT on the six pale ones, where `fg()` is itself a
/// near-black tinted toward the ground — the two composited to 1.03:1. Face and ink now move in
/// opposite directions with the palette, so they separate at both poles by construction.
pub(crate) fn card_face(tint: f32) -> Color4f {
let a = ink().accent;
let base = if ink().scrim.r > 0.5 { 1.0 } else { 0.0 };
let mix = |c: f32| c * tint + base * (1.0 - tint);
Color4f::new(mix(a.r), mix(a.g), mix(a.b), 1.0)
}
/// Ink that reads ON the accent (a filled key, a selected pill): whichever of black or white
/// the accent has more room for. Chosen by luminance rather than by `light`, because an accent
/// is picked for contrast against the GLASS, not against the field.
@@ -169,14 +266,14 @@ pub(crate) fn panel(
k: f32,
) {
let rr = RRect::new_rect_xy(rect, corner * k, corner * k);
canvas.draw_rrect(rr, &Paint::new(ink().glass, None));
canvas.draw_rrect(rr, &fill(ink().glass));
if let Some(tint) = tint {
canvas.draw_rrect(rr, &Paint::new(tint, None));
canvas.draw_rrect(rr, &fill(tint));
}
let mut sp = Paint::default();
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
// Opaque to start with: the Plain/Brand arms overwrite the colour outright, and the
// gradient arms attach a shader whose output this paint's alpha would otherwise scale
// away to nothing.
let mut sp = shaded_stroke(1.0);
match stroke {
PanelStroke::Plain(alpha) => {
sp.set_color4f(fg(alpha), None);
@@ -205,7 +302,6 @@ pub(crate) fn panel(
canvas.draw_rrect(rr, &sp);
}
/// 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.
///
@@ -215,9 +311,8 @@ pub(crate) fn panel(
/// 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.
/// luminance-weighted saturation matrix (Rec. 709 weights) scaled by `sat`, the whole of it
/// then LERPED toward the ground: `out = (1 b)·sat_mix(c) + ground·b`.
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;
@@ -228,27 +323,37 @@ pub(crate) fn recede_matrix(d: f64) -> [f32; 20] {
// 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 };
// A FRACTION of the way to the ground, not a level offset. SwiftUI's `.brightness()` is
// additive and this matched it, which meant 0.24 was 61/255 on every channel and
// Skia's colour matrix clamps: the coverflow's own #1E1E25 placeholder came out at
// literal #000000, the whole side stack a single black slab with no depth in it and no
// cover-art detail left to see. A lerp cannot clip at either pole, and it is also the
// arithmetic "receding into the field" actually means — a fixed subtraction is a
// different amount of recede for every card and total annihilation for a dark one.
let b = (RECEDE_BRIGHTNESS * d) as f32;
let ground = if toward_light { 1.0f32 } else { 0.0 };
let keep = 1.0 - b;
let offset = ground * b;
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,
keep * (ir + sat),
keep * ig,
keep * ib,
0.0,
bright,
ir,
ig + sat,
ib,
offset,
keep * ir,
keep * (ig + sat),
keep * ib,
0.0,
bright,
ir,
ig,
ib + sat,
offset,
keep * ir,
keep * ig,
keep * (ib + sat),
0.0,
bright,
offset,
0.0,
0.0,
0.0,
@@ -257,10 +362,16 @@ pub(crate) fn recede_matrix(d: f64) -> [f32; 20] {
]
}
/// 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;
/// How much colour a fully receded neighbour loses. Gentle enough that a side card still
/// reads as the artwork it is — drain more and the shelf looks like a filter was applied to
/// it rather than like the cards are standing further away. Cannot go below 0.125 while the
/// brightness term is 0.20: `recede_matrix_drains_colour_and_light_but_never_alpha` wants
/// the channel spread cut by 30 %, and spread scales exactly as `(1 b)·sat`.
const RECEDE_SATURATION: f64 = 0.34;
/// …and how far it travels toward the ground, as a FRACTION of the distance — never as a
/// level offset, whatever the Apple gamepad UI's `.brightness()` does. See
/// [`recede_matrix`]: an additive term clips, and a card dark enough clips to nothing.
const RECEDE_BRIGHTNESS: f64 = 0.20;
/// 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,
@@ -272,10 +383,7 @@ const RECEDE_BRIGHTNESS: f64 = 0.24;
/// 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 mut p = shaded_stroke(k.max(1.0));
let colors = [fg(0.10), fg(0.0)];
p.set_shader(gradient::shaders::linear_gradient(
(
@@ -299,20 +407,54 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
if f <= 0.01 {
return;
}
let mut p = Paint::new(accent(0.28 * f), None);
// Every pale palette's accent is DARK — mint 0.34 luma, sunset 0.26, opal 0.33 — so a
// blurred accent on a pale field is a smudge, and the focused tile came out the dirtiest
// thing in the row while its unfocused neighbours stayed clean and light: the focus mark
// inverted. Mixing halfway to the scrim (white there) keeps the palette's own hue while
// making the mark read as light. It needs a little more body to register once lightened.
let (a, s) = (ink().accent, ink().scrim);
let (c, alpha) = if s.r > 0.5 {
let mix = |x: f32, y: f32| x + (y - x) * 0.5;
(
Color4f::new(mix(a.r, s.r), mix(a.g, s.g), mix(a.b, s.b), 1.0),
0.24 * f,
)
} else {
(a, 0.20 * f)
};
let mut p = fill(Color4f::new(c.r, c.g, c.b, alpha));
// Outer, not Normal: Normal keeps the blurred shape's INTERIOR, so the halo also filled
// the card's own footprint at full accent. On the home and collections tiles the panel
// glass over it is translucent (α 0.62 dark, 0.66 pale), so a third of that came through
// the face and the focused card read as a lit blob rather than as a card with light
// spilling around it.
p.set_mask_filter(MaskFilter::blur(
skia_safe::BlurStyle::Normal,
18.0 * k,
skia_safe::BlurStyle::Outer,
10.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));
// side, where the shadow below it is the card's weight falling in one direction. The
// reach is outset + 3σ and it has to stay INSIDE the gap to the next card: at 6 + 3·18
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
// farthest-first the focused card's corona landed on top of its neighbours — which is
// what made every card look like it was glowing.
let spread = rect.with_outset((4.0 * k, 4.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);
// Black under a tile is WEIGHT on a dark field and DIRT on a pale one, where it is the
// heaviest mark on the screen: on `holo` and `sunset` the focused tile sat in a muddy
// grey-brown ring while every unfocused tile stayed clean. Scaled back at the pale pole
// the same way the scrim already scales itself, so the caller's alpha keeps meaning
// "dark-field strength" and no call site has to know which palette is up.
let alpha = if ink().scrim.r > 0.5 {
alpha * 0.40
} else {
alpha
};
let mut p = fill(Color4f::new(0.0, 0.0, 0.0, alpha));
p.set_mask_filter(MaskFilter::blur(
skia_safe::BlurStyle::Normal,
10.0 * k,
@@ -333,11 +475,8 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph
/// The loading/connecting spinner: a rotating 270° arc driven by the shell clock.
pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(fg(0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width((r / 5.0) as f32);
let mut paint = stroke(fg(0.85), (r / 5.0) as f32);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
paint.set_anti_alias(true);
canvas.draw_arc(
Rect::from_xywh(
(cx - r) as f32,
@@ -352,6 +491,21 @@ pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
);
}
// --- Layout -------------------------------------------------------------------------------
/// How far in from a screen's edge its CHROME sits, design units — the heading, the section
/// strip under it and the controller chip on the right all share this one column.
///
/// The number is the other clients' verbatim: Apple pads every gamepad heading and its tab
/// strip `.horizontal, 24`, Android names it `ConsoleEdgeInset = 24.dp`. It is deliberately
/// NOT the legend's 18 — that is a PILL's edge, whose first glyph lands at 31, so matching it
/// would misalign the very thing it was copied from.
///
/// It is a screen inset, not a content margin: the rows, the carousel and the coverflow are
/// all CENTRED columns, so aligning a heading to one would mean tracking `(width column)/2`,
/// which is an artefact of the window size rather than a margin anyone chose.
pub(crate) const EDGE_INSET: f64 = 24.0;
// --- Typography ---------------------------------------------------------------------------
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
@@ -445,7 +599,7 @@ impl Fonts {
text,
Point::new(x as f32, baseline as f32),
&font,
&Paint::new(color, None),
&fill(color),
);
font.measure_str(text, None).0
}
@@ -465,7 +619,7 @@ impl Fonts {
color: Color4f,
) {
let font = self.font(w, size);
let paint = Paint::new(color, None);
let paint = fill(color);
let mut pen = x as f32;
let mut buf = [0u8; 4];
for ch in text.chars() {
@@ -475,6 +629,10 @@ impl Fonts {
}
}
/// `clamp` caps the paragraph at that many lines and ellipsizes what doesn't fit; `None`
/// wraps freely. A heading has to clamp — an over-long one used to grow DOWNWARD into the
/// screen's content, which is why both other clients pin theirs to one line.
#[allow(clippy::too_many_arguments)]
fn paragraph(
&self,
text: &str,
@@ -483,9 +641,14 @@ impl Fonts {
color: Color4f,
align: TextAlign,
max_w: f64,
clamp: Option<usize>,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
if let Some(lines) = clamp {
style.set_max_lines(lines);
style.set_ellipsis("\u{2026}");
}
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(size as f32);
@@ -525,10 +688,53 @@ impl Fonts {
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w);
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w, None);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
/// [`centered`](Self::centered)'s LEFT-ALIGNED twin: `x` is the text's left edge, `y` its
/// top. Same paragraph path, so it shapes and falls back for CJK exactly as `centered`
/// does — which is why the screen chrome cannot use `draw`/`draw_clipped` instead.
#[allow(clippy::too_many_arguments)]
pub(crate) fn leading(
&self,
canvas: &Canvas,
text: &str,
w: W,
size: f64,
color: Color4f,
x: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, None);
p.paint(canvas, Point::new(x as f32, y as f32));
}
/// A screen's heading: left-aligned at `x`, top edge at `y`, clamped to ONE ellipsized
/// line at `max_w`.
///
/// Every punktfunk client anchors its console heading to the leading edge — Apple's
/// carries the note that a centred one "read as a floating label" rather than as a
/// section heading, Android's `ConsoleHeader` pins it to `ConsoleEdgeInset`. The single
/// line is not cosmetic either: left-aligned, a long host name would otherwise wrap under
/// the controller chip and push a second line into the content.
#[allow(clippy::too_many_arguments)]
pub(crate) fn heading(
&self,
canvas: &Canvas,
text: &str,
w: W,
size: f64,
color: Color4f,
x: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, Some(1));
p.paint(canvas, Point::new(x as f32, y as f32));
}
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
/// host/game titles that may exceed their tile.
#[allow(clippy::too_many_arguments)]
@@ -673,4 +879,29 @@ mod tests {
set_ink(DARK_INK);
}
/// The recede must never CLAMP. The other three assertions here are all relative and
/// none of them feeds the matrix a dark input, which is exactly how a brightness term
/// that subtracted 61/255 in the offset column shipped: the coverflow's own placeholder
/// face came out at literal #000000 on every card a slot or more from the focus, so the
/// side stack was one black slab with no depth and no cover-art detail in it. `apply`
/// omits Skia's clamp deliberately, so a channel below zero here IS the shipped bug.
#[test]
fn a_dark_card_face_survives_a_full_recede() {
// Every dark palette's coverless card, at the quieter of the two tints
// `screens::library::draw_poster_placeholder` draws — the darkest face the shelf has.
for p in crate::library::PALETTES.iter().filter(|p| !p.light) {
set_ink(Ink::of(p));
let f = card_face(0.20);
let out = apply(&recede_matrix(1.0), [f.r, f.g, f.b, 1.0]);
for c in &out[..3] {
assert!(
*c > 0.05,
"the recede crushed {}'s card face to black: {out:?}",
p.id
);
}
}
set_ink(DARK_INK);
}
}
+402 -49
View File
@@ -7,7 +7,7 @@
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};
use crate::theme::{accent, fg, fill, stroke, Fonts, PanelStroke, EDGE_INSET, W};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Paint, PathBuilder, RRect, Rect};
@@ -93,6 +93,26 @@ const PRESS_DIP: f64 = 0.97;
/// showing off.
const ROW_RISE: f64 = 12.0;
/// The value a step left behind, and everything its half of the crossfade needs to draw.
struct SlipPrev {
/// Which row it belongs to — by index AND label, because the screen rebuilds the row
/// set from scratch every frame. A row appearing or dropping above the cursor would
/// otherwise hand this text to whatever inherited the index, i.e. slide one setting's
/// old value across a different setting's row.
row: usize,
label: String,
text: String,
/// Where this 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.
offset: f64,
/// The slip position the step armed at, and so the span the crossfade divides by.
/// Deliberately not [`SLIP_DP`]: a held repeat accumulates as far as [`SLIP_MAX`], and
/// normalising that against the smaller constant leaves the INCOMING value at alpha 0
/// for the first third of the travel — a fast repeat showing nothing but stale text.
arm: f64,
}
/// 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 {
@@ -113,11 +133,9 @@ pub(crate) struct MenuList {
/// 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)>,
/// The value the slip is sliding OUT, and where — `None` whenever nothing is mid-step,
/// which is also what tells every OTHER row it has no crossfade to draw.
slip_prev: Option<SlipPrev>,
/// 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.
@@ -316,20 +334,40 @@ impl MenuList {
// 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.
// screen having to report whether its edit landed. The direction is taken
// unconditionally so a step can never leak into a later frame, but only a row that
// ADVERTISES stepping may slip: A and a pointer press arm one whatever the row is, so
// a row whose value merely re-reads differently under the press — a profile's "Pinned
// to 3 hosts" as the count changes — would otherwise slide its value sideways with no
// chevrons on screen ever having promised that it steps.
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();
let stepped = if dir != 0 && !reduce {
rows.get(self.cursor).filter(|r| r.adjustable)
} else {
None
};
if let Some(row) = stepped {
let now = row.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)));
// A step that exactly cancels one still in flight leaves no travel to
// crossfade over — and no span to normalise the fade against — so the new
// value simply takes the row.
self.slip_prev = if self.slip.pos == 0.0 {
None
} else {
Some(SlipPrev {
row: self.cursor,
label: row.label.clone(),
text: prev,
offset: -SLIP_DP * f64::from(dir),
arm: self.slip.pos,
})
};
}
}
self.slip.step_spec(0.0, springs::FOCUS, dt);
@@ -480,59 +518,102 @@ impl MenuList {
};
let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 };
let caret_w = if row.caret { 8.0 * k } else { 0.0 };
// The value FIELD: a fixed right edge and a maximum width, with every string
// right-aligned against that edge by its OWN measured width. Sharing one
// anchor computed from the incoming value is what threw the outgoing text
// off the row: left-aligned on someone else's alignment it started life
// displaced by the width difference and hung that far past the field, which
// on "PyroWave (wired LAN)" → "Automatic" is most of a hundred px.
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 val_right = x0 + row_w - 16.0 * k - chevron_w - caret_w;
let place = |s: &str| val_right - f64::from(fonts.measure(s, W::Medium, 15.0 * k));
// The sprung slip + crossfade. `slip_prev` names its ROW — index and label
// both, since an index alone is not an identity across a rebuild — 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(|p| p.row == i && p.label == row.label);
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;
// Gated on the SLIPPING row exactly as `dx` above is. There is one slip
// spring for the whole list, so an ungated fade read the same spring on
// every row and blanked the entire value column each time any one value
// stepped. Signed rather than `.abs()`, so the deliberate overshoot back
// through zero clamps to nothing instead of fading the ghost in again.
let gone = slipping.map_or(0.0, |p| (self.slip.pos / p.arm).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 {
// Head-truncate: keep the END of a long address visible while typing. Done
// BEFORE placing, so what is right-aligned is the string actually drawn —
// measuring the untruncated one left every long value floating short of the
// edge by whatever the ellipsis saved.
let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax);
// Nothing else confines the pair: the widget's only clip is the LIST rect,
// which is the full window width, leaving a couple of hundred px of open
// background either side of the row for a sliding value to cross. Only while
// there is travel to hide — a settled value fits the field by construction,
// and a clip per row per frame is not free.
if slipping.is_some() {
canvas.save();
canvas.clip_rect(
Rect::from_ltrb(
(val_right - vmax) as f32,
r.top,
val_right as f32,
r.bottom,
),
None,
true,
);
}
if let Some(p) = 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);
let prev_text = truncate_head(fonts, &p.text, W::Medium, 15.0 * k, vmax);
fonts.draw(
canvas,
&prev_text,
vx + dx + offset * k,
place(&prev_text) + dx + p.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 + dx,
place(&shown) + dx,
baseline,
W::Medium,
15.0 * k,
alpha(vcolor, 1.0 - gone),
);
if slipping.is_some() {
canvas.restore(); // the value field
}
if row.caret {
// Rides `dx` so the caret stays welded to the end of the text rather
// than detaching from it mid-slip.
canvas.draw_rect(
Rect::from_xywh(
(vx + vw + 3.0 * k) as f32,
(val_right + 3.0 * k + dx) as f32,
(cy - 9.0 * k) as f32,
(2.0 * k) as f32,
(18.0 * k) as f32,
),
&Paint::new(accent(1.0), None),
&fill(accent(1.0)),
);
}
if row.adjustable && f > 0.01 {
let alpha = 0.6 * f as f32;
chevron(canvas, vx - 11.0 * k, cy, 4.0 * k, true, alpha);
// Drawn after, and outside the field's clip: the chevrons frame the
// value, so a value on the move passes UNDER them.
chevron(canvas, place(&shown) - 11.0 * k, cy, 4.0 * k, true, alpha);
chevron(canvas, x0 + row_w - 16.0 * k, cy, 4.0 * k, false, alpha);
}
}
@@ -566,7 +647,34 @@ pub(crate) struct TabStrip {
pills: Vec<Rect>,
}
/// A pill's label size and its padding either side, in design units.
const PILL_TEXT: f64 = 13.0;
const PILL_PAD_X: f64 = 13.0;
/// Air between two pills.
const PILL_GAP: f64 = 7.0;
/// Each pill's width and the run's total, in device px.
///
/// Shared with [`TabStrip::width`] rather than computed twice, because a caller that
/// RIGHT-ALIGNS a strip has to know the run's width before the strip draws itself — and a
/// second copy of this arithmetic would put the measured edge somewhere the drawn edge is not.
fn pill_widths(labels: &[&str], fonts: &Fonts, k: f64) -> (Vec<f64>, f64) {
let size = PILL_TEXT * k;
let widths: Vec<f64> = labels
.iter()
.map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * PILL_PAD_X * k)
.collect();
let total = widths.iter().sum::<f64>() + PILL_GAP * k * (labels.len().saturating_sub(1)) as f64;
(widths, total)
}
impl TabStrip {
/// How wide this run of pills draws. For a caller placing the strip against a TRAILING
/// edge, where the position depends on the width.
pub(crate) fn width(labels: &[&str], fonts: &Fonts, k: f64) -> f64 {
pill_widths(labels, fonts, k).1
}
pub(crate) fn new() -> TabStrip {
TabStrip {
indicator: None,
@@ -588,7 +696,8 @@ impl TabStrip {
p.press().then(|| p.pick(&self.pills)).flatten()
}
/// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already
/// Draw the pills along the leading edge of `rect`'s top band, at the same
/// [`EDGE_INSET`] the heading above them uses. Returns nothing — the caller already
/// knows the band is [`TAB_STRIP_H`] tall.
#[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's
pub(crate) fn render(
@@ -604,16 +713,24 @@ impl TabStrip {
if labels.is_empty() {
return;
}
let size = 13.0 * k;
let pad_x = 13.0 * k;
let gap = 7.0 * k;
let pill_h = 30.0 * k;
let widths: Vec<f64> = labels
.iter()
.map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x)
.collect();
let total: f64 = widths.iter().sum::<f64>() + gap * (labels.len() - 1) as f64;
let mut x = f64::from(rect.left) + (f64::from(rect.width()) - total) / 2.0;
let size = PILL_TEXT * k;
let (widths, total) = pill_widths(labels, fonts, k);
let gap = PILL_GAP * k;
// Leading, under the heading it belongs to — a strip centred beneath a left-aligned
// title reads as two unrelated pieces of chrome. Both callers hand this widget the
// full content width, so the inset is measured here rather than baked into the band.
//
// Written as a clamp rather than a branch so it degrades CONTINUOUSLY as the window
// narrows: full inset while both fit, then centred, then hard against the leading
// edge once the run is wider than the band. That last case spends its overflow on
// the right, which is the side the user has not looked at yet — losing the first
// section instead would cost them the one the strip is read from. Neither reference
// client can reach it (both scroll their strip) and the narrowest window the console
// is tested at still clears the first case.
let inset = EDGE_INSET * k;
let slack = f64::from(rect.width()) - total;
let mut x = f64::from(rect.left) + inset.min((slack / 2.0).max(0.0));
let top = f64::from(rect.top) + 2.0 * k;
// Where the highlight wants to be, then the eased position it actually draws at.
@@ -698,11 +815,8 @@ fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> Stri
fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
let dir = if left { -1.0 } else { 1.0 };
let mut p = Paint::new(fg(alpha), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.8 * r / 4.0) as f32);
let mut p = stroke(fg(alpha), (1.8 * r / 4.0) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_anti_alias(true);
let mut path = PathBuilder::new();
path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
@@ -927,7 +1041,7 @@ impl Keyboard {
let focused = r == self.row && c == self.col;
let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32);
self.keys.push((kr, *key));
let fill = if focused {
let face = if focused {
let mut b = accent(1.0);
if self.key_flash > 0.02 {
// A just-typed key flashes brighter, then eases back.
@@ -945,7 +1059,7 @@ impl Keyboard {
};
canvas.draw_rrect(
RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32),
&Paint::new(fill, None),
&fill(face),
);
// The focused key is filled with the accent, so its letter needs ink that
// reads on THAT, not on the field.
@@ -995,13 +1109,12 @@ impl Keyboard {
}
}
/// A round-capped, round-joined stroke — the console's hand-drawn marks (chevrons, ticks,
/// the pad silhouettes) all want those, so they get their own wrapper over `theme::stroke`.
fn stroke_paint(ink: skia_safe::Color4f, width: f32) -> Paint {
let mut p = Paint::new(ink, None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(width);
let mut p = stroke(ink, width);
p.set_stroke_cap(skia_safe::PaintCap::Round);
p.set_stroke_join(skia_safe::PaintJoin::Round);
p.set_anti_alias(true);
p
}
@@ -1189,6 +1302,66 @@ mod tests {
);
}
/// The strip stands on the same column as the heading above it — [`EDGE_INSET`], scaled
/// like every other design unit — and gives that column up only in the order a shrinking
/// window forces: inset, then centred, then flush. Asserted as that ordering rather than
/// against three measured x's, so it still means something when a tab is renamed.
#[test]
fn tab_strip_stands_on_the_edge_inset_and_gives_it_up_in_order() {
let fonts = crate::theme::build_fonts().unwrap();
let mut surface = skia_safe::surfaces::raster_n32_premul((1400, 160)).unwrap();
let dt = 1.0 / 60.0;
// What the seven sections actually measure, so nothing below is a hardcoded pixel.
let mut run = |w: f32, k: f64| {
let rect = Rect::from_xywh(0.0, 0.0, w, (TAB_STRIP_H * k) as f32);
let mut strip = TabStrip::new();
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, k, dt);
let first = strip.pill(0).expect("the first section was drawn");
let last = strip
.pill(TABS.len() - 1)
.expect("the last section was drawn");
(rect, f64::from(first.left), f64::from(last.right))
};
// Room for both insets: the strip starts exactly on the heading's column, at every
// scale, and still ends inside the band.
for k in [0.75, 1.0, 2.0] {
let (rect, left, right) = run(1400.0, k);
assert!(
(left - (f64::from(rect.left) + EDGE_INSET * k)).abs() < 0.5,
"k={k}: strip starts at {left}, not on the {} column",
EDGE_INSET * k
);
assert!(
right <= f64::from(rect.right),
"k={k}: strip overran its band"
);
}
let (_, wide_left, wide_right) = run(1400.0, 1.0);
let total = wide_right - wide_left;
// Too narrow for both insets but still wider than the run: it centres, which is the
// only placement that does not spend the whole shortfall on one edge.
let (rect, left, right) = run((total + EDGE_INSET) as f32, 1.0);
assert!(
(left - f64::from(rect.left) - (f64::from(rect.right) - right)).abs() < 0.5,
"a squeezed strip should sit even: {left} in from the left, {} from the right",
f64::from(rect.right) - right
);
// Wider than the band: flush against the leading edge, overflowing right only.
let (rect, left, right) = run((total - 40.0) as f32, 1.0);
assert!(
(left - f64::from(rect.left)).abs() < 0.5,
"an overflowing strip should go flush left, not to {left}"
);
assert!(
right > f64::from(rect.right),
"the run was supposed to overflow"
);
}
fn value_row(value: &str) -> Vec<RowSpec> {
vec![RowSpec {
header: None,
@@ -1201,6 +1374,186 @@ mod tests {
}]
}
/// One row's pixel region to compare across renders: `(row index, (x0, x1), (y0, y1))`.
type Band = (usize, (i32, i32), (i32, i32));
/// A whole COLUMN of steppable value rows — the shape the settings screen actually
/// draws, and the one thing a single-row list cannot stand in for: on a one-row list the
/// slipping row is the only row, so a crossfade that leaks onto its neighbours has no
/// neighbours to leak onto.
fn value_rows(values: &[&str]) -> Vec<RowSpec> {
values
.iter()
.enumerate()
.map(|(i, v)| RowSpec {
header: None,
label: format!("Option {i}"),
value: Some((*v).to_string()),
value_dim: false,
caret: false,
adjustable: true,
enabled: true,
})
.collect()
}
fn read_back(surface: &mut skia_safe::Surface, w: i32, h: i32) -> Vec<u8> {
let mut px = vec![0u8; (w * h * 4) as usize];
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
assert!(
surface.read_pixels(&info, &mut px, (w * 4) as usize, (0, 0)),
"raster surface read-back"
);
px
}
/// How many bytes of one band of two readbacks differ. A COUNT rather than the two
/// slices, because `assert_eq!` on a megapixel prints a megapixel.
fn band_diff(a: &[u8], b: &[u8], w: i32, x: (i32, i32), y: (i32, i32)) -> usize {
let mut differing = 0;
for row in y.0..y.1 {
let base = (row * w * 4) as usize;
let span = base + x.0 as usize * 4..base + x.1 as usize * 4;
differing += a[span.clone()]
.iter()
.zip(&b[span])
.filter(|(p, q)| p != q)
.count();
}
differing
}
/// Stepping ONE row's value leaves every other row's pixels exactly as they were.
///
/// The list owns a SINGLE slip spring, so the crossfade alpha derived from it has to be
/// gated on the slipping row's identity the way its sibling displacement already is.
/// Ungated, one press dropped EVERY value in the list to alpha 0 and faded them back
/// over ~200 ms while the labels sat still — a whole settings column blinking on one
/// step, and worse on a held repeat, where the accumulated slip pins the alpha at zero
/// for several consecutive frames.
#[test]
fn stepping_one_value_leaves_the_other_rows_alone() {
let fonts = crate::theme::build_fonts().unwrap();
let (w, h) = (900, 600);
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
let rect = Rect::from_xywh(0.0, 0.0, w as f32, h as f32);
let clear = skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0);
let dt = 1.0 / 60.0;
let before = value_rows(&["Native", "Automatic", "20 Mbps", "Balanced", "On", "Off"]);
let mut list = MenuList::new();
// Settled first: the mount entrance, the scroll and the focus ease all travel on
// their own, and the claim under test is that nothing ELSE moves.
for _ in 0..240 {
surface.canvas().clear(clear);
list.render(surface.canvas(), rect, &before, &fonts, 1.0, dt, true);
}
let bands: Vec<Band> = (1..before.len())
.map(|i| {
let r = list.row_rect(i).expect("every row is on screen");
(
i,
(r.left as i32, r.right as i32),
(r.top as i32, r.bottom as i32),
)
})
.collect();
let settled = read_back(&mut surface, w, h);
// The bands really are where those values live: a row whose value string differs
// draws differently in exactly this region, so the equality asserted below is not a
// comparison of two identical patches of background.
let mut probe = MenuList::new();
let probed = value_rows(&["Native", "Automatic", "20 Mbps", "Native", "On", "Off"]);
for _ in 0..240 {
surface.canvas().clear(clear);
probe.render(surface.canvas(), rect, &probed, &fonts, 1.0, dt, true);
}
let probe_px = read_back(&mut surface, w, h);
let (_, px_x, px_y) = bands[2];
assert!(
band_diff(&probe_px, &settled, w, px_x, px_y) > 0,
"row 3's band must contain row 3's value"
);
assert_eq!(
list.menu(MenuEvent::Move(MenuDir::Right), before.len()).0,
ListMsg::Adjust(1)
);
let after = value_rows(&[
"Match window",
"Automatic",
"20 Mbps",
"Balanced",
"On",
"Off",
]);
let mut armed = false;
for frame in 0..12 {
surface.canvas().clear(clear);
list.render(surface.canvas(), rect, &after, &fonts, 1.0, dt, true);
armed |= list.slip_prev.is_some();
let px = read_back(&mut surface, w, h);
for (i, bx, by) in &bands {
assert_eq!(
band_diff(&px, &settled, w, *bx, *by),
0,
"row {i} redrew on frame {frame} of a step made on row 0"
);
}
}
assert!(armed, "the step must have animated, or this proves nothing");
}
/// A stepped value stays inside its field, however wide the value it is leaving.
///
/// Both halves of the crossfade used to share ONE anchor — the right-alignment computed
/// from the INCOMING string's width — so the outgoing text was left-aligned on someone
/// else's alignment: it appeared displaced by the width difference and hung that far
/// past the field, at full alpha, for the whole spring. Nothing clipped it either; the
/// widget's only clip is the LIST rect, which is the full window width. "PyroWave (wired
/// LAN)" → "AV1" is most of a hundred px of ghost text sitting on the background.
///
/// Asserted on the band OUTSIDE the field, whose ink — the chevron, the panel's own
/// edge — is fixed once the row has settled, so any change there is text that escaped.
#[test]
fn a_stepped_value_stays_inside_its_field() {
let fonts = crate::theme::build_fonts().unwrap();
let (w, h) = (900, 600);
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
let rect = Rect::from_xywh(0.0, 0.0, w as f32, h as f32);
let clear = skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0);
let dt = 1.0 / 60.0;
let mut list = MenuList::new();
let wide = value_row("PyroWave (wired LAN)");
for _ in 0..240 {
surface.canvas().clear(clear);
list.render(surface.canvas(), rect, &wide, &fonts, 1.0, dt, true);
}
let r = list.row_rect(0).expect("the row is on screen");
// The field's right edge, as `render` computes it: the row's 16 dp gutter plus the
// 18 dp the chevrons reserve, at k = 1. Two px of slack for the clip's own edge.
let field_right = (f64::from(r.right) - 16.0 - 18.0).ceil() as i32;
let outside = (field_right + 2, w);
let band_y = (r.top as i32, r.bottom.ceil() as i32);
let settled = read_back(&mut surface, w, h);
list.menu(MenuEvent::Move(MenuDir::Right), 1);
let narrow = value_row("AV1");
let mut armed = false;
for frame in 0..16 {
surface.canvas().clear(clear);
list.render(surface.canvas(), rect, &narrow, &fonts, 1.0, dt, true);
armed |= list.slip_prev.is_some();
let px = read_back(&mut surface, w, h);
assert_eq!(
band_diff(&px, &settled, w, outside, band_y),
0,
"value ink escaped the field on frame {frame}"
);
}
assert!(armed, "the step must have animated, or this proves nothing");
}
/// 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.
+6 -7
View File
@@ -274,11 +274,10 @@ client, which reads this setting like any other connect. The console home also o
explicit action on an offline host, whatever the toggle says. See
[Wake-on-LAN](/docs/wake-on-lan).
**Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse
a paired host's games and launch one directly; the Windows app still labels it experimental. The
console home has the toggle too, and it governs the desktop clients that share the store — the
console's own **Library** button is offered on any paired host either way. See
[Game library](/docs/game-library).
**Show game library** — *Apple and Android only, default: on.* Browse a paired host's games and
launch one directly. The Linux and Windows apps have nothing to switch on — **Browse library…** sits
on every paired host's card — and neither does the console home, whose **Library** button was always
offered on any paired host. See [Game library](/docs/game-library).
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
@@ -350,8 +349,8 @@ stay global and **cannot be put in a settings profile**:
preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a
preference too, and does live in a profile — a work profile can decline to forward what a game
profile forwards.
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
not about how a given host is streamed.
- **Auto-wake on connect**, and **Show game library** where it still exists (the Apple and Android
apps) — decisions about this device and this network, not about how a given host is streamed.
- Everything under **Interface****Gamepad-optimized browsing**, **Show it** and **Background**.
How this client looks and which layout it wears has nothing to do with how a host streams to it,
so binding them to a host would only make the same device change appearance depending on what it
+4 -5
View File
@@ -133,11 +133,10 @@ confirmation open as usual, but the host refuses the change and the entry stays
Whatever the surface, the client sends only an **id**. The host looks that id up in its own library
and runs what it already knows about the title, so a client can never hand the host a command to run.
- **Native clients** — the browser is a per-device setting in **Settings → Library**, and it needs a
**paired** host. It is **off by default on the Linux and Windows clients** ("Show game library",
or "Show game library (experimental)" on Windows) and **on by default on macOS, iOS, iPadOS, tvOS
and Android**. Turn it on and a paired host's card offers **Browse library** (**Browse Library…**
on Apple); pick a title and the stream starts with the host launching it. See
- **Native clients** — the browser needs a **paired** host, and that is the only condition: a paired
host's card offers **Browse library…** (**Browse Library…** on Apple) with nothing to switch on
first. Pick a title and the stream starts with the host launching it. The Apple and Android apps
keep a **Show game library** switch, on by default, for turning it off. See
[Client settings](/docs/client-settings).
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
+8 -9
View File
@@ -41,9 +41,9 @@ into a "no" on your machine:
and deliberately so: the host resolves the chroma *before* the Welcome and names the losing gate
in its log, and the client's stats overlay prints `4:4:4→4:2:0` rather than letting you assume
you got what you asked for.
- **Default on / opt-in / operator-gated** — HDR and 10-bit are attempted by default; the game
library is off by default on the desktop clients; the shared clipboard is off on the host until
an operator turns it on.
- **Default on / opt-in / operator-gated** — HDR and 10-bit are attempted by default; lossless audio
stays on Opus until a client asks for it; the shared clipboard is off on the host until an
operator turns it on.
One more thing a table cannot show: a few capabilities **latch off for the rest of the host
process** after a failure — a lost HDR negotiation, a repeatedly dying zero-copy import worker, a
@@ -395,8 +395,8 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
| Client | Profiles | `punktfunk://` links | Game library | Speed test | Wake-on-LAN | Updates itself |
|---|---|---|---|---|---|---|
| Linux desktop | ✅ | ✅ | ⚠️ ¹ | ✅ | ✅ | ⚠️ ² |
| Windows desktop | ✅ | ✅ | ⚠️ ¹ | ✅ | ✅ | ❌ ³ |
| Linux desktop | ✅ | ✅ | ¹ | ✅ | ✅ | ⚠️ ² |
| Windows desktop | ✅ | ✅ | ¹ | ✅ | ✅ | ❌ ³ |
| macOS | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
| iPhone · iPad | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
| Apple TV | ⚠️ ⁵ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
@@ -405,10 +405,9 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
| `punktfunk` CLI | ✅ | ✅ ⁹ | ✅ | ✅ | ✅ | ❌ |
| Moonlight | ❌ ¹⁰ | ❌ ¹⁰ | ✅ ¹¹ | ❓ ¹² | ❓ ¹² | ❓ ¹² |
1. **Off by default.** Turn on the library in Preferences before "Browse library…" appears. It also
needs a paired host to return anything — Windows hides the entry until the host is paired; the
GTK client shows it either way and the fetch then fails. See
[Game library](/docs/game-library).
1. **Any paired host.** "Browse library…" sits on a saved host's card with nothing to turn on first.
It stays hidden on a host that is only trusted, not yet paired: the fetch authenticates with the
pairing identity and would come back refused. See [Game library](/docs/game-library).
2. One of two clients with a self-updater (the Decky plugin is the other), and what `--apply-update`
can do depends on how you
installed it: Flatpak updates itself, system packages go through a packaged helper, image-based