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 —