diff --git a/clients/apple/README.md b/clients/apple/README.md index e0a8bc3..187dde3 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -37,8 +37,9 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3): thread per view, token-cancelled so reconnects can't double-pump. - `InputCapture.swift` — `GCMouse` raw deltas + `GCKeyboard` HID→VK mapping (the host's `vk_to_evdev` consumes Windows VKs), with fractional-delta accumulation so sub-pixel - motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2); scroll is - WHEEL_DELTA(120)-scaled. + motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2). Scroll + arrives via the stream view's `scrollWheel` override instead of GC (trackpad/Magic + Mouse gestures never reach GCMouse's scroll dpad), WHEEL_DELTA(120)-scaled. - **`PunktfunkClient`** (the app): hosts grid (saved in UserDefaults), "+" toolbar sheet to add hosts, stream mode in Settings (⌘,), two trust flows — the trust-on-first-use fingerprint prompt over the live-but-blurred stream, and SPAKE2 PIN diff --git a/clients/apple/Sources/PunktfunkKit/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/InputCapture.swift index 73aedb6..6461c0e 100644 --- a/clients/apple/Sources/PunktfunkKit/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/InputCapture.swift @@ -237,18 +237,26 @@ public final class InputCapture { } } } - input.scroll.valueChangedHandler = { [weak self] _, x, y in - guard let self, self.forwarding else { return } - // WHEEL_DELTA(120) per notch; positive = up / right (Moonlight's convention). - let fy = y * 120 + self.residualScrollY - let fx = x * 120 + self.residualScrollX - let iy = fy.rounded(.towardZero) - let ix = fx.rounded(.towardZero) - self.residualScrollY = fy - iy - self.residualScrollX = fx - ix - if iy != 0 { self.connection.send(.scroll(Int32(iy))) } - if ix != 0 { self.connection.send(.scroll(Int32(ix), horizontal: true)) } - } + // NOTE: no scroll handler here. GCMouse's scroll dpad only fires for plain HID + // wheel deltas — trackpad/Magic Mouse scrolling is gesture-based and never + // reaches GameController. Scroll arrives via the stream view's scrollWheel + // override (NSEvent covers wheels too) → sendScroll(). + } + + /// Forward a scroll gesture, WHEEL_DELTA(120)-scaled (positive = up / right, + /// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery + /// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them). + /// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away. + public func sendScroll(dx: Float, dy: Float) { + guard forwarding else { return } + let fy = dy + residualScrollY + let fx = dx + residualScrollX + let iy = fy.rounded(.towardZero) + let ix = fx.rounded(.towardZero) + residualScrollY = fy - iy + residualScrollX = fx - ix + if iy != 0 { connection.send(.scroll(Int32(iy))) } + if ix != 0 { connection.send(.scroll(Int32(ix), horizontal: true)) } } private func attach(keyboard: GCKeyboard) { diff --git a/clients/apple/Sources/PunktfunkKit/StreamView.swift b/clients/apple/Sources/PunktfunkKit/StreamView.swift index 5907ae7..7902b0d 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamView.swift @@ -215,6 +215,23 @@ public final class StreamLayerView: NSView { super.mouseUp(with: event) } + /// Scroll is forwarded from here, not from GCMouse: trackpad/Magic Mouse gestures + /// never reach GameController's scroll dpad. While captured the cursor is parked + /// mid-view, so this view receives every scroll event. Precise (gesture) deltas are + /// pixels — ~0.1 wheel notch per pixel (SDL's factor) → ×12 for WHEEL_DELTA(120); + /// classic wheels report lines, one notch = ±1 → ×120. Signs pass through as-is, + /// preserving the user's local (natural-)scrolling preference. + public override func scrollWheel(with event: NSEvent) { + guard captured, let inputCapture else { + super.scrollWheel(with: event) + return + } + let scale: Float = event.hasPreciseScrollingDeltas ? 12 : 120 + inputCapture.sendScroll( + dx: Float(event.scrollingDeltaX) * scale, + dy: Float(event.scrollingDeltaY) * scale) + } + // While captured, the view is first responder and consumes key events — GC delivers // them to the host independently, and consuming here stops the responder chain's // "unhandled keyDown" beep without touching the event stream GC may rely on.