@@ -19,8 +19,9 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI
1919 var lastMouseX : Int32 = - 1
2020 var lastMouseY : Int32 = - 1
2121 var lastCursorHidden : Bool = false
22- var isPinching = false
2322 var zoomEnabled = false
23+ var appliedRenderScale : CGFloat = 0 // last contentScaleFactor applied (avoids redundant reallocations)
24+ let maxRenderScale : CGFloat = 6.0 // cap on drawable density
2425 var clickHoldActive = false // left button held via long-press (file drag)
2526 // Flick-to-glide momentum (tuned constants, not user settings).
2627 var momentumLink : CADisplayLink ?
@@ -40,6 +41,8 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI
4041 var cursorDidMoveThisTouch = false
4142 var singleTouchStartScreen : CGPoint = . zero
4243 let tapMoveSlop : CGFloat = 4.0 // finger movement (pts) above which a touch is a drag, not a tap
44+ let staleTouchThreshold : TimeInterval = 1.5 // a touch idle this long vs an actively-moving one is a ghost
45+ let staleMoveSlop : CGFloat = 24.0 // the moving finger must travel this far before a ghost is swept
4346 var twoFingerDidMove = false // true once a 2-finger gesture became a pinch/scroll (suppresses right-click)
4447
4548 var mouseSensitivity : Float = Float ( SettingsHandler . mouseSensitivity)
@@ -54,6 +57,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI
5457 var isDragging = false
5558 var twoFingerResidual = false // leftover finger after a 2-finger gesture; inert until full lift
5659 var activeTouches : [ UITouch ] = [ ] // ordered; first two drive pinch / scroll
60+ var touchOrigins : [ UITouch : CGPoint ] = [ : ] // begin location per touch, for ghost-sweep movement test
5761
5862 enum TwoFingerMode { case undecided, zoom, scroll }
5963 var twoFingerMode : TwoFingerMode = . undecided
@@ -90,7 +94,31 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI
9094 fatalError ( " init(coder:) has not been implemented " )
9195 }
9296
97+ func computeRenderScale( ) -> CGFloat {
98+ let nativeScale = UIScreen . main. nativeScale
99+ guard scrollView. zoomScale > 1.01 , let glk = glkView as? ParsecGLKViewController else { return nativeScale }
100+ let vw = glk. glkView. frame. size. width
101+ let vh = glk. glkView. frame. size. height
102+ let sw = CGFloat ( CParsec . hostWidth)
103+ let sh = CGFloat ( CParsec . hostHeight)
104+ guard vw > 0 , vh > 0 , sw > 0 , sh > 0 else { return nativeScale }
105+ // Match the drawable to the source so zoom stays crisp without rendering wasted pixels.
106+ let needed = max ( sw / vw, sh / vh)
107+ return min ( maxRenderScale, max ( nativeScale, needed) )
108+ }
109+
110+ func updateRenderScale( ) {
111+ let target = computeRenderScale ( )
112+ if abs ( target - appliedRenderScale) > 0.01 {
113+ appliedRenderScale = target
114+ DispatchQueue . main. async { [ weak self] in
115+ ( self ? . glkView as? ParsecGLKViewController ) ? . glkView. contentScaleFactor = target
116+ }
117+ }
118+ }
119+
93120 func updateImage( ) {
121+ updateRenderScale ( )
94122 // Optimization: Snap current valus
95123 let currentMouseX = CParsec . mouseInfo. mouseX
96124 let currentMouseY = CParsec . mouseInfo. mouseY
@@ -212,27 +240,30 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI
212240 // All gestures (cursor, pinch-zoom, two-finger scroll) are handled in the
213241 // ParsecTouchInputDelegate methods below, driven by touchOverlay's raw touches.
214242
215- // Remove custom Pinch logic, ScrollView handles it.
216- // But we might want to know isPinching status?
217- // Let's rely on ScrollView delegate for updates.
218-
219243 // Add tap gesture recognizer for single-finger touch
220244 let singleFingerTapGestureRecognizer = UITapGestureRecognizer ( target: self , action: #selector( handleSingleFingerTap ( _: ) ) )
221245 singleFingerTapGestureRecognizer. numberOfTouchesRequired = 1
222246 singleFingerTapGestureRecognizer. allowedTouchTypes = [ 0 , 2 ]
247+ // Let the overlay see the real touch end so no phantom finger lingers.
248+ singleFingerTapGestureRecognizer. cancelsTouchesInView = false
249+ singleFingerTapGestureRecognizer. delaysTouchesEnded = false
223250 view. addGestureRecognizer ( singleFingerTapGestureRecognizer)
224251
225252 // Add tap gesture recognizer for two-finger touch
226253 let twoFingerTapGestureRecognizer = UITapGestureRecognizer ( target: self , action: #selector( handleTwoFingerTap ( _: ) ) )
227254 twoFingerTapGestureRecognizer. numberOfTouchesRequired = 2
228255 twoFingerTapGestureRecognizer. allowedTouchTypes = [ 0 ]
256+ twoFingerTapGestureRecognizer. cancelsTouchesInView = false
257+ twoFingerTapGestureRecognizer. delaysTouchesEnded = false
229258 view. addGestureRecognizer ( twoFingerTapGestureRecognizer)
230259 // view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
231260 // view.backgroundColor = UIColor(red: 0x66, green: 0xcc, blue: 0xff, alpha: 1.0)
232261
233262 let threeFingerTapGestureRecognizer = UITapGestureRecognizer ( target: self , action: #selector( handleThreeFinderTap ( _: ) ) )
234263 threeFingerTapGestureRecognizer. numberOfTouchesRequired = 3
235264 threeFingerTapGestureRecognizer. allowedTouchTypes = [ 0 ]
265+ threeFingerTapGestureRecognizer. cancelsTouchesInView = false
266+ threeFingerTapGestureRecognizer. delaysTouchesEnded = false
236267 view. addGestureRecognizer ( threeFingerTapGestureRecognizer)
237268
238269 let longPressGestureRecognizer = UILongPressGestureRecognizer ( target: self , action: #selector( handleLongPress ( _: ) ) )
@@ -256,6 +287,14 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI
256287 object: nil
257288 )
258289
290+ // Backgrounding / Control Center / system alerts can orphan touches; flush on resign.
291+ NotificationCenter . default. addObserver (
292+ self ,
293+ selector: #selector( appWillResignActive) ,
294+ name: UIApplication . willResignActiveNotification,
295+ object: nil
296+ )
297+
259298 }
260299
261300 override func viewWillTransition( to size: CGSize , with coordinator: UIViewControllerTransitionCoordinator ) {
@@ -300,6 +339,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate, ParsecTouchI
300339 }
301340 NotificationCenter . default. removeObserver ( self , name: UIResponder . keyboardWillShowNotification, object: nil )
302341 NotificationCenter . default. removeObserver ( self , name: UIResponder . keyboardWillHideNotification, object: nil )
342+ NotificationCenter . default. removeObserver ( self , name: UIApplication . willResignActiveNotification, object: nil )
303343 }
304344
305345
@@ -465,9 +505,31 @@ extension ParsecViewController: UIGestureRecognizerDelegate {
465505 let oldCount = activeTouches. count
466506 // Rebuild from the event each time (keeping order) so a cancelled touch can't leave a stale one.
467507 var ordered = activeTouches. filter { touches. contains ( $0) }
468- for t in touches where !ordered. contains ( t) { ordered. append ( t) }
508+ for t in touches where !ordered. contains ( t) {
509+ ordered. append ( t)
510+ touchOrigins [ t] = t. location ( in: view)
511+ }
512+ // Sweep a ghost (an OS-dropped lift lingering as .stationary) only while another finger actively
513+ // moves - a resting or just-tapped finger is never swept, so taps and held fingers stay safe.
514+ var sweptGhost = false
515+ if ordered. count >= 2 && !clickHoldActive,
516+ let newest = ordered. max ( by: { $0. timestamp < $1. timestamp } ) {
517+ let origin = touchOrigins [ newest] ?? newest. location ( in: view)
518+ let here = newest. location ( in: view)
519+ if hypot ( here. x - origin. x, here. y - origin. y) > staleMoveSlop {
520+ let fresh = ordered. filter { newest. timestamp - $0. timestamp < staleTouchThreshold }
521+ if fresh. count < ordered. count { ordered = fresh; sweptGhost = true }
522+ }
523+ }
469524 activeTouches = ordered
470- if activeTouches. count != oldCount {
525+ touchOrigins = touchOrigins. filter { activeTouches. contains ( $0. key) }
526+ if sweptGhost && activeTouches. count == 1 {
527+ // Recover the surviving finger as a live cursor instead of inert two-finger residual.
528+ stopMomentum ( )
529+ twoFingerResidual = false
530+ twoFingerMode = . undecided
531+ startCursor ( )
532+ } else if activeTouches. count != oldCount {
471533 handleTouchCountChange ( old: oldCount, new: activeTouches. count)
472534 }
473535 if moved { handleActiveTouchesMoved ( ) }
@@ -509,6 +571,19 @@ extension ParsecViewController: UIGestureRecognizerDelegate {
509571 }
510572 }
511573
574+ @objc private func appWillResignActive( ) { flushAllTouches ( ) }
575+
576+ // Drop all touch state and release any held button so nothing sticks when backgrounded.
577+ private func flushAllTouches( ) {
578+ stopMomentum ( )
579+ releaseClickHoldIfNeeded ( )
580+ endCursorIfNeeded ( )
581+ isDragging = false
582+ twoFingerResidual = false
583+ twoFingerMode = . undecided
584+ activeTouches = [ ]
585+ }
586+
512587 private func startCursor( ) {
513588 isDragging = true
514589 twoFingerResidual = false
@@ -815,14 +890,7 @@ extension ParsecViewController: UIGestureRecognizerDelegate {
815890 // Centering is handled on cursor movement (updateImage) and at the end of a pinch.
816891 }
817892
818- func scrollViewWillBeginZooming( _ scrollView: UIScrollView , with view: UIView ? ) {
819- // A pinch-to-zoom started: pause two-finger host scrolling until it ends so the
820- // same two-finger gesture isn't interpreted as both a zoom and a scroll.
821- isPinching = true
822- }
823-
824893 func scrollViewDidEndZooming( _ scrollView: UIScrollView , with view: UIView ? , atScale scale: CGFloat ) {
825- isPinching = false
826894 centerViewportOnCursorPos ( )
827895 }
828896
0 commit comments