fix(apple): iPad mouse buttons, held-key repeat, and the scroll direction
Three separate things an iPad's keyboard and mouse got wrong. Every mouse button past the first two clicked LEFT on the host. UIKit's button mask is 1-based over the HID order, and only `.secondary` was read — middle, back and forward all fell into the `else` and became button 1. Map 3/4/5 through `.button(_:)`; only middle and right swap places, because the wire numbers them the other way round. Holding a key deleted exactly one character. GameController reports a key once on press and once on release — it has no repeat channel — and the host injects exactly what it is told, so nothing downstream ever turned a held key into a stream of presses. Generate the repeat client-side at the usual delay-then-rate, newest key only, with modifiers and lock keys excluded. macOS was never affected: NSEvent hands it the window server's own repeats. Scrolling ignored the system's Natural Scrolling switch. UIKit has already applied that preference by the time it hands over a pan translation — it is what makes every UIScrollView on the device turn the right way — so negating y pinned the stream to traditional scrolling and inverted the setting for everyone on the default. Pass the sign through, exactly as the macOS path passes `NSEvent.scrollingDeltaY` through, and let that one recognizer own scroll under pointer lock too: it is the only way trackpad two-finger scrolling ever arrives, so gating it on the lock had been dropping it entirely there, and the raw GameController axis it deferred to carries no such preference. iOS installs no GCMouse scroll handler now, so nothing double-sends; tvOS, which has no scroll pan, keeps it. Closes #7, closes #15 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,13 @@ public final class InputCapture {
|
||||
/// its Esc suppression need it in both states).
|
||||
private var cmdKeysDown: Set<UInt32> = []
|
||||
|
||||
#if !os(macOS)
|
||||
/// The key currently auto-repeating, and the timer driving it. iOS/tvOS only — see
|
||||
/// `startAutoRepeat`. Main-queue only, like every other field here.
|
||||
private var autoRepeatVK: UInt32?
|
||||
private var autoRepeatTimer: Timer?
|
||||
#endif
|
||||
|
||||
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
|
||||
/// the ⌃⌥⇧Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
|
||||
/// like the ⌘⎋ toggle), so it needs the live modifier state — tracked in both forwarding states,
|
||||
@@ -322,6 +329,9 @@ public final class InputCapture {
|
||||
}
|
||||
mice.removeAll()
|
||||
keyboards.removeAll()
|
||||
#if !os(macOS)
|
||||
stopAutoRepeat()
|
||||
#endif
|
||||
}
|
||||
|
||||
deinit { stop() }
|
||||
@@ -330,6 +340,9 @@ public final class InputCapture {
|
||||
/// and modifier/latch tracking (GC delivers nothing while inactive, so a ⌘ released
|
||||
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
||||
private func releaseAll() {
|
||||
#if !os(macOS)
|
||||
stopAutoRepeat() // before the releases below, so the ticker can't outlive the key-up
|
||||
#endif
|
||||
cmdKeysDown.removeAll()
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
@@ -347,6 +360,58 @@ public final class InputCapture {
|
||||
residualScrollY = 0
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
/// Windows VKs that must never auto-repeat: the modifiers (a held Shift is a state, not a
|
||||
/// stream of presses) and the lock keys (each repeat would toggle the light again).
|
||||
private static let noAutoRepeatVKs: Set<UInt32> = [
|
||||
0x10, 0xA0, 0xA1, // Shift, LShift, RShift
|
||||
0x11, 0xA2, 0xA3, // Control, LControl, RControl
|
||||
0x12, 0xA4, 0xA5, // Alt, LAlt, RAlt
|
||||
0x5B, 0x5C, // LWin, RWin
|
||||
0x14, 0x90, 0x91, // CapsLock, NumLock, ScrollLock
|
||||
]
|
||||
|
||||
/// Start (or hand over) the held-key auto-repeat, matching a real keyboard's delay-then-rate.
|
||||
///
|
||||
/// GameController reports a key ONCE on press and once on release — it has no repeat channel —
|
||||
/// and the host injects exactly what it is told, so nothing downstream ever turns a held key
|
||||
/// into a stream of presses. Holding Backspace deleted a single character. macOS is unaffected:
|
||||
/// its NSEvent path already receives the window server's own repeats and forwards them.
|
||||
///
|
||||
/// Only the newest key repeats, which is what a hardware keyboard does — pressing a second key
|
||||
/// takes the repeat over from the first. Timings are the iOS/macOS defaults (~0.5 s delay,
|
||||
/// ~25 Hz); `.common` keeps it running while a scroll or gesture is tracking.
|
||||
private func startAutoRepeat(_ vk: UInt32) {
|
||||
guard !Self.noAutoRepeatVKs.contains(vk) else { return }
|
||||
stopAutoRepeat()
|
||||
autoRepeatVK = vk
|
||||
let timer = Timer(timeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||
guard let self, self.autoRepeatVK == vk else { return }
|
||||
let ticker = Timer(timeInterval: 0.04, repeats: true) { [weak self] _ in
|
||||
// Stop the moment the key is no longer held — a release that raced the timer, or a
|
||||
// releaseAll from a blur, must not keep typing into the host.
|
||||
guard let self, self.autoRepeatVK == vk, self.forwarding,
|
||||
self.pressedVKs.contains(vk)
|
||||
else {
|
||||
self?.stopAutoRepeat()
|
||||
return
|
||||
}
|
||||
self.emitKey(vk, down: true)
|
||||
}
|
||||
self.autoRepeatTimer = ticker
|
||||
RunLoop.main.add(ticker, forMode: .common)
|
||||
}
|
||||
autoRepeatTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
private func stopAutoRepeat() {
|
||||
autoRepeatTimer?.invalidate()
|
||||
autoRepeatTimer = nil
|
||||
autoRepeatVK = nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
|
||||
/// active location-based modifier layout is applied in exactly one place while all internal
|
||||
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
|
||||
@@ -534,16 +599,24 @@ public final class InputCapture {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll WHEEL (plain HID mice) while pointer-locked: GCMouse's scroll dpad reports
|
||||
// wheel deltas here, +y up / +x right — already the host's WHEEL convention, one unit
|
||||
// per notch → ×120 (WHEEL_DELTA), residual-accumulated by sendScroll. (Trackpad
|
||||
// two-finger scrolling is gesture-based and does NOT reach GameController — that
|
||||
// arrives via the stream view's scroll pan recognizer; on macOS, via scrollWheel.)
|
||||
// Scroll WHEEL, tvOS only. GCMouse's scroll dpad reports raw device deltas, +y up / +x
|
||||
// right — the host's WHEEL convention already, one unit per notch → ×120 (WHEEL_DELTA),
|
||||
// residual-accumulated by sendScroll.
|
||||
//
|
||||
// iOS deliberately installs no handler here and takes ALL scroll from the stream view's
|
||||
// pan recognizer instead — the same one that carries trackpad two-finger scrolling, which
|
||||
// is gesture-based and never reaches GameController. That recognizer sees a plain wheel
|
||||
// too, and unlike this raw axis its deltas already carry the system's Natural Scrolling
|
||||
// preference, so routing everything through it is what makes the setting apply under
|
||||
// pointer lock. Installing both would double-send every wheel notch. (macOS has its own
|
||||
// path: StreamLayerView.scrollWheel.)
|
||||
#if os(tvOS)
|
||||
input.scroll.valueChangedHandler = { [weak self] _, dx, dy in
|
||||
guard let self, self.forwarding, self.gcMouseForwarding else { return }
|
||||
self.sendScroll(dx: dx * 120, dy: dy * 120)
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Forward relative mouse motion (macOS). Fed by StreamLayerView's NSEvent monitor —
|
||||
@@ -676,6 +749,14 @@ public final class InputCapture {
|
||||
self.pressedVKs.remove(vk)
|
||||
}
|
||||
self.emitKey(vk, down: pressed)
|
||||
// GC has no repeat channel, so a held key becomes a repeat here (see startAutoRepeat).
|
||||
// A release only cancels the repeat if it is THIS key's — releasing an older key while
|
||||
// a newer one is still held must leave the newer one repeating.
|
||||
if pressed {
|
||||
self.startAutoRepeat(vk)
|
||||
} else if self.autoRepeatVK == vk {
|
||||
self.stopAutoRepeat()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -368,9 +368,14 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
}
|
||||
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
|
||||
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
|
||||
// ever arrives — GameController has no gesture channel), so gating it here dropped trackpad
|
||||
// scrolling entirely under lock. Nothing double-sends because iOS installs no GCMouse scroll
|
||||
// handler at all: this recognizer sees the wheel too, already carrying the system's Natural
|
||||
// Scrolling preference, which the raw GameController axis does not.
|
||||
streamView.onScroll = { [weak self] dx, dy in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendScroll(dx: dx, dy: dy)
|
||||
self?.inputCapture?.sendScroll(dx: dx, dy: dy)
|
||||
}
|
||||
|
||||
let capture = InputCapture(connection: connection)
|
||||
@@ -920,14 +925,21 @@ final class StreamLayerUIView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trackpad / wheel scroll (no lock) → host scroll deltas. The translation is consumed
|
||||
/// each callback so the next is a fresh delta. Sign/scale are tunable (≈ one notch per
|
||||
/// ~10 pt): finger up scrolls up (host +y), x passes through — the host WHEEL convention.
|
||||
/// Trackpad / wheel scroll → host scroll deltas. The translation is consumed each callback so
|
||||
/// the next is a fresh delta, and scales at ≈ one WHEEL notch per 10 pt of pan.
|
||||
///
|
||||
/// Both axes pass through with their sign intact, which is what makes the stream follow the
|
||||
/// system's Natural Scrolling switch: UIKit has already applied that preference by the time it
|
||||
/// hands us a translation (it is what makes every UIScrollView on the device turn the right
|
||||
/// way), so the sign we get IS the user's choice, and the host's WHEEL convention agrees with
|
||||
/// it — +y is a wheel-forward notch, the one that moves content down. Negating y here, as this
|
||||
/// did, pinned the stream to traditional scrolling and inverted the setting for everyone on the
|
||||
/// default. macOS passes `NSEvent.scrollingDeltaY` through for exactly the same reason.
|
||||
@objc private func handleScroll(_ g: UIPanGestureRecognizer) {
|
||||
guard g.state == .began || g.state == .changed else { return }
|
||||
let t = g.translation(in: self)
|
||||
g.setTranslation(.zero, in: self)
|
||||
onScroll?(Float(t.x) * 12, Float(-t.y) * 12)
|
||||
onScroll?(Float(t.x) * 12, Float(t.y) * 12)
|
||||
}
|
||||
|
||||
/// Map a view-space point through the aspect-fit letterbox into host-mode pixels; points
|
||||
@@ -944,9 +956,21 @@ final class StreamLayerUIView: UIView {
|
||||
return HostPoint(x: x, y: y, w: UInt32(hostMode.width), h: UInt32(hostMode.height))
|
||||
}
|
||||
|
||||
/// `.secondary` (right button / two-finger click) → GameStream right (3); else left (1).
|
||||
/// UIKit's button mask → the wire's GameStream button number.
|
||||
///
|
||||
/// The mask is 1-based over the HID button order — 1 primary, 2 secondary, 3 middle, 4/5 the
|
||||
/// side buttons — while the wire numbers middle and right the other way round (1 left,
|
||||
/// 2 middle, 3 right, 4 X1/back, 5 X2/forward), so only those two swap. Without the 3…5 arms
|
||||
/// every button past the first two fell into the `else` and clicked LEFT on the host.
|
||||
///
|
||||
/// `.primary`/`.secondary` are spelled out because they are the only two named cases; the rest
|
||||
/// come from `.button(_:)`, which takes the same 1-based number.
|
||||
private static func gsButton(for mask: UIEvent.ButtonMask) -> UInt32 {
|
||||
mask.contains(.secondary) ? 3 : 1
|
||||
if mask.contains(.secondary) { return 3 }
|
||||
if mask.contains(.button(3)) { return 2 }
|
||||
if mask.contains(.button(4)) { return 4 }
|
||||
if mask.contains(.button(5)) { return 5 }
|
||||
return 1
|
||||
}
|
||||
|
||||
private func nextFreeID() -> UInt32 {
|
||||
|
||||
Reference in New Issue
Block a user