feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s

Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.

A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.

A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.

The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.

Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.

That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.

Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
This commit is contained in:
2026-08-08 12:57:12 +02:00
parent 1ef212a78d
commit 30bd10e301
28 changed files with 761 additions and 130 deletions
+6 -6
View File
@@ -1174,12 +1174,12 @@ pub struct Settings {
/// 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 `tide`/`forest`/`ember`/
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
/// why it is a device preference and never part of a settings profile. An unknown
/// name reads as the default rather than erroring — a newer client may have shipped a
/// palette this binary doesn't know.
/// `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
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
/// stream depends on it, which is why it is a device preference and never part of a
/// settings profile. An unknown name reads as the default rather than erroring — a
/// newer client may have shipped a palette this binary doesn't know.
#[serde(default = "default_ui_palette")]
pub ui_palette: String,
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
+50 -4
View File
@@ -246,17 +246,34 @@ const CELL_RAMP: [f64; 16] = [
-0.10, 0.08, -0.06, 0.12,
];
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
/// gain the same entry to keep the `ui_palette` key portable.
#[rustfmt::skip]
pub const PALETTES: [Palette; 12] = [
pub const PALETTES: [Palette; 13] = [
// --- dark fields (white ink) ---
Palette {
id: "violet", name: "Violet", stops: None,
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is pure
// black too: the calm mix on the form screens lifts toward nothing, so settings and
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
// luminance while keeping the backdrop a field with somewhere to go rather than a
// dead rectangle. The accent stays the brand violet — focus has to be findable on
// black.
id: "oled", name: "OLED",
stops: Some(&[
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
]),
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -857,7 +874,7 @@ mod tests {
assert_eq!(
ids,
[
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"bloom", "dawn", "mint", "opal",
]
);
@@ -867,7 +884,36 @@ mod tests {
.position(|p| p.light)
.expect("some are light");
assert!(PALETTES[first_light..].iter().all(|p| p.light));
assert_eq!(first_light, 6);
assert_eq!(first_light, 7);
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
/// under every other field's, and a ground that lifts to nothing on the form screens.
#[test]
fn oled_is_actually_black() {
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
let oled = palette("oled");
assert_eq!(
oled.ground,
(0.0, 0.0, 0.0),
"the calm lift must be nothing"
);
let cells = oled.mesh_colors();
assert!(
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
"the shaded corner has to be switched off, not dimmed"
);
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
let darkest_other = PALETTES
.iter()
.filter(|p| p.id != "oled")
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
.fold(f64::MAX, f64::min);
assert!(
mean < darkest_other / 2.0,
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
);
}
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
+158 -44
View File
@@ -258,11 +258,17 @@ impl SettingsScreen {
}
}
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
/// profile, or the explainer placeholder while there are none.
fn row_ids(&self) -> Vec<RowId> {
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
/// explainer placeholder while there are none.
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
if self.tab != PROFILES_TAB {
return TABS[self.tab].1.to_vec();
return TABS[self.tab]
.1
.iter()
.copied()
.filter(|id| row_applies(*id, ctx.settings))
.collect();
}
if self.profiles.is_empty() {
vec![RowId::NoProfiles]
@@ -271,6 +277,16 @@ impl SettingsScreen {
}
}
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
/// take it away between frames while this screen is open.
fn clamp_cursor(&mut self, len: usize) {
if self.list.cursor >= len {
self.list.jump_to(len.saturating_sub(1));
}
}
#[cfg(test)]
pub(crate) fn tab_for_test(&self) -> usize {
self.tab
@@ -278,21 +294,22 @@ impl SettingsScreen {
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
/// value cycle), keeping each tab's own cursor.
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
let n = TABS.len() as i32;
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
}
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
/// a press on a pill names a tab outright rather than a direction to step in.
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
if tab >= TABS.len() {
return None;
}
self.tab_cursors[self.tab] = self.list.cursor;
self.tab = tab;
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
let len = self.row_ids().len();
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
// Video's follows whether the smoothness buffer is offered.
let len = self.row_ids(ctx).len();
self.list
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
Some(MenuPulse::Move)
@@ -302,10 +319,11 @@ impl SettingsScreen {
/// there is never meant for a row.
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
if let Some(tab) = self.strip.pointer(p) {
self.show_tab(tab);
self.show_tab(tab, ctx);
return true;
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.pointer(p, ids.len());
if matches!(msg, ListMsg::None) && pulse.is_none() {
return false;
@@ -325,11 +343,12 @@ impl SettingsScreen {
fx.pop();
return None;
}
MenuEvent::JumpBack => return self.switch_tab(-1),
MenuEvent::JumpForward => return self.switch_tab(1),
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
_ => {}
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.menu(ev, ids.len());
self.apply_row(msg, pulse, &ids, ctx, fx)
}
@@ -344,8 +363,14 @@ impl SettingsScreen {
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
// A cursor with no row under it can only mean the list shrank between the clamp above
// and here, which nothing does today — but indexing on the assumption would turn that
// into a panic in a shipping console rather than a dropped keypress.
let Some(&focused) = ids.get(self.list.cursor) else {
return pulse;
};
// The Profiles rows navigate instead of editing the settings file.
match ids[self.list.cursor] {
match focused {
RowId::Profile(i) => {
return match msg {
ListMsg::Activate => {
@@ -378,7 +403,7 @@ impl SettingsScreen {
}
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
let changed = adjust(focused, delta, false, ctx);
if changed {
ctx.settings.save();
Some(MenuPulse::Move)
@@ -388,7 +413,7 @@ impl SettingsScreen {
}
ListMsg::Activate => {
// A cycles forward WRAPPING, so every option is reachable one-handed.
if adjust(ids[self.list.cursor], 1, true, ctx) {
if adjust(focused, 1, true, ctx) {
ctx.settings.save();
}
pulse
@@ -397,8 +422,8 @@ impl SettingsScreen {
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
let ids = self.row_ids();
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
let ids = self.row_ids(ctx);
// The shoulders always change section, so that hint leads on every row.
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
hints.extend(match ids.get(self.list.cursor) {
@@ -445,7 +470,8 @@ impl SettingsScreen {
rect.right,
rect.bottom - detail_h as f32,
);
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
@@ -466,6 +492,24 @@ impl SettingsScreen {
}
}
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
///
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
/// can see a line or two above them, so dimming them shows the relationship — dropping them
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
/// so the cursor is never under anything that moves.
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
match id {
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
}
}
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
_ => {}
}
let s = &ctx.settings;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
// — the same relationship the desktop shells draw by greying a row out (they hide the
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
// move everything under the cursor).
// Two rows follow a switch a line or two above them: echo cancellation only means
// anything while the mic streams, and the pad rows only while any controller is
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
// shells draw by greying a row out, and dimming (not dropping) is what shows the
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
// list instead now — see [`row_applies`] for why that one is different.
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
}
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
// is only reachable if another writer flipped the intent between the frame that built
// the list and the keypress that lands here — a boundary thud, not a stored value
// nothing will read.
RowId::SmoothBuffer => {
if s.present_priority == "smooth" {
let cur = SMOOTH_BUFFERS
@@ -1093,9 +1139,6 @@ mod tests {
fake_home();
let mut s = SettingsScreen::with_profiles(Vec::new());
rendered(&mut s);
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids()[0], RowId::Resolution);
let first = s.list.row_rect(0).expect("the list drew its rows");
let (mut settings, pads) = ctx_parts();
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
@@ -1109,6 +1152,9 @@ mod tests {
device_name: "t",
t: 0.0,
};
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
let mut fx = Outbox::default();
assert!(!ctx.settings.match_window);
assert!(s.pointer(press(first), &mut ctx, &mut fx));
@@ -1232,13 +1278,12 @@ mod tests {
assert!(ctx.settings.echo_cancel);
}
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
/// row list dims it, because a row vanishing mid-list would shift everything under the
/// cursor.
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
/// always done; this screen was the exception until its row list stopped being fixed.
#[test]
fn smoothness_buffer_follows_the_intent() {
fn smoothness_buffer_is_offered_only_under_smoothness() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.present_priority, "latency", "the shipped default");
let library = crate::library::LibraryShared::default();
@@ -1251,24 +1296,93 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
let video = s.row_ids(&ctx);
assert!(
!video.contains(&RowId::SmoothBuffer),
"latency hides the buffer row: {video:?}"
);
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
// Even reached out of band it writes nothing — the list it came from is a frame old.
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
);
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
// Stepping the intent to Smoothness brings the buffer row to life.
// Stepping the intent to Smoothness brings the row into the list, directly under it.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
let intent = video
.iter()
.position(|id| *id == RowId::PresentPriority)
.expect("the intent row");
assert_eq!(
video.get(intent + 1),
Some(&RowId::SmoothBuffer),
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
never has anything move out from under it"
);
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
assert_eq!(ctx.settings.smooth_buffer, 1);
// The intent wraps back and the row goes inert again.
// The intent wraps back and the row leaves again — with the cursor parked on the
// intent row, which is where a user who just stepped it necessarily is.
s.list.cursor = intent;
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "latency");
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
assert!(!video.contains(&RowId::SmoothBuffer));
assert_eq!(
video.get(s.list.cursor),
Some(&RowId::PresentPriority),
"the cursor is still on the row the user was stepping"
);
}
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
/// than indexed with — the console must not panic because another writer changed the
/// presentation intent while its settings screen was open.
#[test]
fn a_shrinking_list_pulls_the_cursor_back() {
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
// seated with the SHRUNKEN list's intent, which is the state being tested.
fake_home();
let (mut settings, pads) = ctx_parts();
settings.present_priority = "latency".into();
settings.save();
settings.present_priority = "smooth".into();
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,
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
// Park on the last row while the buffer row is still there…
s.list.cursor = s.row_ids(&ctx).len() - 1;
let parked = s.list.cursor;
// …then take it away behind the screen's back, as a desktop shell would.
ctx.settings.present_priority = "latency".into();
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(pulse.is_some(), "the press was routed, not dropped");
assert!(s.list.cursor < parked, "the cursor came back onto the list");
assert!(fx.nav.is_none());
}
#[test]
@@ -1392,7 +1506,7 @@ mod tests {
("p2".into(), "Game".into()),
]);
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
@@ -1438,7 +1552,7 @@ mod tests {
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::NoProfiles]);
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
assert!(!spec.enabled);
+1 -1
View File
@@ -329,7 +329,7 @@ fn dump_console_screens() {
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpForward);
}
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
s.settings.ui_palette = id.to_string();
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
}