feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped

The console settings were one 30-row scroll, which on a Deck meant thumbing past
Video and Audio to reach the pad settings. They are now split across sections —
Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the
desktop console, which alone carries the touch/mouse rows. L1/R1 walks them,
each section remembers where its cursor was, and the names are the same word on
every client so a setting is where you looked for it last.

Shoulders are not the only route, because a D-pad remote hasn't got any: on
Android, Up from the first row moves onto the strip (left/right walks sections
there, A drops back in), and on tvOS the pills are focusable, so the focus
engine handles it — a Siri Remote has no extended gamepad profile and never
reaches the input poll at all. The desktop console needs neither; PageUp and
PageDown already map to the same events.

New "Background" row, six palettes: Violet (the brand default), Tide, Forest,
Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over
the ONE colour field each client already draws, so every palette inherits its
structure and Violet is the identity transform — existing installs see exactly
what they see today. The maths is ported three times (Rust/Swift/Kotlin) under
one shared `ui_palette` key, with the same assertions pinned in each language.
It is presentation only, so it is a device preference and never part of a
profile.

The form screens no longer have a backdrop of their own. Settings, add-host and
pair used to sit on a still gradient; they now wear the same living field at a
calm mix — pools dimmed onto the palette's own corner colour, vignette halved so
rows that run to the edges don't get crushed. On the desktop console that
collapsed the old aurora-over-static crossfade into one shader pass with a
chased uniform. Motion speed is identical in both modes on purpose: changing it
would make the field jump mid-transition. Nothing in the gamepad UI is backed by
a static image now, and Reduce Motion (Apple) / "remove animations" (Android)
still freeze it.

Also: the settings screen had no raster coverage at all — the eyeball dump is
`#[ignore]`d — so a new test draws every tab, and the Android screenshot set
gains a console-settings scene. Both earned their keep immediately: the renders
showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now,
and the Section cell only appears where shoulders exist) and the form backdrop
crushing its own edges.
This commit is contained in:
2026-08-06 12:39:30 +02:00
parent 35ba64ca0f
commit 5a7f7f0fc5
25 changed files with 1805 additions and 414 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ impl Shell {
canvas.save_layer_alpha_f(None, appear as f32);
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
// console taking over rather than a card popping up.
self.draw_aurora(canvas, w, h, t);
self.draw_aurora(canvas, w, h, t, 0.0);
// A soft pool of shade under the centre seats the white text against a bright aurora.
let mut vignette = Paint::default();
vignette.set_shader(gradient_shader::radial(
+5 -12
View File
@@ -8,7 +8,7 @@ use crate::screens::{Bg, Ctx, Screen};
use crate::theme::{white, Fonts, PanelStroke, W, WHITE};
use pf_client_core::gamepad::PadInfo;
use pf_client_core::trust;
use skia_safe::{Canvas, Color4f, Rect};
use skia_safe::{Canvas, Rect};
use std::time::Instant;
use super::{Motion, Shell, BOTTOM_BAND, TOP_BAND};
@@ -67,7 +67,9 @@ impl Shell {
}
};
// Backdrop crossfade follows the top screen.
// The backdrop settles into (or out of) calm with the screen transition. It is the
// SAME living field either way — a form screen quiets it, it doesn't replace it —
// so this is one shader pass with a chased uniform, not two stacked backdrops.
let bg_target = match self.stack.last().expect("non-empty").background() {
Bg::Aurora => 0.0,
Bg::Form => 1.0,
@@ -76,16 +78,7 @@ impl Shell {
if (self.bg_mix - bg_target).abs() < 0.005 {
self.bg_mix = bg_target;
}
if self.bg_mix < 1.0 {
self.draw_aurora(canvas, w, h, t);
} else {
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
}
if self.bg_mix > 0.0 {
canvas.save_layer_alpha_f(None, self.bg_mix as f32);
crate::theme::draw_form_background(canvas, w, h);
canvas.restore();
}
self.draw_aurora(canvas, w, h, t, self.bg_mix);
// The screens, through the transition choreography.
let content = Rect::from_ltrb(
+61
View File
@@ -167,6 +167,47 @@ fn wake_gates_input_in_the_same_press() {
assert!(s.handle_menu(MenuEvent::Move(MenuDir::Left)).is_some());
}
/// Every settings tab actually RASTERS. The eyeball dump below is `#[ignore]`d, so without
/// this nothing in the normal gate ever ran the tab strip's layout arithmetic or a settings
/// screen's rows — a bad index there would only surface on a Deck. CPU raster: the SkSL
/// backdrop, the layers and the text all run without a GPU.
#[test]
fn every_settings_tab_rasters() {
let fonts = crate::theme::build_fonts().unwrap();
let (w, h) = (1280u32, 800u32);
let pads: Vec<PadInfo> = Vec::new();
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.handle_menu(MenuEvent::Tertiary); // X → Settings
let mut frame = |s: &mut Shell| {
s.render(
surface.canvas(),
w,
h,
&fonts,
Some("Xbox Wireless Controller"),
Some(GamepadPref::Xbox360),
&pads,
);
};
// One lap of the strip — R1 wraps back to where it started. Every tab's rows fit on an
// 800-tall window at once, so ONE frame per tab draws all of them; the cursor is walked to
// the end first (input only, no render) so the focused and unfocused row paths both run.
// Deliberately frugal: a full-screen SkSL field on the CPU costs the better part of a second
// per frame in a debug build, and this test's job is to catch a panic, not to look pretty.
for _ in 0..crate::screens::settings::TAB_COUNT {
for _ in 0..12 {
s.handle_menu(MenuEvent::Move(MenuDir::Down));
}
frame(&mut s);
s.handle_menu(MenuEvent::JumpForward);
}
// A narrow window is the case the strip has to shrink for (the pills are laid out from
// measured text, so a too-small width must clamp rather than lay out off-screen).
s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads);
}
/// Render every console scene to PNGs for the eyeball pass (ignored; run with
/// `PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui --release -- --ignored dump`).
/// CPU raster — the SkSL aurora, layers and text all run without a GPU.
@@ -208,6 +249,26 @@ fn dump_console_screens() {
dump(&mut s, 3, 25, "02-transition", true);
dump(&mut s, 40, 8, "03-settings", true);
// The Interface tab (5 shoulder presses along) leads with the Background row, so this frame
// shows both the strip mid-list and the palette picker…
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpForward);
}
dump(&mut s, 40, 8, "03b-settings-interface", true);
// …and cycling it three times lands on Ember, which is the whole point: the CALM backdrop
// behind these rows recolours live.
for _ in 0..3 {
s.handle_menu(MenuEvent::Confirm);
}
dump(&mut s, 40, 8, "03c-settings-ember", true);
// Back to the brand default and the first tab so the later scenes look like they always did.
for _ in 0..3 {
s.handle_menu(MenuEvent::Confirm);
}
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpBack);
}
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
s.handle_menu(MenuEvent::Back);
dump(&mut s, 40, 8, "_back", true);