From 30bd10e3011e68c8f3ec742a02feffc2a4ceecae Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 12:57:12 +0200 Subject: [PATCH] feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/main/kotlin/io/unom/punktfunk/App.kt | 9 +- .../io/unom/punktfunk/GamepadPalette.kt | 18 +- .../unom/punktfunk/GamepadSettingsScreen.kt | 15 ++ .../kotlin/io/unom/punktfunk/GamepadUi.kt | 30 ++- .../main/kotlin/io/unom/punktfunk/Settings.kt | 31 ++- .../io/unom/punktfunk/SettingsScreen.kt | 17 +- .../io/unom/punktfunk/GamepadPaletteTest.kt | 23 +- .../unom/punktfunk/GamepadSettingsRowsTest.kt | 43 ++++ .../kotlin/io/unom/punktfunk/GamepadUiTest.kt | 53 +++++ .../kotlin/io/unom/punktfunk/ProfilesTest.kt | 1 + .../Sources/PunktfunkClient/ContentView.swift | 7 +- .../PunktfunkClient/Home/GamepadInk.swift | 32 ++- .../PunktfunkClient/Home/LibraryView.swift | 15 +- .../Settings/GamepadSettingsView.swift | 26 +++ .../Settings/SettingsOptions.swift | 8 + .../Settings/SettingsView+Sections.swift | 19 +- .../Settings/SettingsView.swift | 23 ++ .../PunktfunkClient/Support/GlassStyle.swift | 49 +++-- .../Gamepad/GamepadUIEnvironment.swift | 34 ++- .../PunktfunkShared/DefaultsKeys.swift | 23 +- .../PunktfunkShared/GamepadPalette.swift | 14 +- .../GamepadPaletteTests.swift | 23 +- .../GamepadUIEnvironmentTests.swift | 56 ++++- crates/pf-client-core/src/trust.rs | 12 +- crates/pf-console-ui/src/library.rs | 54 ++++- crates/pf-console-ui/src/screens/settings.rs | 202 ++++++++++++++---- crates/pf-console-ui/src/shell/tests.rs | 2 +- docs-site/content/docs/client-settings.md | 52 ++++- 28 files changed, 761 insertions(+), 130 deletions(-) create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadUiTest.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index d24bcf08..f21cc243 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) { // later manual Back out of the library is not undone by a stale value. var reopenLibraryHostId by remember { mutableStateOf(null) } - // Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is - // a TV OR the dev force flag). Flips live as controllers connect/disconnect. + // Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a + // pad is attached OR this is a TV OR the dev force flag). Flips live as controllers + // connect/disconnect — unless the mode is Always, where it simply stays. val tv = remember { isTvDevice(context) } val controllerConnected by rememberControllerConnected() - val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi) + val gamepadUi = gamepadUiActive( + settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi, + ) // Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND // activity instance (the normal case under `launchMode = standard`) can refuse it before that diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt index 9e4a04d2..aca24aeb 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt @@ -67,7 +67,7 @@ class GamepadPalette( ) /** - * The twelve shipped palettes: the brand default, five more dark fields, then six pale + * 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 range one way. */ val ALL = listOf( @@ -77,6 +77,22 @@ class GamepadPalette( ground = Triple(0.075, 0.060, 0.160), accent = Triple(0.525, 0.471, 0.961), light = false, ), + GamepadPalette( + // For OLED and AMOLED panels, where a black pixel is a pixel switched off — no + // glow, no power. The first two stops are literally (0,0,0), so the 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. What is + // left is a faint indigo→violet ember in the bright corner. The accent stays the + // brand violet — focus has to be findable on black. + "oled", "OLED", + listOf( + Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000), + Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115), + Triple(0.120, 0.024, 0.130), + ), + ground = Triple(0.0, 0.0, 0.0), + accent = Triple(0.525, 0.471, 0.961), light = false, + ), GamepadPalette( // Deep indigo climbing through violet into a hot magenta. "nebula", "Nebula", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index a9611574..040152fa 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -665,6 +665,21 @@ internal fun buildSettingsRows( "Turn off to use the touch interface even with a controller connected.", s.gamepadUiEnabled, ) { update(s.copy(gamepadUiEnabled = it)) }, + ) + listOfNotNull( + // WHEN the switch above takes over. Built only while it is ON: turn the switch off from + // this very screen and the row under the cursor would otherwise be one deciding nothing, + // on a screen that is itself about to disappear. + if (s.gamepadUiEnabled) { + choice( + "gamepadUIMode", GpTab.INTERFACE, null, "Show it", + "With a controller: the touch interface comes back when the last one " + + "disconnects. Always keeps this layout either way — for a device that lives " + + "docked to a TV. A TV itself is always in this mode regardless.", + GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode, + ) { update(s.copy(gamepadUiMode = it)) } + } else { + null + }, ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt index 7c2019f4..6c27a6a9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt @@ -16,15 +16,35 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import io.unom.punktfunk.kit.Gamepad +/** + * [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what + * the switch meant when it was a lone Boolean. + */ +const val GAMEPAD_UI_WHEN_CONNECTED = "connected" + +/** + * [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or + * tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not + * always awake. + */ +const val GAMEPAD_UI_ALWAYS = "always" + /** * Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should * replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`: - * the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced] - * flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the - * console UI (as long as the setting is on). + * the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached + * OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is + * the only input, so it's always the console UI (as long as the setting is on), which is why the + * mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer + * client wrote can never strand this one in a layout it has no way back out of. */ -fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean = - enabled && (controllerConnected || tv || forced) +fun gamepadUiActive( + enabled: Boolean, + mode: String, + controllerConnected: Boolean, + tv: Boolean, + forced: Boolean, +): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced) /** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */ fun isTvDevice(context: Context): Boolean { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 9b1048a1..27cb4975 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -94,11 +94,20 @@ data class Settings( val touchMode: TouchMode = TouchMode.TRACKPAD, /** * Swap the whole home screen for the controller-optimized "console" UI (the host carousel + - * gamepad chrome) whenever a controller is connected — mirrors the Apple client's - * `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached. + * gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off + * to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode]. * A TV (leanback) is always in this mode regardless (its remote/pad is the only input). */ val gamepadUiEnabled: Boolean = true, + /** + * When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair, + * mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch + * has always meant) waits for a controller; `"always"` keeps the console UI with no pad in + * reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled] + * is on, which is why both settings screens hide the row when the switch is off. Anything + * unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode. + */ + val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED, /** * Show the experimental game-library browser (the coverflow reached with Y from a saved host). * Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple @@ -107,9 +116,10 @@ data class Settings( val libraryEnabled: Boolean = true, /** * Which colour family the console (gamepad) UI's living backdrop drifts through — the - * cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`, - * `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the - * desktop console's and the Apple client's under the same names. Presentation only: nothing + * cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`, + * `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See + * [GamepadPalette], whose table and maths mirror the desktop console's and the Apple + * client's under the same names. Presentation only: nothing * about a stream depends on it, so it is a device preference and never part of a profile. * An unknown value reads as the default rather than failing — a newer client may have shipped * a palette this build doesn't know. @@ -303,6 +313,8 @@ class SettingsStore(context: Context) { // Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct). ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true), + gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED) + ?: GAMEPAD_UI_WHEN_CONNECTED, libraryEnabled = prefs.getBoolean(K_LIBRARY, true), uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet", lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), @@ -344,6 +356,7 @@ class SettingsStore(context: Context) { .putString(K_STATS_VERBOSITY, s.statsVerbosity.name) .putString(K_TOUCH_MODE, s.touchMode.name) .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) + .putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode) .putBoolean(K_LIBRARY, s.libraryEnabled) .putString(K_UI_PALETTE, s.uiPalette) .putBoolean(K_LOW_LATENCY, s.lowLatencyMode) @@ -384,6 +397,7 @@ class SettingsStore(context: Context) { const val K_HUD = "stats_hud_enabled" const val K_TOUCH_MODE = "touch_mode" const val K_GAMEPAD_UI = "gamepad_ui_enabled" + const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode" const val K_LIBRARY = "library_enabled" const val K_UI_PALETTE = "ui_palette" @@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List> { ) } +/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim. + * Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */ +val GAMEPAD_UI_MODE_OPTIONS = listOf( + GAMEPAD_UI_WHEN_CONNECTED to "With a controller", + GAMEPAD_UI_ALWAYS to "Always", +) + /** (mode, label) for the touch-input model. */ val TOUCH_MODE_OPTIONS = listOf( TouchMode.TRACKPAD to "Trackpad", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 1a737062..3e49b414 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) { SettingsGroup("Interface") { ToggleRow( title = "Controller-optimized UI", - subtitle = "Switch to the console home when a controller is connected. A TV " + - "always uses it.", + subtitle = "Swap the touch home for the console home — the host carousel and " + + "gamepad chrome. A TV always uses it.", checked = s.gamepadUiEnabled, onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) }, ) + // Only decides anything while the switch above is on, so it is HIDDEN rather than + // dimmed when it isn't — a picker whose every option changes nothing is worse than + // no picker, and this group is short enough that nothing jumps far. + if (s.gamepadUiEnabled) { + SettingDropdown( + label = "Show it", + options = GAMEPAD_UI_MODE_OPTIONS, + selected = s.gamepadUiMode, + caption = "With a controller: the touch home comes back when the last one " + + "disconnects. Always keeps the console home either way — for a device " + + "that lives docked to a TV.", + ) { v -> update(s.copy(gamepadUiMode = v)) } + } } } } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt index bff22ff0..8cf959c2 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt @@ -33,14 +33,14 @@ class GamepadPaletteTest { fun tableMatchesTheOtherClients() { assertEquals( listOf( - "violet", "nebula", "abyss", "ember", "moss", "graphite", + "violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset", "bloom", "dawn", "mint", "opal", ), GamepadPalette.ALL.map { it.id }, ) // Dark fields lead, pale ones follow, so stepping the row walks one direction. val firstLight = GamepadPalette.ALL.indexOfFirst { it.light } - assertEquals(6, firstLight) + assertEquals(7, firstLight) assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light }) // An unknown name is a newer client's palette, not an error. assertEquals("violet", GamepadPalette.named("chartreuse").id) @@ -72,6 +72,25 @@ class GamepadPaletteTest { } } + /** + * OLED is the one palette whose selling point is measurable: it has to be genuinely black, + * not merely the darkest of the dark fields. The blob field this client draws samples the + * ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp. + */ + @Test + fun oledIsActuallyBlack() { + val oled = GamepadPalette.named("oled") + assertEquals(Triple(0.0, 0.0, 0.0), oled.ground) + assertEquals(0f, oled.blobColors[0].red, 1e-6f) + assertEquals(0f, oled.blobColors[0].green, 1e-6f) + assertEquals(0f, oled.blobColors[0].blue, 1e-6f) + val mean = oled.stops.sumOf { luma(it) } / oled.stops.size + val darkestOther = GamepadPalette.ALL + .filter { it.id != "oled" && it.stops.isNotEmpty() } + .minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size } + assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2) + } + /** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */ @Test fun palettesAreHonestAboutLightness() { diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt index 2f47864d..2c78c8cc 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt @@ -95,4 +95,47 @@ class GamepadSettingsRowsTest { // Drawn as a switch, and reading the persisted default. assertEquals(true, row(on, "dsCapture").toggled) } + + /** + * The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is + * OFFERED only while that switch is on — hidden rather than dimmed, because with the switch + * off this whole screen is about to be replaced by the touch UI and a dimmed row there would + * be one last thing to step past on the way out. + */ + @Test + fun `the activation-mode row follows the switch it belongs to`() { + fun ids(enabled: Boolean) = buildSettingsRows( + Settings(gamepadUiEnabled = enabled), + hasBodyVibrator = false, hasGyroscope = false, av1Capable = false, + ) {}.map { it.id } + + val on = ids(enabled = true) + assertTrue("the mode row is missing", "gamepadUIMode" in on) + assertEquals( + "the mode belongs directly under the switch it qualifies", + on.indexOf("gamepadUI") + 1, + on.indexOf("gamepadUIMode"), + ) + val off = ids(enabled = false) + assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off) + assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off) + } + + /** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */ + @Test + fun `the activation-mode row steps the shared key`() { + var s = Settings() + fun mode() = buildSettingsRows( + s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false, + ) { s = it }.first { it.id == "gamepadUIMode" } + + assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode) + assertEquals("With a controller", mode().value) + assertFalse("already the first = thud", mode().adjust(-1)) + assertTrue(mode().adjust(1)) + assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode) + // A from the last entry wraps home. + mode().activate() + assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode) + } } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadUiTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadUiTest.kt new file mode 100644 index 00000000..9b431596 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadUiTest.kt @@ -0,0 +1,53 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's + * `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a + * disagreement here is a device that behaves differently from the same setting. + */ +class GamepadUiTest { + + /** The default mode is what the switch meant when it was a lone Boolean. */ + @Test + fun whenConnectedWaitsForAPad() { + assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false)) + assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false)) + assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false)) + assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false)) + // A TV is in console mode whatever the mode says — its remote is the only input. + assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false)) + } + + /** Always drops the controller from the decision — but never the switch, which is the one + * way back to the touch UI. */ + @Test + fun alwaysIgnoresThePadButNotTheSwitch() { + assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false)) + assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false)) + assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false)) + assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false)) + } + + /** A value a newer client wrote waits for a pad rather than stranding this build in a + * layout it has no way back out of. */ + @Test + fun anUnknownModeWaitsForAPad() { + assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false)) + assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false)) + assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false)) + } + + /** The shipped default: the console UI still waits for a controller. */ + @Test + fun theDefaultIsUnchangedBehaviour() { + val s = Settings() + assertTrue(s.gamepadUiEnabled) + assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode) + assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false)) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt index 2a6f3dd0..ec5cd3e7 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ProfilesTest.kt @@ -77,6 +77,7 @@ class ProfilesTest { // Device-scope settings are not in the overlay at all, so no profile can move them. assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled) + assertEquals(base.gamepadUiMode, out.gamepadUiMode) assertEquals(base.libraryEnabled, out.libraryEnabled) assertEquals(base.autoWakeEnabled, out.autoWakeEnabled) assertEquals(base.sc2Capture, out.sc2Capture) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index f2072c30..ea3caede 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -99,6 +99,10 @@ struct ContentView: View { // with no (extended) controller attached tvOS falls back to HomeView as before. @ObservedObject private var gamepadManager = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + /// When the switch above takes over — "connected" (default) or "always". See + /// `GamepadUIEnvironment`. + @AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode = + GamepadUIEnvironment.modeWhenConnected /// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host /// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects /// go straight through with no wake. The explicit "Wake Host" action is unaffected either way. @@ -113,7 +117,8 @@ struct ContentView: View { @Environment(\.scenePhase) private var scenePhase private var gamepadUIActive: Bool { GamepadUIEnvironment.isActive( - gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) + gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled, + mode: gamepadUIMode) } // The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift index 852e3a9c..9d26aec0 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift @@ -85,16 +85,40 @@ extension EnvironmentValues { } extension View { - /// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the - /// gamepad screens' common root so no individual view has to read the setting. - func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) } + /// Resolve the stored `ui_palette` and publish its ink — AND the matching colour scheme — to + /// everything below. Applied by the gamepad screens' common root so no individual view has to + /// read the setting. + /// + /// `active` exists for the one surface that is the same view in both worlds: `LibraryView` + /// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false` + /// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a + /// palette's scheme would invert their own system colours instead of matching them. + func gamepadPaletteInk(_ active: Bool = true) -> some View { + modifier(GamepadInkModifier(active: active)) + } } private struct GamepadInkModifier: ViewModifier { + var active = true @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + /// The ambient scheme from ABOVE this modifier — what gets republished unchanged when the + /// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch + /// that would change this view's identity. + @Environment(\.colorScheme) private var systemScheme func body(content: Content) -> some View { - content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID))) + let palette = GamepadPalette.named(paletteID) + return content + .environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark) + // The ink alone was never enough. Every SYSTEM-derived colour that lands on these + // screens — `.secondary` in a placeholder, a `.bordered` button's chrome, a + // NavigationStack's title, a material's frost — resolves against the DEVICE's + // appearance, which no part of this app had ever set. On iPhone and Mac that is often + // Light, so the pale palettes looked correct by accident; an Apple TV is Dark + // essentially always, so on tvOS every one of them came out WHITE on a pale field and + // the interface was unreadable. Publishing the scheme here — once, beside the ink it + // has to agree with — is what makes a pale palette mean "light" to UIKit too. + .environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 156f599b..dd511fd3 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -32,9 +32,12 @@ struct LibraryView: View { // setting off) every platform keeps the plain-grid presentation of this same view. @ObservedObject private var gamepadManager = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + @AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode = + GamepadUIEnvironment.modeWhenConnected private var gamepadUIActive: Bool { GamepadUIEnvironment.isActive( - gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) + gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled, + mode: gamepadUIMode) } #endif @@ -78,6 +81,16 @@ struct LibraryView: View { } } #endif + #if os(iOS) || os(macOS) || os(tvOS) + // Published HERE, not just inside the coverflow, because the coverflow is only one of + // four things this view renders: the loading spinner, the error state and the empty + // state sit above it, as do the navigation title and toolbar. On iOS those are wrapped + // by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view + // directly in a NavigationStack, so under a pale palette every one of them kept the + // system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad + // UI isn't drawing — the plain grid belongs to the system background. + .gamepadPaletteInk(gamepadUIActive) + #endif } @ViewBuilder private var content: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 4358cddc..ae5612fb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -81,6 +81,9 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + /// When the switch above takes over — the row is only built while it is on. + @AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode = + GamepadUIEnvironment.modeWhenConnected /// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as /// the row steps, which is why the picker lives here and not in a sheet. @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" @@ -659,6 +662,21 @@ struct GamepadSettingsView: View { detail: "Turn off to use the touch interface even with a controller connected.", value: $gamepadUIEnabled), ] + // WHEN the switch above takes over. Built only while it is on: with the switch off this + // screen is unreachable in the first place (no gamepad UI to open it from), so a row + // that decides nothing would exist purely to be found in a screenshot. + if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) { + list.insert( + choiceRow( + id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle", + label: "Show it", + detail: "With a controller: the touch interface comes back when the last one " + + "disconnects. Always keeps this layout either way — for a device that " + + "lives on a TV.", + options: SettingsOptions.gamepadUIModes, current: gamepadUIMode + ) { gamepadUIMode = $0 }, + at: at + 1) + } #if os(macOS) // The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside // the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row @@ -707,6 +725,14 @@ struct GamepadSettingsView: View { at: anchor + 1) } #endif + // The smoothness buffer only decides anything under Smoothness. Every other settings + // surface — touch, tvOS, the GTK and WinUI shells — hides it under Lowest latency; this + // screen alone left it live and steppable, which is a row that thuds or silently stores + // a value nothing reads. Removed here rather than omitted from the literal above so the + // macOS safe-present insertion can still anchor on it. + if presentPriority != "smooth" { + list.removeAll { $0.id == "smoothBuffer" } + } return list + profileRows } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index 04fcef9c..618bb201 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -53,6 +53,14 @@ enum SettingsOptions { static let hudPlacements: [(label: String, tag: String)] = HUDPlacement.allCases.map { ($0.label, $0.rawValue) } + /// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) — only meaningful while + /// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch + /// is off rather than showing a picker that decides nothing. + static let gamepadUIModes: [(label: String, tag: String)] = [ + ("With a controller", GamepadUIEnvironment.modeWhenConnected), + ("Always", GamepadUIEnvironment.modeAlways), + ] + /// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced /// the visible stage picker with intent; see SessionPresenter's PresentPriority and /// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 8ee5f380..ddbca72a 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -724,11 +724,24 @@ extension SettingsView { #endif #if !os(tvOS) if !inProfileScope { - described("With a controller connected, the host list and library switch to a " - + "controller-friendly layout — larger focus targets, a swipeable cover " - + "browser.") { + described("The host list and library switch to a controller-friendly layout — " + + "larger focus targets, a swipeable cover browser.") { Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) } + // Only meaningful while the switch above is on, so it is HIDDEN rather than + // disabled when it isn't: a picker whose every option decides nothing is worse + // than no picker, and this Section is short enough that nothing jumps far. + if gamepadUIEnabled { + described("With a controller: the touch interface comes back when the last " + + "one disconnects. Always keeps the controller-friendly layout either " + + "way — for a device that lives on a TV.") { + Picker("Show it", selection: $gamepadUIMode) { + ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + } + } } #endif #if DEBUG && !os(tvOS) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index e93581f2..a2d82720 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -75,6 +75,13 @@ struct SettingsView: View { @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @ObservedObject var gamepads = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true + /// When the switch above takes over — read (and shown) only while it is on. + @AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode = + GamepadUIEnvironment.modeWhenConnected + /// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) — every other + /// platform reaches it through the gamepad settings screen, which an Apple TV without a + /// controller cannot open. + @AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet" @AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true @AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false @AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10 @@ -488,6 +495,22 @@ struct SettingsView: View { TVSelectionRow( title: "Gamepad-optimized browsing", options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag) + // Hidden while the switch above is off — see the touch settings' identical gate. + if gamepadUIEnabled { + TVSelectionRow( + title: "Show it", + options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode) + // The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the + // Background row lives on the gamepad settings screen, which is reached from + // the gamepad launcher — and on tvOS that launcher needs an extended-profile + // controller, so an Apple TV driven by the Siri Remote alone could not reach + // the palettes at all. It belongs beside "Show it" because both describe the + // same interface: this row is what that interface looks like once it is up. + TVSelectionRow( + title: "Background", + options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) }, + selection: $uiPalette) + } tvCaption(Self.controllersFooter) NavigationLink("About") { AboutView() } .padding(.top, 8) diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index f8b64170..9861199c 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -95,24 +95,19 @@ private struct ConsoleGlass: ViewModifier { private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) } func body(content: Content) -> some View { + // The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`. + // Scoped to the fill it frosts the material correctly and stops there, so a system colour + // in the row's own content (a `.secondary` label, a `.bordered` button) still resolved + // against the device appearance — which is how the pale palettes came out light-on-light + // on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the + // tvOS and pre-26 branches were the odd ones out. #if os(tvOS) // ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these // surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the // Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights // the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost. - content.background { - shape.fill(.ultraThinMaterial) - .environment(\.colorScheme, scheme) - .overlay { shape.fill(materialWash) } - .overlay { - if let tint { shape.fill(tint) } - } - } - #else - if #available(iOS 26, macOS 26, *) { - content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) - } else { - content.background { + content + .background { shape.fill(.ultraThinMaterial) .environment(\.colorScheme, scheme) .overlay { shape.fill(materialWash) } @@ -120,6 +115,21 @@ private struct ConsoleGlass: ViewModifier { if let tint { shape.fill(tint) } } } + .environment(\.colorScheme, scheme) + #else + if #available(iOS 26, macOS 26, *) { + content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) + } else { + content + .background { + shape.fill(.ultraThinMaterial) + .environment(\.colorScheme, scheme) + .overlay { shape.fill(materialWash) } + .overlay { + if let tint { shape.fill(tint) } + } + } + .environment(\.colorScheme, scheme) } #endif } @@ -173,11 +183,14 @@ private struct ConsoleGlassBackground: ViewModifier { in: shape) .environment(\.colorScheme, scheme) } else { - content.background { - shape.fill(.regularMaterial) - .environment(\.colorScheme, scheme) - .overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) } - } + // Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost. + content + .background { + shape.fill(.regularMaterial) + .environment(\.colorScheme, scheme) + .overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) } + } + .environment(\.colorScheme, scheme) } } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift index 97ad42cb..267c334d 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift @@ -3,20 +3,40 @@ // layouts). A pure function, not a singleton: the reactivity comes from callers already observing // `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same // local-read pattern SettingsView already uses for GamepadManager), so this stays the single place -// the two combine without adding a second ObservableObject or an environment key nobody else needs. +// the inputs combine without adding a second ObservableObject or an environment key nobody else needs. import Foundation import PunktfunkShared public enum GamepadUIEnvironment { - /// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`); + /// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default, + /// and what the switch meant when it was a lone Bool. + public static let modeWhenConnected = "connected" + /// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad — asked + /// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the + /// one they want and the pad is not always awake. + public static let modeAlways = "always" + + /// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) — off means + /// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters + /// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a + /// value a newer client wrote) waits for a controller. + /// /// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable /// controller is actually attached (a non-extended-profile device leaves `active` nil, which - /// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's - /// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable - /// without a real `GCController` (which XCTest can't construct). - public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool { - enabledSetting && (gamepadConnected || forced) + /// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function + /// has nothing else to inspect, and it keeps the helper testable without a real `GCController` + /// (which XCTest can't construct). + /// `mode` carries no default on purpose: a call site that forgot it would silently strand + /// everyone who picked Always back on "only with a controller", which is exactly the bug + /// this parameter exists to make impossible. + public static func isActive( + gamepadConnected: Bool, + enabledSetting: Bool, + mode: String + ) -> Bool { + guard enabledSetting else { return false } + return mode == modeAlways || gamepadConnected || forced } /// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 6a67b893..dc857763 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -176,16 +176,23 @@ public enum DefaultsKey { /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. public static let hudPlacement = "punktfunk.hudPlacement" /// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly - /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) - /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. + /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library). + /// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`. public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" + /// When `gamepadUIEnabled` actually takes over: `"connected"` (the default — only while a + /// usable controller is attached, the behaviour this switch has always had) or `"always"`, + /// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a + /// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the + /// settings rows hide it when the switch is off. Anything unrecognized reads as + /// `"connected"`. A device preference, never part of a stream profile. + public static let gamepadUIMode = "punktfunk.gamepadUIMode" /// Which colour family the gamepad UI's living backdrop drifts through — a - /// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/ - /// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the - /// Android client carry the same table under the same names. Presentation only, so it is - /// a device preference and never part of a stream profile. An unknown value reads as the - /// default rather than failing — a newer client may have shipped a palette this build - /// doesn't know. + /// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/ + /// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop + /// console and the Android client carry the same table under the same names. Presentation + /// only, so it is a device preference and never part of a stream profile. An unknown value + /// reads as the default rather than failing — a newer client may have shipped a palette this + /// build doesn't know. public static let uiPalette = "punktfunk.uiPalette" /// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this /// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where diff --git a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift index 09b6d702..e9da97a6 100644 --- a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift +++ b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift @@ -65,13 +65,25 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable { SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96), ] - /// The twelve shipped palettes: the brand default, five more dark fields, then six pale + /// 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 one way. public static let all: [GamepadPalette] = [ // --- dark fields (white ink) --- GamepadPalette( id: "violet", name: "Violet", stops: [], ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false), + GamepadPalette( + // For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow, + // no power. The first two stops are literally (0,0,0), so the 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. What is left is a + // faint indigo→violet ember in the bright corner. The accent stays the brand violet + // — focus has to be findable on black. + id: "oled", name: "OLED", + stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000), + SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115), + SIMD3(0.120, 0.024, 0.130)], + ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false), GamepadPalette( // Deep indigo climbing through violet into a hot magenta. id: "nebula", name: "Nebula", diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift index 2a5f7b28..972e316e 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift @@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase { func testTableMatchesTheOtherClients() { XCTAssertEqual( GamepadPalette.all.map(\.id), - ["violet", "nebula", "abyss", "ember", "moss", "graphite", + ["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset", "bloom", "dawn", "mint", "opal"]) // Dark fields lead, pale ones follow, so stepping the row walks one direction. let firstLight = GamepadPalette.all.firstIndex { $0.light } - XCTAssertEqual(firstLight, 6) - XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light)) + XCTAssertEqual(firstLight, 7) + XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light)) + } + + /// OLED is the one palette whose selling point is measurable: it has to be genuinely black, + /// not merely the darkest of the dark fields. + func testOLEDIsActuallyBlack() { + let oled = GamepadPalette.named("oled") + XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing") + let cells = oled.meshColors + XCTAssertGreaterThanOrEqual( + cells.filter { luma($0) == 0 }.count, 3, + "the shaded corner has to be switched off, not dimmed") + let mean = cells.map(luma).reduce(0, +) / Double(cells.count) + let darkestOther = GamepadPalette.all + .filter { $0.id != "oled" } + .map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) } + .min() ?? 0 + XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)") } /// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift index ae44e999..9730e416 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadUIEnvironmentTests.swift @@ -1,14 +1,58 @@ -// GamepadUIEnvironment.isActive is a pure AND — table-tested exhaustively over its 2x2 inputs. +// GamepadUIEnvironment.isActive is pure — table-tested exhaustively over its inputs. import XCTest @testable import PunktfunkKit final class GamepadUIEnvironmentTests: XCTestCase { - func testActiveOnlyWhenEnabledAndConnected() { - XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true)) - XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false)) - XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true)) - XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false)) + private let connected = GamepadUIEnvironment.modeWhenConnected + private let always = GamepadUIEnvironment.modeAlways + + /// The default mode is the behaviour the switch had when it was a lone Bool, so an install + /// that never sees the new row is exactly where it was. + func testWhenConnectedIsAPlainAnd() { + XCTAssertTrue( + GamepadUIEnvironment.isActive( + gamepadConnected: true, enabledSetting: true, mode: connected)) + XCTAssertFalse( + GamepadUIEnvironment.isActive( + gamepadConnected: true, enabledSetting: false, mode: connected)) + XCTAssertFalse( + GamepadUIEnvironment.isActive( + gamepadConnected: false, enabledSetting: true, mode: connected)) + XCTAssertFalse( + GamepadUIEnvironment.isActive( + gamepadConnected: false, enabledSetting: false, mode: connected)) + } + + /// Always drops the controller from the decision entirely — but NOT the switch, which stays + /// the one way back to the touch UI. + func testAlwaysIgnoresTheControllerButNotTheSwitch() { + XCTAssertTrue( + GamepadUIEnvironment.isActive( + gamepadConnected: false, enabledSetting: true, mode: always)) + XCTAssertTrue( + GamepadUIEnvironment.isActive( + gamepadConnected: true, enabledSetting: true, mode: always)) + XCTAssertFalse( + GamepadUIEnvironment.isActive( + gamepadConnected: false, enabledSetting: false, mode: always)) + XCTAssertFalse( + GamepadUIEnvironment.isActive( + gamepadConnected: true, enabledSetting: false, mode: always)) + } + + /// A value a newer client wrote must wait for a controller, never strand this build in a + /// layout it has no way back out of. + func testUnknownModeWaitsForAController() { + XCTAssertFalse( + GamepadUIEnvironment.isActive( + gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so")) + XCTAssertTrue( + GamepadUIEnvironment.isActive( + gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so")) + XCTAssertFalse( + GamepadUIEnvironment.isActive( + gamepadConnected: false, enabledSetting: true, mode: "")) } } diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index b5a3e5dc..ef2fbf10 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -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 diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index 53158e13..b52555a5 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -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::() / 16.0; + let darkest_other = PALETTES + .iter() + .filter(|p| p.id != "oled") + .map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::() / 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 — diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 08cf48b6..1a7c214e 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -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 { + /// 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 { 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 { + fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option { 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 { + fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option { 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 { + // 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 { - let ids = self.row_ids(); + pub(crate) fn hints(&self, ctx: &Ctx) -> Vec { + 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 = 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); diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 7752f2c6..39e793a4 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -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); } diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index f44b9598..c4e5ad5c 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -96,11 +96,12 @@ those hiccups out, at that buffer's worth of added delay. Linux and Windows apps home; the Apple and Android apps have carried the same setting for a while, and it is stored under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device. -**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How -many frames are held back before showing. Each frame absorbs roughly one screen refresh of network -hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra -delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever -**Prioritize** is offered, and greyed out until you pick Smoothness. +**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before +showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of +delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of +jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is +offered, and only once you have picked **Smoothness** — under Lowest latency there are no held +frames for it to count, so it isn't shown at all. **V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display @@ -263,6 +264,43 @@ when you return to the host list. The console home carries the row for the deskt shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV and Android have no equivalent. +## Interface + +These change how the client itself looks and behaves. None of them touches a stream, so none of them +can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of +you. + +**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the +controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and +settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to +stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the +controller-optimized home is a separate entry point rather than a switch, so there is nothing to +turn off. An Android TV is always in this mode — its remote is the only input it has. + +**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides +*when* that switch takes effect. **With a controller** is the long-standing behaviour: the +controller-optimized home appears as a pad connects and the touch interface returns when the last one +disconnects. **Always** keeps the controller-optimized home either way — for a phone or tablet that +lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you +want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing +there.) + +**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop +drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**, +**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**, +**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop +recolours as you step the row, so pick by looking. **OLED** is the one with a practical point rather +than a decorative one: it is true black — most of the frame is pixels switched off, which on an OLED +or AMOLED panel means no glow and no power drawn, with only a faint violet ember left in one corner. +Stored under the same name on every client, so a phone, a Deck and a desktop set to Mint all look +alike. Appearance only — nothing about a stream depends on it. + +The row lives in the controller-optimized settings themselves — the screen you reach with **X** from +the controller-optimized home — on every platform that has one, which includes the Steam Deck and the +Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its +ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real +controller to open and the palettes would otherwise be unreachable from the Siri Remote. + ## Overlay **Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a @@ -292,6 +330,10 @@ stay global and **cannot be put in a settings profile**: 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. +- 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 + connected to. One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved host's own edit sheet, because handing a machine your clipboard is a decision about that one host —