diff --git a/.checkpoint b/.checkpoint new file mode 100644 index 00000000..033c33ba --- /dev/null +++ b/.checkpoint @@ -0,0 +1 @@ +05d6c65 | preserve combined branch while split PR work uses local worktrees diff --git a/.gitignore b/.gitignore index db0b15fd..d9b29772 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ Temporary Items ## User settings xcuserdata/ + +# Local agent worktrees +.worktree/ diff --git a/OpenParsec.xcodeproj/project.pbxproj b/OpenParsec.xcodeproj/project.pbxproj index 5526ba57..7314737e 100644 --- a/OpenParsec.xcodeproj/project.pbxproj +++ b/OpenParsec.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 17CD1E032BF07BC3003D2102 /* ViewContainerPatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CD1E022BF07BC3003D2102 /* ViewContainerPatch.swift */; }; 17EA35F72BF712A8001BE0DF /* ParsecSDKBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17EA35F62BF712A8001BE0DF /* ParsecSDKBridge.swift */; }; 17EEB91C2BEE62BA00502A3A /* KeyBoardTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17EEB91B2BEE62BA00502A3A /* KeyBoardTest.swift */; }; + A1B2C3D42F8E000100000002 /* InputMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F8E000100000001 /* InputMapping.swift */; }; 271D14F9292E90EB00D7F1D6 /* ParsecView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 271D14F8292E90EB00D7F1D6 /* ParsecView.swift */; }; 271D14FC292EAA3600D7F1D6 /* ParsecGLKRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 271D14FA292EAA3600D7F1D6 /* ParsecGLKRenderer.swift */; }; 271D14FD292EAA3600D7F1D6 /* ParsecGLKViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 271D14FB292EAA3600D7F1D6 /* ParsecGLKViewController.swift */; }; @@ -65,6 +66,7 @@ 17CD1E022BF07BC3003D2102 /* ViewContainerPatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewContainerPatch.swift; sourceTree = ""; }; 17EA35F62BF712A8001BE0DF /* ParsecSDKBridge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParsecSDKBridge.swift; sourceTree = ""; }; 17EEB91B2BEE62BA00502A3A /* KeyBoardTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBoardTest.swift; sourceTree = ""; }; + A1B2C3D42F8E000100000001 /* InputMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMapping.swift; sourceTree = ""; }; 271D14F8292E90EB00D7F1D6 /* ParsecView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParsecView.swift; sourceTree = ""; }; 271D14FA292EAA3600D7F1D6 /* ParsecGLKRenderer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParsecGLKRenderer.swift; sourceTree = ""; }; 271D14FB292EAA3600D7F1D6 /* ParsecGLKViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParsecGLKViewController.swift; sourceTree = ""; }; @@ -164,6 +166,7 @@ 271D14FB292EAA3600D7F1D6 /* ParsecGLKViewController.swift */, 27B23A212B1B979C00B52F14 /* ParsecMetalRenderer.swift */, 27B23A232B1B98EF00B52F14 /* ParsecMetalViewController.swift */, + A1B2C3D42F8E000100000001 /* InputMapping.swift */, 17EA35F62BF712A8001BE0DF /* ParsecSDKBridge.swift */, 271D14F8292E90EB00D7F1D6 /* ParsecView.swift */, 170E0A762BEF5AFF00B0EA32 /* ParsecViewController.swift */, @@ -305,6 +308,7 @@ 27899AC7292FE2A9001ACA33 /* audio.c in Sources */, 27E61AA62929817700FF6563 /* LoginView.swift in Sources */, 27B8D564292DC7A000A324AD /* CParsec.swift in Sources */, + A1B2C3D42F8E000100000002 /* InputMapping.swift in Sources */, 27A923E229E8FEE900F54BDA /* UIViewControllerWrapper.swift in Sources */, 170E0A772BEF5AFF00B0EA32 /* ParsecViewController.swift in Sources */, 27B23A222B1B979C00B52F14 /* ParsecMetalRenderer.swift in Sources */, diff --git a/OpenParsec/GameController.swift b/OpenParsec/GameController.swift index 78a529d3..2c7dd9b7 100644 --- a/OpenParsec/GameController.swift +++ b/OpenParsec/GameController.swift @@ -9,6 +9,9 @@ class GamepadController { private(set) var mice = Set() // private var panRecognizer: UIPanGestureRecognizer! weak var delegate: InputManagerDelegate? + var pointerInputStatusProvider: (() -> PointerInputStatus?)? + var pointerButtonTouchSupported = false + var gcmouseScrollHandler: ((GCMouseScrollAxis, Float) -> Void)? public func viewDidLoad() { @@ -82,32 +85,56 @@ class GamepadController { func registerMouseHandler() { for mouse in GCMouse.mice() { mice.insert(mouse) - mouse.mouseInput?.leftButton.pressedChangedHandler = {(_: GCControllerButtonInput, _: Float, pressed: Bool) in + mouse.mouseInput?.leftButton.pressedChangedHandler = {[weak self] (_: GCControllerButtonInput, _: Float, pressed: Bool) in + guard GCMousePointerMapper.shouldSendButton(pointerButtonTouchSupported: self?.pointerButtonTouchSupported == true) else { return } guard ParsecBackgroundManager.shared.hasActiveConnection else { return } CParsec.sendMouseClickMessage(MOUSE_L, pressed) } - mouse.mouseInput?.rightButton?.pressedChangedHandler = {(_: GCControllerButtonInput, _: Float, pressed: Bool) in + mouse.mouseInput?.rightButton?.pressedChangedHandler = {[weak self] (_: GCControllerButtonInput, _: Float, pressed: Bool) in + guard GCMousePointerMapper.shouldSendButton(pointerButtonTouchSupported: self?.pointerButtonTouchSupported == true) else { return } // pointer-lock toggles on the connect/disconnect view swap can synthesize a button edge // with no real input — dont forward it unless a session is actually live guard ParsecBackgroundManager.shared.hasActiveConnection else { return } CParsec.sendMouseClickMessage(MOUSE_R, pressed) } - mouse.mouseInput?.middleButton?.pressedChangedHandler = {(_: GCControllerButtonInput, _: Float, pressed: Bool) in + mouse.mouseInput?.middleButton?.pressedChangedHandler = {[weak self] (_: GCControllerButtonInput, _: Float, pressed: Bool) in + guard GCMousePointerMapper.shouldSendButton(pointerButtonTouchSupported: self?.pointerButtonTouchSupported == true) else { return } guard ParsecBackgroundManager.shared.hasActiveConnection else { return } CParsec.sendMouseClickMessage(MOUSE_MIDDLE, pressed) } - mouse.mouseInput?.mouseMovedHandler={(_: GCMouseInput, v: Float, v2: Float) in + mouse.mouseInput?.mouseMovedHandler={[weak self] (_: GCMouseInput, v: Float, v2: Float) in + guard let status = self?.pointerInputStatus() else { + return + } + let pointerHoverSupported: Bool + if #available(iOS 13.4, *) { + pointerHoverSupported = true + } else { + pointerHoverSupported = false + } + guard GCMousePointerMapper.shouldSendRelativeMove(pointerHoverSupported: pointerHoverSupported) else { + return + } + guard status.isActive else { + return + } CParsec.sendMouseDelta(Int32(v/1.25 * Float(SettingsHandler.mouseSensitivity)), Int32(-v2/1.25 * Float(SettingsHandler.mouseSensitivity))) } - mouse.mouseInput?.scroll.yAxis.valueChangedHandler = {(_: GCControllerAxisInput, value: Float) in - CParsec.sendWheelMsg(x: Int32(value), y: 0) + mouse.mouseInput?.scroll.yAxis.valueChangedHandler = {[weak self] (_: GCControllerAxisInput, value: Float) in + ScrollInputGate.recordGCMouseScroll() + self?.gcmouseScrollHandler?(.y, value) } - mouse.mouseInput?.scroll.xAxis.valueChangedHandler = {(_: GCControllerAxisInput, value: Float) in - CParsec.sendWheelMsg(x: 0, y: Int32(value)) + mouse.mouseInput?.scroll.xAxis.valueChangedHandler = {[weak self] (_: GCControllerAxisInput, value: Float) in + ScrollInputGate.recordGCMouseScroll() + self?.gcmouseScrollHandler?(.x, value) } } } + private func pointerInputStatus() -> PointerInputStatus? { + return pointerInputStatusProvider?() + } + @objc func didMouseConnectController(_ notification: Notification) { self.registerMouseHandler() } diff --git a/OpenParsec/InputMapping.swift b/OpenParsec/InputMapping.swift new file mode 100644 index 00000000..3245d595 --- /dev/null +++ b/OpenParsec/InputMapping.swift @@ -0,0 +1,237 @@ +import Foundation +import CoreGraphics + +enum ZoomViewportPolicy { + private static let originalScaleTolerance: CGFloat = 0.01 + + static func shouldConstrain( + zoomEnabled: Bool, + zoomScale: CGFloat, + minimumZoomScale: CGFloat + ) -> Bool { + return zoomEnabled && zoomScale > minimumZoomScale + originalScaleTolerance + } + + static func shouldRepositionForCursor( + zoomEnabled: Bool, + zoomScale: CGFloat, + minimumZoomScale: CGFloat, + directMouseMode: Bool + ) -> Bool { + return !directMouseMode && shouldConstrain( + zoomEnabled: zoomEnabled, + zoomScale: zoomScale, + minimumZoomScale: minimumZoomScale + ) + } +} + +enum ScrollWheelMapper { + private static let wheelDivisor: Float = 20.0 + + static func wheelScale(sensitivity: Float, naturalScrolling: Bool) -> Float { + let direction: Float = naturalScrolling ? -1.0 : 1.0 + return sensitivity * direction / wheelDivisor + } + + static func wheelUnits(rawDelta: Float, sensitivity: Float, naturalScrolling: Bool) -> Int32 { + return Int32(rawDelta * wheelScale(sensitivity: sensitivity, naturalScrolling: naturalScrolling)) + } +} + +struct ScrollWheel { + let x: Int32 + let y: Int32 +} + +enum GCMouseScrollAxis { + case x + case y +} + +enum GCMouseScrollMapper { + static func adjustedDelta(axis: GCMouseScrollAxis, rawValue: Float, naturalScrolling: Bool) -> (x: Float, y: Float) { + let direction: Float = naturalScrolling ? -1.0 : 1.0 + let adjusted = rawValue * direction + switch axis { + case .x: + return (x: 0, y: adjusted) + case .y: + return (x: adjusted, y: 0) + } + } +} + +struct GCMouseScrollMotion { + private var accumulatedX: Float = 0 + private var accumulatedY: Float = 0 + private(set) var velocityX: Float = 0 + private(set) var velocityY: Float = 0 + private(set) var lastEventTime: TimeInterval? + + mutating func consume( + axis: GCMouseScrollAxis, + rawValue: Float, + naturalScrolling: Bool, + at time: TimeInterval + ) -> ScrollWheel { + let delta = GCMouseScrollMapper.adjustedDelta( + axis: axis, + rawValue: rawValue, + naturalScrolling: naturalScrolling + ) + + if let lastEventTime { + let elapsed = Float(time - lastEventTime) + if elapsed > 0.0005 && elapsed < 0.1 { + switch axis { + case .x: + velocityY = velocityY * 0.4 + (delta.y / elapsed) * 0.6 + case .y: + velocityX = velocityX * 0.4 + (delta.x / elapsed) * 0.6 + } + } else if elapsed >= 0.1 { + velocityX = 0 + velocityY = 0 + } + } + lastEventTime = time + + return accumulate(deltaX: delta.x, deltaY: delta.y) + } + + mutating func momentumWheel(deltaTime: Float, decayPerSecond: Double) -> ScrollWheel { + let decay = Float(pow(decayPerSecond, Double(deltaTime))) + velocityX *= decay + velocityY *= decay + return accumulate(deltaX: velocityX * deltaTime, deltaY: velocityY * deltaTime) + } + + func shouldStartMomentum(minimumSpeed: Float) -> Bool { + return speed >= minimumSpeed + } + + func shouldStopMomentum(maximumSpeed: Float) -> Bool { + return speed < maximumSpeed + } + + mutating func reset() { + accumulatedX = 0 + accumulatedY = 0 + velocityX = 0 + velocityY = 0 + lastEventTime = nil + } + + private var speed: Float { + return sqrt(velocityX * velocityX + velocityY * velocityY) + } + + private mutating func accumulate(deltaX: Float, deltaY: Float) -> ScrollWheel { + accumulatedX += deltaX + accumulatedY += deltaY + let wheel = ScrollWheel(x: Int32(accumulatedX), y: Int32(accumulatedY)) + accumulatedX -= Float(wheel.x) + accumulatedY -= Float(wheel.y) + return wheel + } +} + +enum ScrollInputGate { + private static let trackpadSuppressionWindow: TimeInterval = 0.20 + private static var lastGCMouseScrollTime: TimeInterval? + + static func recordGCMouseScroll(at time: TimeInterval = Date().timeIntervalSinceReferenceDate) { + lastGCMouseScrollTime = time + } + + static func shouldSendTrackpadScroll(at time: TimeInterval = Date().timeIntervalSinceReferenceDate) -> Bool { + guard let lastGCMouseScrollTime else { + return true + } + + return time - lastGCMouseScrollTime >= trackpadSuppressionWindow + } + + static func reset() { + lastGCMouseScrollTime = nil + } +} + +enum PointerInputGate { + // Absolute hover input needs an unlocked system pointer. + static let prefersPointerLocked = false + + static func status( + windowIsFocused: Bool, + appIsActive: Bool, + sceneIsForegroundActive: Bool + ) -> PointerInputStatus { + return PointerInputStatus( + windowIsFocused: windowIsFocused, + appIsActive: appIsActive, + sceneIsForegroundActive: sceneIsForegroundActive + ) + } + + static func isActive( + windowIsFocused: Bool, + appIsActive: Bool, + sceneIsForegroundActive: Bool + ) -> Bool { + return status( + windowIsFocused: windowIsFocused, + appIsActive: appIsActive, + sceneIsForegroundActive: sceneIsForegroundActive + ).isActive + } +} + +struct PointerInputStatus { + let windowIsFocused: Bool + let appIsActive: Bool + let sceneIsForegroundActive: Bool + + var isActive: Bool { + return windowIsFocused && appIsActive && sceneIsForegroundActive + } +} + +enum GCMousePointerMapper { + static func shouldSendRelativeMove(pointerHoverSupported: Bool) -> Bool { + return !pointerHoverSupported + } + + static func shouldSendButton(pointerButtonTouchSupported: Bool) -> Bool { + return !pointerButtonTouchSupported + } +} + +enum PointerPositionMapper { + static func hostPosition( + pointerLocation: CGPoint, + visibleFrame: CGRect, + contentSize: CGSize, + windowIsFocused: Bool = true, + appIsActive: Bool = true, + sceneIsForegroundActive: Bool = true + ) -> CGPoint? { + guard PointerInputGate.isActive( + windowIsFocused: windowIsFocused, + appIsActive: appIsActive, + sceneIsForegroundActive: sceneIsForegroundActive + ) else { + return nil + } + guard visibleFrame.width > 0, visibleFrame.height > 0, contentSize.width > 0, contentSize.height > 0 else { + return nil + } + guard visibleFrame.contains(pointerLocation) else { + return nil + } + + let relativeX = (pointerLocation.x - visibleFrame.minX) / visibleFrame.width + let relativeY = (pointerLocation.y - visibleFrame.minY) / visibleFrame.height + return CGPoint(x: relativeX * contentSize.width, y: relativeY * contentSize.height) + } +} diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index e508d1f5..21e87c57 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -176,7 +176,7 @@ struct ParsecView: View { let save = SettingsHandler.saveSessionSettings _muted = State(initialValue: SettingsHandler.startMuted || (save && SettingsHandler.savedMuted)) - _zoomEnabled = State(initialValue: save ? SettingsHandler.savedZoomEnabled : false) + _zoomEnabled = State(initialValue: SettingsHandler.pinchZoomEnabled) _constantFps = State(initialValue: save ? SettingsHandler.savedConstantFps : false) _resolutions = State(initialValue: ParsecResolution.resolutions) _bitrates = State(initialValue: ParsecResolution.bitrates) @@ -413,6 +413,7 @@ struct ParsecView: View { CParsec.pause() ParsecBackgroundManager.shared.isPaused = true } else { + parsecViewController.restorePointerInput() if ParsecBackgroundManager.shared.isReconnecting { return } @@ -459,6 +460,15 @@ struct ParsecView: View { parsecViewController.onKeyboardVisibilityChanged = { visible in showKeyboard = visible } + let menuVisibility = $showMenu + parsecViewController.onMenuEdgeSwipe = { + DispatchQueue.main.async { + menuVisibility.wrappedValue = true + let data = Data() + CParsec.sendUserData(type: .getVideoConfig, message: data) + CParsec.sendUserData(type: .getAdapterInfo, message: data) + } + } parsecViewController.setKeyboardVisible(showKeyboard) } @@ -565,7 +575,7 @@ struct ParsecView: View { DispatchQueue.main.async { zoomEnabled.toggle() parsecViewController.setZoomEnabled(zoomEnabled) - if SettingsHandler.saveSessionSettings { SettingsHandler.savedZoomEnabled = zoomEnabled } + SettingsHandler.pinchZoomEnabled = zoomEnabled } } diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index deb80beb..2015e117 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -70,13 +70,24 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI var keyboardHeight: CGFloat = 0.0 var keyboardVisible: Bool = false var onKeyboardVisibilityChanged: ((Bool) -> Void)? + var onMenuEdgeSwipe: (() -> Void)? var scrollView: UIScrollView! var contentView: UIView! var lastLaidOutWidth: CGFloat = 0 var lastLaidOutHeight: CGFloat = 0 + private weak var pointerHoverGestureRecognizer: UIHoverGestureRecognizer? + private var lastTrackpadScrollTranslation: CGPoint = .zero + private var accumulatedTrackpadScrollX: Float = 0 + private var accumulatedTrackpadScrollY: Float = 0 + private var gcmouseScrollMotion = GCMouseScrollMotion() + private var gcmouseScrollTracking = false + private var gcmouseScrollMomentumActive = false + private let gcmouseScrollIdleDelay: TimeInterval = 0.05 + private let gcmouseScrollStartSpeed: Float = 20 + private let gcmouseScrollStopSpeed: Float = 2 override var prefersPointerLocked: Bool { - return true + return PointerInputGate.prefersPointerLocked } override var prefersHomeIndicatorAutoHidden: Bool { @@ -150,10 +161,11 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI // driven moves are reflected and any prediction drift is corrected. if !isDragging && !clickHoldActive && !cursorMomentumActive { cursorContentPos = CGPoint(x: CGFloat(currentMouseX), y: CGFloat(currentMouseY)) - positionCursorOverlay() if scrollView.zoomScale > 1.0 { - centerViewportOnCursorPos() - } else if keyboardVisible && scrollView.contentInset.bottom > 0 { + repositionViewportForCursor() + } + positionCursorOverlay() + if scrollView.zoomScale <= 1.0 && keyboardVisible && scrollView.contentInset.bottom > 0 { // Not zoomed: keep the cursor above the on-screen keyboard. let margin: CGFloat = 50.0 let effectiveViewHeight = view.bounds.height - keyboardHeight @@ -213,6 +225,15 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI } touchController.viewDidLoad() + gamePadController.pointerInputStatusProvider = { [weak self] in + self?.pointerInputStatus() + } + gamePadController.gcmouseScrollHandler = { [weak self] axis, value in + self?.handleGCMouseScroll(axis: axis, rawValue: value) + } + if #available(iOS 13.4, *) { + gamePadController.pointerButtonTouchSupported = true + } gamePadController.viewDidLoad() // Touch overlay: a transparent sibling ON TOP of the scroll view (and above the gamepad / @@ -226,6 +247,13 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI touchOverlay.inputDelegate = self view.addSubview(touchOverlay) + let menuEdgeGestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleMenuEdgeSwipe(_:))) + menuEdgeGestureRecognizer.edges = SettingsHandler.menuSwipeEdge == .left ? .left : .right + menuEdgeGestureRecognizer.allowedTouchTypes = [0] + menuEdgeGestureRecognizer.cancelsTouchesInView = true + menuEdgeGestureRecognizer.delaysTouchesBegan = true + view.addGestureRecognizer(menuEdgeGestureRecognizer) + // Cursor is a screen-space overlay ON TOP of everything (not inside the scroll view), so the // zoom transform never scales it - keeping it crisp and a constant on-screen size. u = UIImageView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) @@ -237,6 +265,31 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI let pointerInteraction = UIPointerInteraction(delegate: self) view.addInteraction(pointerInteraction) + if #available(iOS 13.4, *) { + let pointerButtonGestureRecognizer = PointerButtonGestureRecognizer() + pointerButtonGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)] + pointerButtonGestureRecognizer.cancelsTouchesInView = false + pointerButtonGestureRecognizer.delegate = self + pointerButtonGestureRecognizer.shouldForwardPress = { [weak self] in + guard let self else { return false } + return ParsecBackgroundManager.shared.hasActiveConnection && self.pointerInputStatus().isActive + } + pointerButtonGestureRecognizer.buttonChangedHandler = { [weak self] button, pressed in + self?.handlePointerButton(button, pressed: pressed) + } + view.addGestureRecognizer(pointerButtonGestureRecognizer) + + let hoverGestureRecognizer = UIHoverGestureRecognizer(target: self, action: #selector(handlePointerHover(_:))) + view.addGestureRecognizer(hoverGestureRecognizer) + pointerHoverGestureRecognizer = hoverGestureRecognizer + + let scrollGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleTrackpadScroll(_:))) + scrollGestureRecognizer.delegate = self + scrollGestureRecognizer.allowedScrollTypesMask = .all + scrollGestureRecognizer.maximumNumberOfTouches = 0 + view.addGestureRecognizer(scrollGestureRecognizer) + } + view.isMultipleTouchEnabled = true view.isUserInteractionEnabled = true @@ -277,28 +330,58 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI longPressGestureRecognizer.cancelsTouchesInView = false view.addGestureRecognizer(longPressGestureRecognizer) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) + } - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) + private func startObservingInputLifecycle() { + stopObservingInputLifecycle() + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(windowDidBecomeKey), name: UIWindow.didBecomeKeyNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(sceneDidActivate), name: UIScene.didActivateNotification, object: nil) + } - // Backgrounding / Control Center / system alerts can orphan touches; flush on resign. - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) + private func stopObservingInputLifecycle() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIWindow.didBecomeKeyNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil) + } + + private func refreshPointerInput() { + guard isViewLoaded, view.window != nil else { return } + if #available(iOS 13.4, *) { + pointerHoverGestureRecognizer?.isEnabled = false + pointerHoverGestureRecognizer?.isEnabled = true + } + parent?.setChildViewControllerForPointerLock(self) + setNeedsUpdateOfPrefersPointerLocked() + } + + func restorePointerInput() { + refreshPointerInput() + // Activation and PiP teardown can precede the final window state. Refresh again + // on the next main-loop turn without requesting a lock that disables hover input. + DispatchQueue.main.async { [weak self] in + self?.refreshPointerInput() + } + } + + @objc private func appDidBecomeActive() { + restorePointerInput() + } + @objc private func windowDidBecomeKey(_ notification: Notification) { + guard let keyWindow = notification.object as? UIWindow, keyWindow === view.window else { return } + restorePointerInput() + } + + @objc private func sceneDidActivate(_ notification: Notification) { + guard let scene = notification.object as? UIScene, scene === view.window?.windowScene else { return } + restorePointerInput() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -349,6 +432,12 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI becomeFirstResponder() } // (Pinch-to-zoom is driven manually via touchOverlay; the scroll view's pinch stays off.) + restorePointerInput() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + startObservingInputLifecycle() } override func viewWillDisappear(_ animated: Bool) { @@ -358,9 +447,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI parent.setChildForHomeIndicatorAutoHidden(nil) parent.setChildViewControllerForPointerLock(nil) } - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + stopObservingInputLifecycle() } @@ -526,10 +613,124 @@ extension ParsecViewController: UIGestureRecognizerDelegate { return true } + @objc private func handleMenuEdgeSwipe(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) { + guard gestureRecognizer.state == .began else { return } + onMenuEdgeSwipe?() + } + @objc func handlePinchGesture(_ gestureRecognizer: UIPinchGestureRecognizer) { // Pinch is handled by UIScrollView } + private func sendAbsolutePointerPosition(_ pointerLocation: CGPoint) { + let visibleFrame = contentView.convert(contentView.bounds, to: view) + let status = pointerInputStatus() + guard let hostPosition = PointerPositionMapper.hostPosition( + pointerLocation: pointerLocation, + visibleFrame: visibleFrame, + contentSize: contentView.bounds.size, + windowIsFocused: status.windowIsFocused, + appIsActive: status.appIsActive, + sceneIsForegroundActive: status.sceneIsForegroundActive + ) else { return } + + CParsec.sendMousePosition(Int32(hostPosition.x), Int32(hostPosition.y)) + } + + func pointerInputStatus() -> PointerInputStatus { + PointerInputGate.status( + windowIsFocused: view.window?.isKeyWindow == true, + appIsActive: UIApplication.shared.applicationState == .active, + sceneIsForegroundActive: view.window?.windowScene?.activationState == .foregroundActive + ) + } + + @available(iOS 13.4, *) + @objc func handlePointerHover(_ gestureRecognizer: UIHoverGestureRecognizer) { + switch gestureRecognizer.state { + case .began, .changed: + sendAbsolutePointerPosition(gestureRecognizer.location(in: view)) + default: + break + } + } + + @available(iOS 13.4, *) + private func handlePointerButton(_ button: UIEvent.ButtonMask, pressed: Bool) { + let parsecButton: ParsecMouseButton + switch button { + case .primary: + parsecButton = MOUSE_L + case .secondary: + parsecButton = MOUSE_R + case .button(3): + parsecButton = MOUSE_MIDDLE + default: + return + } + CParsec.sendMouseClickMessage(parsecButton, pressed) + } + + @available(iOS 13.4, *) + @objc func handleTrackpadScroll(_ gestureRecognizer: UIPanGestureRecognizer) { + let translation = gestureRecognizer.translation(in: gestureRecognizer.view) + switch gestureRecognizer.state { + case .began: + lastTrackpadScrollTranslation = translation + accumulatedTrackpadScrollX = 0 + accumulatedTrackpadScrollY = 0 + case .changed: + let deltaX = Float(translation.x - lastTrackpadScrollTranslation.x) + let deltaY = Float(translation.y - lastTrackpadScrollTranslation.y) + lastTrackpadScrollTranslation = translation + + guard ScrollInputGate.shouldSendTrackpadScroll() else { + accumulatedTrackpadScrollX = 0 + accumulatedTrackpadScrollY = 0 + return + } + + let scale = ScrollWheelMapper.wheelScale( + sensitivity: mouseSensitivity, + naturalScrolling: SettingsHandler.naturalScrolling + ) + accumulatedTrackpadScrollX += deltaX * scale + accumulatedTrackpadScrollY += deltaY * scale + + let wheelX = Int32(accumulatedTrackpadScrollX) + let wheelY = Int32(accumulatedTrackpadScrollY) + if wheelX != 0 || wheelY != 0 { + CParsec.sendWheelMsg(x: wheelX, y: wheelY) + accumulatedTrackpadScrollX -= Float(wheelX) + accumulatedTrackpadScrollY -= Float(wheelY) + } + case .ended, .cancelled, .failed: + lastTrackpadScrollTranslation = .zero + accumulatedTrackpadScrollX = 0 + accumulatedTrackpadScrollY = 0 + default: + break + } + } + + private func handleGCMouseScroll(axis: GCMouseScrollAxis, rawValue: Float) { + let now = CACurrentMediaTime() + gcmouseScrollTracking = true + gcmouseScrollMomentumActive = false + scrollMomentumActive = false + + let wheel = gcmouseScrollMotion.consume( + axis: axis, + rawValue: rawValue, + naturalScrolling: SettingsHandler.naturalScrolling, + at: now + ) + if wheel.x != 0 || wheel.y != 0 { + CParsec.sendWheelMsg(x: wheel.x, y: wheel.y) + } + ensureMomentumLink() + } + // MARK: - Manual touch handling (ParsecTouchInputDelegate) // touchOverlay delivers raw touches here. One finger drives the mouse cursor; two fingers are // classified once per gesture into either pinch-zoom or host scroll-wheel (never both), so the @@ -703,7 +904,7 @@ extension ParsecViewController: UIGestureRecognizerDelegate { // Command the host to the ABSOLUTE predicted position (not a delta) so the host cursor can't // crawl behind network round-trips - clicks always land where the cursor is drawn. CParsec.sendMousePosition(Int32(cursorContentPos.x), Int32(cursorContentPos.y)) - centerViewportOnCursorPos() + repositionViewportForCursor() positionCursorOverlay() } @@ -777,6 +978,9 @@ extension ParsecViewController: UIGestureRecognizerDelegate { func stopMomentum() { cursorMomentumActive = false scrollMomentumActive = false + gcmouseScrollTracking = false + gcmouseScrollMomentumActive = false + gcmouseScrollMotion.reset() cursorVelocity = .zero scrollVelocity = 0 momentumLink?.invalidate() @@ -798,7 +1002,7 @@ extension ParsecViewController: UIGestureRecognizerDelegate { if !CParsec.mouseInfo.mousePositionRelative { CParsec.sendMousePosition(Int32(cursorContentPos.x), Int32(cursorContentPos.y)) } - centerViewportOnCursorPos() + repositionViewportForCursor() positionCursorOverlay() let stalled = abs(cursorContentPos.x - before.x) < 0.05 && abs(cursorContentPos.y - before.y) < 0.05 if hypot(cursorVelocity.x, cursorVelocity.y) < cursorStopSpeed || stalled { @@ -823,6 +1027,37 @@ extension ParsecViewController: UIGestureRecognizerDelegate { } } + if gcmouseScrollTracking { + if let lastEventTime = gcmouseScrollMotion.lastEventTime, + CACurrentMediaTime() - lastEventTime < gcmouseScrollIdleDelay { + stillActive = true + } else { + gcmouseScrollTracking = false + gcmouseScrollMomentumActive = gcmouseScrollMotion.shouldStartMomentum( + minimumSpeed: gcmouseScrollStartSpeed + ) + if !gcmouseScrollMomentumActive { + gcmouseScrollMotion.reset() + } + } + } + + if gcmouseScrollMomentumActive { + let wheel = gcmouseScrollMotion.momentumWheel( + deltaTime: Float(dt), + decayPerSecond: scrollDecayPerSec + ) + if wheel.x != 0 || wheel.y != 0 { + CParsec.sendWheelMsg(x: wheel.x, y: wheel.y) + } + if gcmouseScrollMotion.shouldStopMomentum(maximumSpeed: gcmouseScrollStopSpeed) { + gcmouseScrollMomentumActive = false + gcmouseScrollMotion.reset() + } else { + stillActive = true + } + } + if !stillActive { stopMomentum() } } @@ -836,23 +1071,72 @@ extension ParsecViewController: UIGestureRecognizerDelegate { let contentX = (anchor.x + off.x) / oldZoom let contentY = (anchor.y + off.y) / oldZoom scrollView.zoomScale = newZoom - var newOff = CGPoint(x: contentX * newZoom - anchor.x, y: contentY * newZoom - anchor.y) - let maxX = max(0, scrollView.contentSize.width - scrollView.bounds.width) - let maxY = max(0, scrollView.contentSize.height - scrollView.bounds.height) - newOff.x = min(max(0, newOff.x), maxX) - newOff.y = min(max(0, newOff.y), maxY) + let proposedOffset = CGPoint(x: contentX * newZoom - anchor.x, y: contentY * newZoom - anchor.y) + let newOff = clampViewportOffset(proposedOffset, zoom: newZoom) scrollView.setContentOffset(newOff, animated: false) - // Park the cursor at the viewport centre after zooming so a later drag doesn't snap the view. - let bottomInset = keyboardVisible ? keyboardHeight : 0.0 - let centerX = (scrollView.bounds.width / 2 + scrollView.contentOffset.x) / newZoom - let centerY = ((scrollView.bounds.height - bottomInset) / 2 + scrollView.contentOffset.y) / newZoom - cursorContentPos = clampToContent(CGPoint(x: centerX, y: centerY)) - if !CParsec.mouseInfo.mousePositionRelative { - CParsec.sendMousePosition(Int32(cursorContentPos.x), Int32(cursorContentPos.y)) - } + // Preserve the actual host cursor position. Pinching moves only the viewport. positionCursorOverlay() } + private func hostContentRect() -> CGRect { + let viewport = scrollView.bounds.size + let hostWidth = CGFloat(CParsec.hostWidth) + let hostHeight = CGFloat(CParsec.hostHeight) + guard viewport.width > 0, viewport.height > 0, hostWidth > 0, hostHeight > 0 else { + return CGRect(origin: .zero, size: viewport) + } + let fitScale = min(viewport.width / hostWidth, viewport.height / hostHeight) + let contentSize = CGSize(width: hostWidth * fitScale, height: hostHeight * fitScale) + return CGRect( + x: (viewport.width - contentSize.width) / 2, + y: (viewport.height - contentSize.height) / 2, + width: contentSize.width, + height: contentSize.height + ) + } + + private func clampViewportOffset(_ proposed: CGPoint, zoom: CGFloat) -> CGPoint { + guard ZoomViewportPolicy.shouldConstrain( + zoomEnabled: zoomEnabled, + zoomScale: zoom, + minimumZoomScale: scrollView.minimumZoomScale + ) else { return proposed } + + let viewport = scrollView.bounds.size + let hostRect = hostContentRect() + func clampAxis( + _ value: CGFloat, + contentMin: CGFloat, + contentMax: CGFloat, + viewportLength: CGFloat + ) -> CGFloat { + let lower = contentMin * zoom + let upper = contentMax * zoom - viewportLength + if upper >= lower { + // Once the scaled host fills this axis, cap panning at both host edges so + // cursor-following can never expose letterbox space. + return min(max(value, lower), upper) + } + // Before this axis fills the viewport, keep its unavoidable letterbox space + // centred. Do not switch alignment based on cursor position; that causes jumps. + return (lower + upper) / 2 + } + return CGPoint( + x: clampAxis( + proposed.x, + contentMin: hostRect.minX, + contentMax: hostRect.maxX, + viewportLength: viewport.width + ), + y: clampAxis( + proposed.y, + contentMin: hostRect.minY, + contentMax: hostRect.maxY, + viewportLength: viewport.height + ) + ) + } + private func clampToContent(_ p: CGPoint) -> CGPoint { let w = view.bounds.width let h = view.bounds.height @@ -941,26 +1225,38 @@ extension ParsecViewController: UIGestureRecognizerDelegate { } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { - centerViewportOnCursorPos() - } - - // Slides the viewport so the host cursor stays centered on screen while zoomed in, - // clamped so the cursor can still reach the true edges of the host screen. - func centerViewportOnCursorPos() { - guard scrollView.zoomScale > 1.0 else { return } + repositionViewportForCursor() + } + + // Move the viewport only when the cursor approaches an edge, without pinning it to centre. + // Once the scaled host fills the viewport, the offset cap prevents all four host edges from + // crossing into view and exposing letterbox space. + func repositionViewportForCursor() { + guard ZoomViewportPolicy.shouldRepositionForCursor( + zoomEnabled: zoomEnabled, + zoomScale: scrollView.zoomScale, + minimumZoomScale: scrollView.minimumZoomScale, + directMouseMode: SettingsHandler.cursorMode == .direct + ) else { return } let zoom = scrollView.zoomScale - let bottomInset = keyboardVisible ? keyboardHeight : 0.0 - let visibleWidth = view.bounds.width - let visibleHeight = view.bounds.height - bottomInset + let visibleWidth = scrollView.bounds.width + let visibleHeight = scrollView.bounds.height let cursorX = cursorContentPos.x * zoom let cursorY = cursorContentPos.y * zoom - var targetX = cursorX - visibleWidth / 2 - var targetY = cursorY - visibleHeight / 2 - let maxX = max(0, scrollView.contentSize.width - scrollView.bounds.width) - let maxY = max(0, scrollView.contentSize.height - scrollView.bounds.height + bottomInset) - targetX = min(max(0, targetX), maxX) - targetY = min(max(0, targetY), maxY) - scrollView.setContentOffset(CGPoint(x: targetX, y: targetY), animated: false) + let marginX = min(96, visibleWidth * 0.18) + let marginY = min(96, visibleHeight * 0.18) + var proposed = scrollView.contentOffset + if cursorX < proposed.x + marginX { + proposed.x = cursorX - marginX + } else if cursorX > proposed.x + visibleWidth - marginX { + proposed.x = cursorX - visibleWidth + marginX + } + if cursorY < proposed.y + marginY { + proposed.y = cursorY - marginY + } else if cursorY > proposed.y + visibleHeight - marginY { + proposed.y = cursorY - visibleHeight + marginY + } + scrollView.setContentOffset(clampViewportOffset(proposed, zoom: zoom), animated: false) } func setZoomEnabled(_ enabled: Bool) { @@ -977,6 +1273,7 @@ extension ParsecViewController: UIPointerInteractionDelegate { func pointerInteraction(_ inter: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? { let loc = request.location + sendAbsolutePointerPosition(loc) if let iv = view!.hitTest(loc, with: nil) { let rect = view!.convert(iv.bounds, from: iv) let region = UIPointerRegion(rect: rect, identifier: iv.tag) @@ -1243,15 +1540,71 @@ protocol ParsecTouchInputDelegate: AnyObject { func parsecTouchesUpdated(_ activeTouches: Set, moved: Bool) } +@available(iOS 13.4, *) +final class PointerButtonGestureRecognizer: UIGestureRecognizer { + // Preserve raw button down/up edges so rapid clicks and click-drag reach the host unchanged. + var shouldForwardPress: () -> Bool = { true } + var buttonChangedHandler: ((UIEvent.ButtonMask, Bool) -> Void)? + + private let supportedButtons: [UIEvent.ButtonMask] = [.primary, .secondary, .button(3)] + private var activeButtons: UIEvent.ButtonMask = [] + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + guard shouldForwardPress() else { + state = .failed + return + } + + for button in supportedButtons where event.buttonMask.contains(button) { + activeButtons.insert(button) + buttonChangedHandler?(button, true) + } + state = activeButtons.isEmpty ? .failed : .began + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + if state == .began || state == .changed { + state = .changed + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + releaseActiveButtons() + state = .ended + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + releaseActiveButtons() + state = .cancelled + } + + override func reset() { + releaseActiveButtons() + super.reset() + } + + private func releaseActiveButtons() { + for button in supportedButtons where activeButtons.contains(button) { + buttonChangedHandler?(button, false) + } + activeButtons = [] + } +} + // A transparent overlay that owns every raw touch and forwards it to the controller. Sitting on // top of (not inside) the scroll view, it gives deterministic, unfiltered touch delivery with no // UIScrollView gesture races - the controller decides 1-finger cursor vs 2-finger pinch / scroll. class TouchOverlayView: UIView { weak var inputDelegate: ParsecTouchInputDelegate? - // Forward the live touch set (excluding ended/cancelled) on every event. + // External pointers also arrive through the dedicated hover/GCMouse paths. Ignore their + // duplicate UITouch events here so they do not fight the remote cursor position. private func forward(_ event: UIEvent?, moved: Bool) { - let active = (event?.allTouches ?? []).filter { $0.phase != .ended && $0.phase != .cancelled } + let active = (event?.allTouches ?? []).filter { touch in + guard touch.phase != .ended && touch.phase != .cancelled else { return false } + if #available(iOS 13.4, *), touch.type == .indirectPointer { return false } + return true + } inputDelegate?.parsecTouchesUpdated(active, moved: moved) } override func touchesBegan(_ touches: Set, with event: UIEvent?) { forward(event, moved: false) } diff --git a/OpenParsec/PictureInPictureManager.swift b/OpenParsec/PictureInPictureManager.swift index 10259a72..91adb40c 100644 --- a/OpenParsec/PictureInPictureManager.swift +++ b/OpenParsec/PictureInPictureManager.swift @@ -259,8 +259,13 @@ class PictureInPictureManager: NSObject { private func feedSampleBuffer() { guard let pixelBuffer = pixelBuffer, - let displayLayer = sampleBufferDisplayLayer, - displayLayer.isReadyForMoreMediaData else { return } + let displayLayer = sampleBufferDisplayLayer else { return } + + if displayLayer.status == .failed { + displayLayer.flush() + cachedFormatDescription = nil + } + guard displayLayer.isReadyForMoreMediaData else { return } if cachedFormatDescription == nil { CMVideoFormatDescriptionCreateForImageBuffer( @@ -291,6 +296,12 @@ class PictureInPictureManager: NSObject { ) guard let buffer = sampleBuffer else { return } + CMSetAttachment( + buffer, + key: kCMSampleAttachmentKey_DisplayImmediately, + value: kCFBooleanTrue, + attachmentMode: kCMAttachmentMode_ShouldNotPropagate + ) displayLayer.enqueue(buffer) } diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 9f2a873f..4517d658 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -1,6 +1,11 @@ import Foundation import SwiftUI +enum MenuSwipeEdge: Int { + case left + case right +} + struct SettingsHandler { // public static var renderer:RendererType = .opengl @AppStorage("resolution") public static var resolution: ParsecResolution = .client @@ -9,6 +14,8 @@ struct SettingsHandler { @AppStorage("cursorMode") public static var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") public static var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") public static var mouseSensitivity: Double = 1.0 + @AppStorage("naturalScrolling") public static var naturalScrolling: Bool = true + @AppStorage("menuSwipeEdge") public static var menuSwipeEdge: MenuSwipeEdge = .left @AppStorage("noOverlay") public static var noOverlay: Bool = false @AppStorage("hideStatusBar") public static var hideStatusBar: Bool = true @AppStorage("rightClickPosition") public static var rightClickPosition: RightClickPosition = .firstFinger @@ -25,7 +32,7 @@ struct SettingsHandler { @AppStorage("autoConnectOnLaunch") public static var autoConnectOnLaunch: Bool = false @AppStorage("saveSessionSettings") public static var saveSessionSettings: Bool = true - @AppStorage("savedZoomEnabled") public static var savedZoomEnabled: Bool = false + @AppStorage("savedZoomEnabled") public static var pinchZoomEnabled: Bool = false @AppStorage("savedConstantFps") public static var savedConstantFps: Bool = false @AppStorage("savedMuted") public static var savedMuted: Bool = false diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index fc5e4f17..9b3d13ff 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -10,6 +10,9 @@ struct SettingsView: View { @AppStorage("cursorMode") var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") var mouseSensitivity: Double = 1.0 + @AppStorage("naturalScrolling") var naturalScrolling: Bool = true + @AppStorage("menuSwipeEdge") var menuSwipeEdge: MenuSwipeEdge = .left + @AppStorage("savedZoomEnabled") var pinchZoomEnabled: Bool = false @AppStorage("noOverlay") var noOverlay: Bool = false @AppStorage("hideStatusBar") var hideStatusBar: Bool = true @AppStorage("rightClickPosition") var rightClickPosition: RightClickPosition = .firstFinger @@ -109,6 +112,21 @@ struct SettingsView: View { .frame(width: 200) Text(String(format: "%.1f", mouseSensitivity)) } + CatItem("Natural Scrolling") { + Toggle("", isOn: $naturalScrolling) + .frame(width: 80) + } + CatItem("Two-Finger Pinch Zoom") { + Toggle("", isOn: $pinchZoomEnabled) + .frame(width: 80) + } + CatItem("Menu Edge Swipe") { + MultiPicker(selection: $menuSwipeEdge, options: + [ + Choice("Left Edge", MenuSwipeEdge.left), + Choice("Right Edge", MenuSwipeEdge.right) + ]) + } } CatTitle("Graphics") CatList { diff --git a/scripts/check_pointer_mapping.swift b/scripts/check_pointer_mapping.swift new file mode 100644 index 00000000..e488c168 --- /dev/null +++ b/scripts/check_pointer_mapping.swift @@ -0,0 +1,136 @@ +import CoreGraphics +import Foundation + +@main +struct CheckPointerMapping { + static func expect(_ actual: CGPoint?, _ expected: CGPoint, _ label: String) { + guard let actual else { + fatalError("\(label): expected \(expected), got nil") + } + if abs(actual.x - expected.x) > 0.001 || abs(actual.y - expected.y) > 0.001 { + fatalError("\(label): expected \(expected), got \(actual)") + } + } + + static func expectNil(_ actual: CGPoint?, _ label: String) { + if let actual { + fatalError("\(label): expected nil, got \(actual)") + } + } + + static func main() { + if PointerInputGate.prefersPointerLocked && !GCMousePointerMapper.shouldSendRelativeMove(pointerHoverSupported: true) { + fatalError("recovery must not lock the pointer while relative movement is blocked") + } + + let recoveryStates: [(String, Bool, Bool, Bool, Bool)] = [ + ("focused", true, true, true, true), + ("switch app", true, false, false, false), + ("return before scene activation", true, true, false, false), + ("app recovered", true, true, true, true), + ("window unfocused", false, true, true, false), + ("window refocused", true, true, true, true), + ("PiP background", false, false, false, false), + ("PiP restored", true, true, true, true), + ] + for (label, focused, active, foreground, expected) in recoveryStates { + let position = PointerPositionMapper.hostPosition( + pointerLocation: CGPoint(x: 80, y: 60), + visibleFrame: CGRect(x: 0, y: 0, width: 200, height: 100), + contentSize: CGSize(width: 1000, height: 500), + windowIsFocused: focused, appIsActive: active, sceneIsForegroundActive: foreground + ) + if expected { expect(position, CGPoint(x: 400, y: 300), label) } + else { expectNil(position, label) } + } + + let visibleFrame = CGRect(x: 20, y: 40, width: 200, height: 100) + let contentSize = CGSize(width: 1000, height: 500) + + expect( + PointerPositionMapper.hostPosition( + pointerLocation: CGPoint(x: 120, y: 90), + visibleFrame: visibleFrame, + contentSize: contentSize + ), + CGPoint(x: 500, y: 250), + "center maps by relative position" + ) + + expect( + PointerPositionMapper.hostPosition( + pointerLocation: CGPoint(x: 20, y: 40), + visibleFrame: visibleFrame, + contentSize: contentSize + ), + CGPoint(x: 0, y: 0), + "top-left maps to origin" + ) + + expectNil( + PointerPositionMapper.hostPosition( + pointerLocation: CGPoint(x: 19, y: 40), + visibleFrame: visibleFrame, + contentSize: contentSize + ), + "outside visible frame ignored" + ) + + expectNil( + PointerPositionMapper.hostPosition( + pointerLocation: CGPoint(x: 120, y: 90), + visibleFrame: visibleFrame, + contentSize: contentSize, + windowIsFocused: false + ), + "unfocused window ignored" + ) + + expectNil( + PointerPositionMapper.hostPosition( + pointerLocation: CGPoint(x: 120, y: 90), + visibleFrame: visibleFrame, + contentSize: contentSize, + appIsActive: false + ), + "inactive app ignored" + ) + + expectNil( + PointerPositionMapper.hostPosition( + pointerLocation: CGPoint(x: 120, y: 90), + visibleFrame: visibleFrame, + contentSize: contentSize, + sceneIsForegroundActive: false + ), + "inactive scene ignored" + ) + + if PointerInputGate.isActive(windowIsFocused: true, appIsActive: false, sceneIsForegroundActive: true) { + fatalError("inactive app should block pointer input") + } + + let pointerStatus = PointerInputStatus( + windowIsFocused: true, + appIsActive: false, + sceneIsForegroundActive: true + ) + if pointerStatus.isActive { + fatalError("inactive app status should not be active") + } + if GCMousePointerMapper.shouldSendRelativeMove(pointerHoverSupported: true) { + fatalError("gcmouse move should be blocked when absolute hover is supported") + } + if !GCMousePointerMapper.shouldSendRelativeMove(pointerHoverSupported: false) { + fatalError("gcmouse move should be allowed without absolute hover support") + } + if GCMousePointerMapper.shouldSendButton(pointerButtonTouchSupported: true) { + fatalError("gcmouse buttons should be blocked when UIKit pointer buttons are supported") + } + if !GCMousePointerMapper.shouldSendButton(pointerButtonTouchSupported: false) { + fatalError("gcmouse buttons should be allowed without UIKit pointer button support") + } + + print("pointer mapping ok") + } +} diff --git a/scripts/check_scroll_mapping.swift b/scripts/check_scroll_mapping.swift new file mode 100644 index 00000000..a01badb7 --- /dev/null +++ b/scripts/check_scroll_mapping.swift @@ -0,0 +1,69 @@ +import Foundation + +@main +struct CheckScrollMapping { + static func expect(_ actual: Int32, _ expected: Int32, _ label: String) { + if actual != expected { + fatalError("\(label): expected \(expected), got \(actual)") + } + } + + static func expect(_ actual: Bool, _ expected: Bool, _ label: String) { + if actual != expected { + fatalError("\(label): expected \(expected), got \(actual)") + } + } + + static func expectNear(_ actual: Float, _ expected: Float, _ label: String, tolerance: Float = 0.001) { + if abs(actual - expected) > tolerance { + fatalError("\(label): expected \(expected), got \(actual)") + } + } + + static func main() { + expect(ScrollWheelMapper.wheelUnits(rawDelta: 40, sensitivity: 1, naturalScrolling: true), -2, "natural downward") + expect(ScrollWheelMapper.wheelUnits(rawDelta: -40, sensitivity: 1, naturalScrolling: true), 2, "natural upward") + expect(ScrollWheelMapper.wheelUnits(rawDelta: 40, sensitivity: 1, naturalScrolling: false), 2, "inverted downward") + expect(ScrollWheelMapper.wheelUnits(rawDelta: 40, sensitivity: 2, naturalScrolling: true), -4, "sensitivity") + let naturalY = GCMouseScrollMapper.adjustedDelta(axis: .y, rawValue: -1.19, naturalScrolling: true) + expectNear(naturalY.x, 1.19, "gcmouse y natural x") + expectNear(naturalY.y, 0, "gcmouse y natural y") + expectNear(GCMouseScrollMapper.adjustedDelta(axis: .y, rawValue: -1.19, naturalScrolling: false).x, -1.19, "gcmouse y inverted x") + let naturalX = GCMouseScrollMapper.adjustedDelta(axis: .x, rawValue: 1.19, naturalScrolling: true) + expectNear(naturalX.x, 0, "gcmouse x natural x") + expectNear(naturalX.y, -1.19, "gcmouse x natural y") + expectNear(GCMouseScrollMapper.adjustedDelta(axis: .x, rawValue: 1.19, naturalScrolling: false).y, 1.19, "gcmouse x inverted y") + + var motion = GCMouseScrollMotion() + var accumulatedX: Int32 = 0 + for index in 0..<8 { + let wheel = motion.consume(axis: .y, rawValue: -0.25, naturalScrolling: true, at: Double(index) * 0.02) + accumulatedX += wheel.x + } + expect(accumulatedX, 2, "gcmouse fractional deltas accumulate") + expectNear(motion.velocityX, 12.47952, "gcmouse velocity smoothing") + expect(motion.shouldStartMomentum(minimumSpeed: 10), true, "gcmouse momentum starts") + var momentumX: Int32 = 0 + for _ in 0..<30 { + momentumX += motion.momentumWheel(deltaTime: 1.0 / 60.0, decayPerSecond: 0.004).x + } + expect(momentumX > 0, true, "gcmouse momentum emits decaying wheel units") + expect(motion.shouldStopMomentum(maximumSpeed: 2), true, "gcmouse momentum stops after decay") + + motion.reset() + for index in 0..<3 { + _ = motion.consume(axis: .y, rawValue: -0.25, naturalScrolling: true, at: Double(index) * 0.02) + } + motion.reset() + let afterReset = motion.consume(axis: .y, rawValue: -0.25, naturalScrolling: true, at: 1.0) + expect(afterReset.x, 0, "gcmouse reset clears fractional remainder") + + ScrollInputGate.reset() + expect(ScrollInputGate.shouldSendTrackpadScroll(at: 1.0), true, "trackpad initially allowed") + ScrollInputGate.recordGCMouseScroll(at: 1.0) + expect(ScrollInputGate.shouldSendTrackpadScroll(at: 1.05), false, "trackpad suppressed after gcmouse") + expect(ScrollInputGate.shouldSendTrackpadScroll(at: 1.30), true, "trackpad allowed after suppression") + + print("scroll mapping ok") + } +} diff --git a/scripts/check_zoom_viewport.swift b/scripts/check_zoom_viewport.swift new file mode 100644 index 00000000..295bcacb --- /dev/null +++ b/scripts/check_zoom_viewport.swift @@ -0,0 +1,55 @@ +import CoreGraphics + +@main +struct CheckZoomViewport { + static func expect(_ actual: Bool, _ expected: Bool, _ label: String) { + if actual != expected { + fatalError("\(label): expected \(expected), got \(actual)") + } + } + + static func main() { + expect( + ZoomViewportPolicy.shouldConstrain(zoomEnabled: true, zoomScale: 1.0, minimumZoomScale: 1.0), + false, + "original zoom is unconstrained" + ) + expect( + ZoomViewportPolicy.shouldConstrain(zoomEnabled: true, zoomScale: 1.005, minimumZoomScale: 1.0), + false, + "near-original zoom is unconstrained" + ) + expect( + ZoomViewportPolicy.shouldConstrain(zoomEnabled: false, zoomScale: 2.0, minimumZoomScale: 1.0), + false, + "disabled pinch zoom is unconstrained" + ) + expect( + ZoomViewportPolicy.shouldConstrain(zoomEnabled: true, zoomScale: 1.5, minimumZoomScale: 1.0), + true, + "active zoom remains constrained" + ) + expect( + ZoomViewportPolicy.shouldRepositionForCursor( + zoomEnabled: true, + zoomScale: 1.5, + minimumZoomScale: 1.0, + directMouseMode: true + ), + false, + "direct mouse mode preserves the viewport" + ) + expect( + ZoomViewportPolicy.shouldRepositionForCursor( + zoomEnabled: true, + zoomScale: 1.5, + minimumZoomScale: 1.0, + directMouseMode: false + ), + true, + "touchpad mode follows the cursor" + ) + + print("zoom viewport policy ok") + } +}