Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fb41affba |
@@ -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<GpRow>, 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<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
|
||||
options: List<Pair<T, String>>, 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)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Settings> = mutableListOf(),
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
av1Capable = true,
|
||||
) { sink += it }
|
||||
|
||||
private fun row(rows: List<GpRow>, 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<Settings>()
|
||||
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<Settings>(), writes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same rows do write once forwarding is on`() {
|
||||
val writes = mutableListOf<Settings>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Equatable>(
|
||||
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<Bool>
|
||||
value: Binding<Bool>, 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
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user