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 c33e4f88..b085163c 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 @@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore // a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it, // B closes. Both write the same SharedPreferences, so values round-trip with the touch settings. -private class GpRow( +internal class GpRow( val id: String, val header: String?, val label: String, @@ -78,6 +78,15 @@ private class GpRow( val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) ) +/** + * The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of + * [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the + * already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing + * else, and every dimmed row still stepped its setting. + */ +internal fun liveRow(rows: List, index: Int): GpRow? = + rows.getOrNull(index)?.takeIf { it.enabled } + @Composable fun GamepadSettingsScreen( initial: Settings, @@ -144,11 +153,13 @@ fun GamepadSettingsScreen( when (dir) { NavDir.UP -> if (focus > 0) focus-- NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) } - NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) } + // A disabled row is INERT, not just dim — the step is refused instead of writing a + // setting that has nothing to act on (see `liveRow`). + NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } + NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } } }, - onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() }, + onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() }, ) // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. @@ -186,7 +197,10 @@ fun GamepadSettingsScreen( } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { - if (focus == index) { adjustDir = 1; row.activate() } else focus = index + // Same inertness as the pad path above — tapping a dimmed row focuses it (so + // its detail explains itself) but never flips it. + if (focus != index) focus = index + else if (row.enabled) { adjustDir = 1; row.activate() } }) } } @@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick /** Build the console settings rows from the current [Settings], writing through [update]. * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the * AV1 codec entry (see `codecOptionsFor`). */ -private fun buildSettingsRows( +internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, av1Capable: Boolean, @@ -348,13 +362,14 @@ private fun buildSettingsRows( ): List { fun choice( id: String, header: String?, label: String, detail: String, - options: List>, current: T, write: (T) -> Unit, + options: List>, current: T, enabled: Boolean = true, write: (T) -> Unit, ): GpRow { val idx = options.indexOfFirst { it.first == current } return GpRow( id, header, label, value = options.getOrNull(idx)?.second ?: "—", detail = detail, + enabled = enabled, adjust = { delta -> if (idx < 0) { options.firstOrNull()?.let { write(it.first) } != null @@ -371,11 +386,12 @@ private fun buildSettingsRows( } fun toggle( id: String, header: String?, label: String, detail: String, - value: Boolean, write: (Boolean) -> Unit, + value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit, ): GpRow = GpRow( id, header, label, value = if (value) "On" else "Off", detail = detail, + enabled = enabled, adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false }, activate = { write(!value) }, toggled = value, @@ -478,22 +494,26 @@ private fun buildSettingsRows( "so games don't see two of them.", s.gamepadForwarding, ) { update(s.copy(gamepadForwarding = it)) }, + // Everything below the master switch follows it — dim and inert while nothing is being + // forwarded, the same relationship the touch settings draw with `enabled =`. This screen + // had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so + // the pad rows kept stepping settings that had nothing to act on. choice( "padType", null, "Controller type", "The virtual pad the host creates — Automatic matches this controller.", - GAMEPAD_OPTIONS, s.gamepad, + GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding, ) { update(s.copy(gamepad = it)) }, choice( "systemButtons", null, "Guide button", "Where the guide (Xbox/PS) and share presses go while streaming — Automatic " + "sends them to the host whenever this device delivers them.", - SYSTEM_BUTTON_OPTIONS, s.systemButtons, + SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding, ) { update(s.copy(systemButtons = it)) }, choice( "guideGesture", null, "Hold Select for guide", "Hold Select alone to press the host's guide button — keep holding for a " + "Gaming-Mode host's quick-access menu. A Select tap still goes through.", - GUIDE_GESTURE_OPTIONS, s.guideGesture, + GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding, ) { update(s.copy(guideGesture = it)) }, ) + listOfNotNull( if (hasBodyVibrator) { @@ -513,8 +533,18 @@ private fun buildSettingsRows( "sc2", null, "Steam Controller 2 passthrough", "Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " + "it as-is — Steam on the host drives it like the physical pad.", - s.sc2Capture, + s.sc2Capture, enabled = s.gamepadForwarding, ) { update(s.copy(sc2Capture = it)) }, + // The SC2 row's twin, and missing here until now: the touch settings have carried both + // side by side, so a couch user on a TV box — where there IS no touch interface to fall + // back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate + // reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's. + toggle( + "dsCapture", null, "DualSense / DualShock passthrough (USB)", + "Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " + + "triggers, lightbar and gyro.", + s.dsCapture, enabled = s.gamepadForwarding, + ) { update(s.copy(dsCapture = it)) }, ) } 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 new file mode 100644 index 00000000..b9012c0f --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt @@ -0,0 +1,97 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The controller-navigable settings rows: what the master forwarding switch governs, and that a + * governed row is inert rather than merely dim. + * + * The touch settings and the desktop console have carried this relationship for a while (`enabled = + * s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so + * these tests pin both halves — the flag AND the refusal to write. + */ +class GamepadSettingsRowsTest { + + /** Rows for a given forwarding state, capturing whatever a row writes back. */ + private fun rows( + forwarding: Boolean, + sink: MutableList = mutableListOf(), + ): List = buildSettingsRows( + Settings(gamepadForwarding = forwarding), + hasBodyVibrator = true, + av1Capable = true, + ) { sink += it } + + private fun row(rows: List, id: String): GpRow = + rows.first { it.id == id } + + /** Every row that only means something while a controller is actually being forwarded. */ + private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture") + + @Test + fun `forwarding off dims every row that depends on it`() { + val off = rows(forwarding = false) + for (id in governed) { + assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled) + } + // The master switch itself stays live — otherwise it could never be turned back on. + assertTrue(row(off, "padForward").enabled) + } + + @Test + fun `forwarding on leaves them all live`() { + val on = rows(forwarding = true) + for (id in governed) { + assertTrue("$id should be live with forwarding on", row(on, id).enabled) + } + } + + @Test + fun `a dimmed row is inert - liveRow withholds it and nothing is written`() { + val writes = mutableListOf() + val off = rows(forwarding = false, sink = writes) + for (id in governed) { + val i = off.indexOfFirst { it.id == id } + assertNull("$id must not be reachable while dimmed", liveRow(off, i)) + // What the screen actually does on left/right/A — the whole point is that it no-ops. + liveRow(off, i)?.adjust(1) + liveRow(off, i)?.adjust(-1) + liveRow(off, i)?.activate() + } + assertEquals("a dimmed row wrote a setting", emptyList(), writes) + } + + @Test + fun `the same rows do write once forwarding is on`() { + val writes = mutableListOf() + val on = rows(forwarding = true, sink = writes) + val i = on.indexOfFirst { it.id == "sc2" } + assertNotNull(liveRow(on, i)) + liveRow(on, i)?.activate() + assertEquals(1, writes.size) + assertFalse("activate flips the toggle", writes[0].sc2Capture) + } + + /** + * R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly + * where this screen is the only one reachable — a TV box has no touch interface to fall back to. + */ + @Test + fun `the DualSense passthrough toggle is present, next to its SC2 twin`() { + val on = rows(forwarding = true) + val ids = on.map { it.id } + assertTrue("dsCapture row is missing", "dsCapture" in ids) + assertEquals( + "the two passthrough rows belong side by side", + ids.indexOf("sc2") + 1, + ids.indexOf("dsCapture"), + ) + // Drawn as a switch, and reading the persisted default. + assertEquals(true, row(on, "dsCapture").toggled) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 514e7bc8..09274f1f 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -166,6 +166,11 @@ struct GamepadSettingsView: View { /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. private var hints: [GamepadHint] { guard pinTarget != nil else { + // A dimmed row takes neither, so offering them would be the same lie the row itself + // used to tell — only Done remains, and the detail line says what to turn on first. + guard rows.first(where: { $0.id == focusID })?.enabled ?? true else { + return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] + } return [ .init(glyph: "arrow.left.and.right", text: "Adjust"), .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), @@ -218,7 +223,8 @@ struct GamepadSettingsView: View { HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) + .foregroundStyle( + .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -239,9 +245,13 @@ struct GamepadSettingsView: View { .animation(.smooth(duration: 0.22), value: row.value) Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) + .foregroundStyle( + .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) } } + // Contents only — the glass and border below stay at full strength, so a dimmed row + // still reads as a row you can sit on (which you can: its detail is the point). + .opacity(row.enabled ? 1 : 0.45) .padding(.horizontal, m.rowHPad) .padding(.vertical, m.rowVPad) // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. @@ -276,6 +286,13 @@ struct GamepadSettingsView: View { /// Whether left/right means anything here — false hides the value's chevrons (the /// Profiles rows navigate, and the placeholder rows do nothing at all). var adjustable = true + /// Dimmed and inert when false: a row whose meaning depends on another setting that is + /// currently off. It stays in the list and stays FOCUSABLE — its `detail` is how the + /// user learns which switch to flip first, and a row that vanished mid-list would + /// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` / + /// `activate(id:)`, not per closure, so no row builder can forget it. + /// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.) + var enabled = true /// Left/right step; returns whether the value actually changed (false ⇒ boundary thud). let adjust: (Int) -> Bool /// A — cycle forward (wrapping) / flip. @@ -286,12 +303,14 @@ struct GamepadSettingsView: View { /// (never on state captured at wire time). private func adjust(id: String, by delta: Int) -> Bool { lastAdjustDelta = delta - return rows.first { $0.id == id }?.adjust(delta) ?? false + guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false } + return row.adjust(delta) } private func activate(id: String) { lastAdjustDelta = 1 // A always cycles forward - rows.first { $0.id == id }?.activate() + guard let row = rows.first(where: { $0.id == id }), row.enabled else { return } + row.activate() } private var rows: [Row] { @@ -391,27 +410,35 @@ struct GamepadSettingsView: View { + "controller already reaches the host another way — USB passthrough such " + "as VirtualHere — so games don't see two of them.", value: $gamepadForwarding), + // The four rows below only mean something while something is being forwarded, so + // they follow the switch above — the same relationship the touch settings draw with + // `.disabled(!effective.gamepadForwarding)`. This screen could not express it until + // `Row.enabled` existed, so it alone left them live and steppable. choiceRow( id: "pad", icon: "gamecontroller", label: "Use controller", detail: "Which pad is forwarded to the host, as player 1.", - options: controllers, current: gamepads.preferredID + options: controllers, current: gamepads.preferredID, + enabled: gamepadForwarding ) { gamepads.preferredID = $0 }, choiceRow( id: "padType", icon: "dpad", label: "Controller type", detail: "The virtual pad the host creates — Automatic matches this controller.", - options: SettingsOptions.padTypes, current: gamepadType + options: SettingsOptions.padTypes, current: gamepadType, + enabled: gamepadForwarding ) { gamepadType = $0 }, choiceRow( id: "systemButtons", icon: "house.circle", label: "Guide button", detail: "Where the guide (Xbox/PS) and share presses go while streaming — " + "Automatic sends them to the host whenever this device delivers them.", - options: SettingsOptions.systemButtons, current: systemButtons + options: SettingsOptions.systemButtons, current: systemButtons, + enabled: gamepadForwarding ) { systemButtons = $0 }, choiceRow( id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide", detail: "Hold Select alone to press the host's guide button — keep holding " + "for a Gaming-Mode host's quick-access menu. A tap still goes through.", - options: SettingsOptions.guideGestures, current: guideGesture + options: SettingsOptions.guideGestures, current: guideGesture, + enabled: gamepadForwarding ) { guideGesture = $0 }, choiceRow( @@ -583,13 +610,15 @@ struct GamepadSettingsView: View { private func choiceRow( id: String, header: String? = nil, icon: String, label: String, detail: String, - options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void + options: [(label: String, tag: T)], current: T, enabled: Bool = true, + write: @escaping (T) -> Void ) -> Row { let index = options.firstIndex { $0.tag == current } return Row( id: id, header: header, icon: icon, label: label, value: index.map { options[$0].label } ?? "—", detail: detail, + enabled: enabled, adjust: { delta in // Unknown current value: snap to the first option on any step. guard let index else { @@ -610,12 +639,13 @@ struct GamepadSettingsView: View { private func toggleRow( id: String, header: String? = nil, icon: String, label: String, detail: String, - value: Binding + value: Binding, enabled: Bool = true ) -> Row { Row( id: id, header: header, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, + enabled: enabled, adjust: { delta in // Directional semantics: left = off, right = on; a no-op reads as a boundary. let target = delta > 0 diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index b623f050..4ea7812b 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -98,8 +98,17 @@ public final class GamepadCapture { /// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is /// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release /// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD. - private static let escapeChord: UInt32 = + /// Internal rather than private only so `GamepadEscapeChordTests` can pin it against + /// `escapeChordElements` below — the two must not drift. + static let escapeChord: UInt32 = GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back + /// `escapeChord`'s four elements by GameController alias — the ONLY system gestures claimed + /// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and + /// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two + /// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV. + static let escapeChordElements = [ + GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions, + ] /// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client. private static let disconnectHold: TimeInterval = 1.5 /// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes @@ -236,7 +245,17 @@ public final class GamepadCapture { // gesture attached the press is the system's, not the game's. During capture the remote // session IS the game: the share button must reach the host (e.g. Steam screenshots), // the PS button must open the host's Steam overlay. Restored to .enabled on close. - for element in c.physicalInputProfile.elements.values { + // + // With forwarding OFF none of that applies — no press reaches the host, so taking the + // user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord + // is still read off this slot, and on tvOS it is the only controller way out of a + // stream, so the chord's own four elements keep their claim. (Menu especially: leave + // its gesture attached on tvOS and the press is the system's — the chord would never + // complete and the session would have no controller exit at all.) + let claimed = forwarding + ? Array(c.physicalInputProfile.elements.values) + : Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] } + for element in claimed { element.preferredSystemGestureState = .disabled } // The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit, @@ -276,7 +295,11 @@ public final class GamepadCapture { MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } } } } - if let motion = c.motion { + // Motion is wire-only — `forwardMotion` has nothing to do with forwarding off, and no + // local feature reads it. Powering the IMU anyway costs the pad real battery (it streams + // gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power + // it back down), so with nothing to forward we simply never turn it on. + if forwarding, let motion = c.motion { if motion.sensorsRequireManualActivation { motion.sensorsActive = true } motion.valueChangedHandler = { [weak self, weak slot] m in MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift new file mode 100644 index 00000000..0531f7e7 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift @@ -0,0 +1,52 @@ +import GameController +import XCTest + +@testable import PunktfunkKit + +/// The escape chord's mask and its GameController alias list have to describe the same four +/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding +/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops +/// covering the mask, the missing button's press stays the system's and the chord never completes. +/// +/// That matters most on tvOS, where this chord is the only controller way out of a stream: the +/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes. +/// Hence a test on the invariant rather than trusting the comment beside it. +@MainActor +final class GamepadEscapeChordTests: XCTestCase { + + /// The intended alias↔bit pairing, spelled out independently of the implementation. + private let pairing: [(alias: String, bit: UInt32)] = [ + (GCInputLeftShoulder, GamepadWire.leftShoulder), + (GCInputRightShoulder, GamepadWire.rightShoulder), + (GCInputButtonMenu, GamepadWire.start), + (GCInputButtonOptions, GamepadWire.back), + ] + + func testChordMaskIsExactlyTheFourPairedButtons() { + XCTAssertEqual( + pairing.reduce(UInt32(0)) { $0 | $1.bit }, + GamepadCapture.escapeChord, + "the chord mask and the alias pairing describe different buttons") + } + + func testEveryChordBitHasAnElementToClaim() { + // One alias per bit — a mask that grew a fifth button without a matching alias would + // leave that button's gesture with the OS while forwarding is off. + XCTAssertEqual( + GamepadCapture.escapeChordElements.count, + GamepadCapture.escapeChord.nonzeroBitCount, + "alias list and chord mask differ in size") + XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias)) + } + + /// The claim list is a strict subset of what a forwarding slot takes — it is a NARROWING of + /// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which + /// is the behaviour this deliberately avoids). + func testClaimListIsNonEmptyAndAllDistinct() { + XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty) + XCTAssertEqual( + Set(GamepadCapture.escapeChordElements).count, + GamepadCapture.escapeChordElements.count, + "a repeated alias would mean a chord bit has no element") + } +} diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 4bbdd924..ecfa1792 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -981,6 +981,13 @@ pub(crate) fn settings_page( s.forward_pad = key.unwrap_or_default(); s.save(); }) + // Dimmed with the master switch above it, like echo cancellation under the mic + // (see that row) — this and the three below have nothing to act on while no + // controller is forwarded at all. Every commit bumps `rev` and re-renders this + // screen, so they follow the toggle live. Brings this client in line with how GTK + // (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the + // console UI (dim + refuse the step) have always drawn the same relationship. + .enabled(s.gamepad_forwarding) }; let pad_forward_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| { @@ -991,7 +998,8 @@ pub(crate) fn settings_page( }); let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| { s.gamepad = GAMEPADS[i].0.to_string(); - }); + }) + .enabled(s.gamepad_forwarding); let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons); let sysbtn_combo = setting_combo( ctx, @@ -1002,7 +1010,8 @@ pub(crate) fn settings_page( |s, i| { s.system_buttons = SYSTEM_BUTTONS[i].0.to_string(); }, - ); + ) + .enabled(s.gamepad_forwarding); let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture); let gesture_combo = setting_combo( ctx, @@ -1013,7 +1022,8 @@ pub(crate) fn settings_page( |s, i| { s.guide_gesture = GUIDE_GESTURES[i].0.to_string(); }, - ); + ) + .enabled(s.gamepad_forwarding); let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode); let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| { s.touch_mode = TOUCH_MODES[i].0.to_string();