Mission Control Turbo: macOS multitasking turbocharged
1import AppKit
2import Combine
3import SwiftUI
4
5/// Manages the full-screen overlay NSPanel for each display with
6/// interactive gesture-driven transitions.
7@MainActor
8final class OverlayWindowController {
9 // MARK: - State
10
11 private enum State {
12 case idle
13 case tracking // gesture active, panels visible, driving progress
14 case committing // spring animating to 1.0
15 case shown // fully visible and interactive
16 case dismissTracking // gesture active while shown, driving progress down
17 case dismissing // spring animating to 0.0 (committed dismiss)
18 case cancelling // spring animating back (cancel gesture)
19 }
20
21 private var state: State = .idle
22 private var panels: [NSPanel] = []
23 private var layoutEngines: [ScreenKey: LayoutEngine] = [:]
24 private let gestureProgress: GestureProgress
25 private var springAnimator: SpringAnimator?
26 private var captureGeneration: Int = 0
27
28 /// Cached thumbnails from previous activation, keyed by window ID.
29 private var thumbnailCache: [CGWindowID: CGImage] = [:]
30
31 /// Floating window that follows the cursor during cross-monitor drag.
32 private var dragFloatingWindow: NSWindow?
33 private var dragObservation: AnyCancellable?
34
35 var onWindowSelected: ((WindowInfo) -> Void)?
36 var onDismiss: (() -> Void)?
37
38 // Normalization: 10% of trackpad range = full reveal
39 private let normalizationDivisor: Float = 0.10
40
41 // Commit thresholds
42 private let commitProgressThreshold: CGFloat = 0.3
43 private let commitVelocityThreshold: Float = 0.2
44
45 var isVisible: Bool {
46 switch state {
47 case .idle: return false
48 default: return true
49 }
50 }
51
52 init(gestureProgress: GestureProgress) {
53 self.gestureProgress = gestureProgress
54 startObservingDrag()
55 }
56
57 // MARK: - Gesture callbacks
58
59 func gestureDidBegin() {
60 mctLog("[MCT] gestureDidBegin state=%d", state == .idle ? 0 : state == .shown ? 1 : 2)
61 switch state {
62 case .idle, .cancelling, .dismissing:
63 // Interrupt any running animation and start fresh
64 springAnimator?.stop()
65 springAnimator = nil
66
67 // If panels exist from a cancelling/dismissing animation, remove them
68 if state != .idle {
69 removePanels()
70 gestureProgress.thumbnailsReady = false
71 gestureProgress.layouts.removeAll()
72 }
73
74 state = .tracking
75 captureGeneration += 1
76 let gen = captureGeneration
77
78 createPanels(interactive: false)
79 setProgress(0.0, phase: .tracking)
80
81 // Phase 1: enumerate + apply cached thumbnails immediately
82 var windows = WindowEnumerator.enumerateWindows()
83 applyCachedThumbnails(to: &windows)
84 applyThumbnails(windows: windows)
85
86 // Phase 2: capture fresh thumbnails in background
87 Task {
88 var freshWindows = windows
89 await ThumbnailCapture.captureThumbnails(for: &freshWindows)
90 let captured = freshWindows
91 await MainActor.run {
92 guard gen == self.captureGeneration else { return }
93 self.updateThumbnailCache(from: captured)
94 self.applyThumbnails(windows: captured)
95 }
96 }
97
98 case .shown, .committing:
99 // Begin dismiss tracking (interrupt commit animation if mid-spring)
100 springAnimator?.stop()
101 springAnimator = nil
102 state = .dismissTracking
103 setInteractive(false)
104 setProgress(gestureProgress.progress, phase: .dismissTracking)
105
106 case .tracking, .dismissTracking:
107 // Already tracking — ignore duplicate began
108 break
109 }
110 }
111
112 func gestureDidChange(rawDY: Float, rawVelocity: Float) {
113 switch state {
114 case .tracking:
115 let normalized = CGFloat(clamp(rawDY / normalizationDivisor, min: 0, max: 1))
116 setProgress(normalized, phase: .tracking)
117
118 case .dismissTracking:
119 // Negative DY (swipe down) drives dismiss; positive re-opens
120 let normalized = CGFloat(clamp(-rawDY / normalizationDivisor, min: 0, max: 1))
121 let progress = 1.0 - normalized
122 setProgress(progress, phase: .dismissTracking)
123
124 default:
125 break
126 }
127 }
128
129 func gestureDidEnd(rawDY: Float, rawVelocity: Float) {
130 switch state {
131 case .tracking:
132 let normalized = CGFloat(clamp(rawDY / normalizationDivisor, min: 0, max: 1))
133 let shouldCommit = normalized > commitProgressThreshold || rawVelocity > commitVelocityThreshold
134 if shouldCommit {
135 animateToCommit(from: normalized, velocity: CGFloat(rawVelocity))
136 } else {
137 animateToCancel(from: normalized, velocity: CGFloat(rawVelocity))
138 }
139
140 case .dismissTracking:
141 let dismissNormalized = CGFloat(clamp(-rawDY / normalizationDivisor, min: 0, max: 1))
142 let currentProgress = 1.0 - dismissNormalized
143 let shouldDismiss = dismissNormalized > commitProgressThreshold || (-rawVelocity) > commitVelocityThreshold
144 if shouldDismiss {
145 animateToDismiss(from: currentProgress, velocity: CGFloat(rawVelocity))
146 } else {
147 animateToCancelDismiss(from: currentProgress, velocity: CGFloat(rawVelocity))
148 }
149
150 default:
151 break
152 }
153 }
154
155 func gestureDidCancel() {
156 switch state {
157 case .tracking:
158 let current = gestureProgress.progress
159 animateToCancel(from: current, velocity: 0)
160
161 case .dismissTracking:
162 let current = gestureProgress.progress
163 animateToCancelDismiss(from: current, velocity: 0)
164
165 default:
166 break
167 }
168 }
169
170 // MARK: - Hotkey toggle
171
172 func toggle() {
173 mctLog("[MCT] toggle called, state=%d", state == .idle ? 0 : 1)
174 switch state {
175 case .idle:
176 // Hotkey show: create panels, animate to fully shown
177 state = .committing
178 springAnimator?.stop()
179 springAnimator = nil
180 captureGeneration += 1
181 let gen = captureGeneration
182
183 createPanels(interactive: true)
184 setProgress(0.0, phase: .committing)
185
186 NSApp.activate(ignoringOtherApps: true)
187 if let firstPanel = panels.first {
188 firstPanel.makeKey()
189 }
190
191 // Phase 1: enumerate + apply cached thumbnails immediately
192 let t0 = CFAbsoluteTimeGetCurrent()
193 var windows = WindowEnumerator.enumerateWindows()
194 applyCachedThumbnails(to: &windows)
195 applyThumbnails(windows: windows)
196 let t1 = CFAbsoluteTimeGetCurrent()
197 mctLog("[MCT] phase1 (cached): %.0f ms (%d windows)", (t1 - t0) * 1000, windows.count)
198
199 // Phase 2: capture fresh thumbnails in background and swap in
200 Task {
201 var freshWindows = windows
202 await ThumbnailCapture.captureThumbnails(for: &freshWindows)
203 let t2 = CFAbsoluteTimeGetCurrent()
204 mctLog("[MCT] phase2 (capture): %.0f ms", (t2 - t1) * 1000)
205
206 let captured = freshWindows
207 await MainActor.run {
208 guard gen == self.captureGeneration else { return }
209 self.updateThumbnailCache(from: captured)
210 self.applyThumbnails(windows: captured)
211 }
212 }
213
214 animateToCommit(from: 0, velocity: 0, stiffness: 800.0)
215
216 case .shown:
217 animateToDismiss(from: 1.0, velocity: 0, stiffness: 800.0)
218
219 case .committing:
220 // Reverse: dismiss from current progress
221 let current = gestureProgress.progress
222 animateToDismiss(from: current, velocity: 0, stiffness: 800.0)
223
224 case .dismissing, .cancelling:
225 // Reverse: reopen from current progress
226 let current = gestureProgress.progress
227 setInteractive(true)
228 NSApp.activate(ignoringOtherApps: true)
229 if let firstPanel = panels.first {
230 firstPanel.makeKey()
231 }
232 animateToCommit(from: current, velocity: 0, stiffness: 800.0)
233
234 default:
235 let current = gestureProgress.progress
236 animateToDismiss(from: current, velocity: 0, stiffness: 800.0)
237 }
238 }
239
240 /// Dismiss from external trigger (Escape key, background tap).
241 func dismiss() {
242 switch state {
243 case .shown:
244 animateToDismiss(from: 1.0, velocity: 0, stiffness: 800.0)
245 case .idle:
246 break
247 default:
248 let current = gestureProgress.progress
249 animateToDismiss(from: current, velocity: 0, stiffness: 800.0)
250 }
251 }
252
253 // MARK: - Spring animations
254
255 private func animateToCommit(from current: CGFloat, velocity: CGFloat, stiffness: CGFloat = 300.0) {
256 state = .committing
257 gestureProgress.phase = .committing
258 springAnimator?.stop()
259 springAnimator = SpringAnimator(
260 from: current,
261 to: 1.0,
262 initialVelocity: velocity,
263 stiffness: stiffness,
264 onUpdate: { [weak self] value in
265 self?.gestureProgress.progress = value
266 },
267 onComplete: { [weak self] in
268 self?.commitComplete()
269 }
270 )
271 springAnimator?.start()
272 }
273
274 private func animateToCancel(from current: CGFloat, velocity: CGFloat) {
275 state = .cancelling
276 gestureProgress.phase = .cancelling
277 springAnimator?.stop()
278 springAnimator = SpringAnimator(
279 from: current,
280 to: 0.0,
281 initialVelocity: velocity,
282 onUpdate: { [weak self] value in
283 self?.gestureProgress.progress = value
284 },
285 onComplete: { [weak self] in
286 self?.cancelComplete()
287 }
288 )
289 springAnimator?.start()
290 }
291
292 private func animateToDismiss(from current: CGFloat, velocity: CGFloat, stiffness: CGFloat = 300.0) {
293 state = .dismissing
294 gestureProgress.phase = .dismissing
295 springAnimator?.stop()
296 springAnimator = SpringAnimator(
297 from: current,
298 to: 0.0,
299 initialVelocity: velocity,
300 stiffness: stiffness,
301 onUpdate: { [weak self] value in
302 self?.gestureProgress.progress = value
303 },
304 onComplete: { [weak self] in
305 self?.dismissComplete()
306 }
307 )
308 springAnimator?.start()
309 }
310
311 private func animateToCancelDismiss(from current: CGFloat, velocity: CGFloat) {
312 // Animate back to fully shown
313 state = .committing
314 gestureProgress.phase = .committing
315 springAnimator?.stop()
316 springAnimator = SpringAnimator(
317 from: current,
318 to: 1.0,
319 initialVelocity: velocity,
320 onUpdate: { [weak self] value in
321 self?.gestureProgress.progress = value
322 },
323 onComplete: { [weak self] in
324 self?.commitComplete()
325 }
326 )
327 springAnimator?.start()
328 }
329
330 // MARK: - Completion handlers
331
332 private func commitComplete() {
333 state = .shown
334 gestureProgress.progress = 1.0
335 gestureProgress.phase = .committed
336 setInteractive(true)
337
338 NSApp.activate(ignoringOtherApps: true)
339 if let firstPanel = panels.first {
340 firstPanel.makeKey()
341 }
342 }
343
344 private func cancelComplete() {
345 state = .idle
346 gestureProgress.progress = 0.0
347 gestureProgress.phase = .idle
348 gestureProgress.thumbnailsReady = false
349 gestureProgress.layouts.removeAll()
350 gestureProgress.dragState = nil
351 layoutEngines.values.forEach { $0.invalidate() }
352 layoutEngines.removeAll()
353 removeDragFloatingWindow()
354 removePanels()
355 }
356
357 private func dismissComplete() {
358 state = .idle
359 gestureProgress.progress = 0.0
360 gestureProgress.phase = .idle
361 gestureProgress.thumbnailsReady = false
362 gestureProgress.layouts.removeAll()
363 gestureProgress.dragState = nil
364 layoutEngines.values.forEach { $0.invalidate() }
365 layoutEngines.removeAll()
366 removeDragFloatingWindow()
367 onDismiss?()
368 removePanels()
369 }
370
371 // MARK: - Panel lifecycle
372
373 private func createPanels(interactive: Bool) {
374 removePanels()
375
376 for screen in NSScreen.screens {
377 let panel = createPanel(for: screen)
378 panel.ignoresMouseEvents = !interactive
379 panel.alphaValue = 1.0
380
381 let overlayView = OverlayView(
382 gestureProgress: gestureProgress,
383 screenBounds: screen.frame,
384 onSelect: { [weak self] windowInfo in
385 self?.selectWindow(windowInfo)
386 },
387 onDismiss: { [weak self] in
388 self?.dismiss()
389 },
390 onDrop: { [weak self] point in
391 self?.handleDrop(at: point)
392 }
393 )
394
395 panel.contentView = NSHostingView(rootView: overlayView)
396 panel.makeKeyAndOrderFront(nil)
397 panel.orderFrontRegardless()
398
399 panels.append(panel)
400 }
401 }
402
403 private func removePanels() {
404 for panel in panels {
405 panel.orderOut(nil)
406 }
407 panels.removeAll()
408 }
409
410 private func setInteractive(_ interactive: Bool) {
411 for panel in panels {
412 panel.ignoresMouseEvents = !interactive
413 }
414 }
415
416 private func createPanel(for screen: NSScreen) -> NSPanel {
417 let panel = OverlayPanel(
418 contentRect: screen.frame,
419 styleMask: [.borderless, .nonactivatingPanel],
420 backing: .buffered,
421 defer: false
422 )
423 panel.isFloatingPanel = true
424 panel.level = .screenSaver
425 panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
426 panel.isOpaque = false
427 panel.backgroundColor = .clear
428 panel.hasShadow = false
429 panel.hidesOnDeactivate = false
430 panel.acceptsMouseMovedEvents = true
431 panel.ignoresMouseEvents = false
432 return panel
433 }
434
435 // MARK: - Cross-monitor drag
436
437 func startObservingDrag() {
438 dragObservation = gestureProgress.$dragState
439 .receive(on: RunLoop.main)
440 .sink { [weak self] state in
441 guard let self else { return }
442 if let state = state {
443 if self.dragFloatingWindow == nil {
444 self.createDragFloatingWindow(for: state)
445 }
446 self.updateDragFloatingWindow(position: state.currentScreenPosition)
447 } else {
448 self.removeDragFloatingWindow()
449 }
450 }
451 }
452
453 private func createDragFloatingWindow(for state: DragState) {
454 let windowInfo = state.windowInfo
455
456 // Size: 200px wide, aspect-preserved
457 let aspect = windowInfo.frame.height / max(windowInfo.frame.width, 1)
458 let thumbWidth: CGFloat = 200
459 let thumbHeight = thumbWidth * aspect
460
461 let window = NSWindow(
462 contentRect: NSRect(x: 0, y: 0, width: thumbWidth, height: thumbHeight),
463 styleMask: [.borderless],
464 backing: .buffered,
465 defer: false
466 )
467 window.level = NSWindow.Level(Int(CGWindowLevelForKey(.screenSaverWindow)) + 1)
468 window.isOpaque = false
469 window.backgroundColor = .clear
470 window.hasShadow = true
471 window.ignoresMouseEvents = true
472 window.collectionBehavior = [.canJoinAllSpaces]
473
474 if let thumb = windowInfo.thumbnail {
475 let nsImage = NSImage(cgImage: thumb, size: NSSize(width: thumbWidth, height: thumbHeight))
476 let imageView = NSImageView(frame: NSRect(x: 0, y: 0, width: thumbWidth, height: thumbHeight))
477 imageView.image = nsImage
478 imageView.imageScaling = .scaleProportionallyUpOrDown
479 imageView.wantsLayer = true
480 imageView.layer?.cornerRadius = 8
481 imageView.layer?.masksToBounds = true
482 imageView.layer?.opacity = 0.85
483 window.contentView = imageView
484 }
485
486 window.alphaValue = 0.9
487 window.orderFrontRegardless()
488 dragFloatingWindow = window
489 }
490
491 private func updateDragFloatingWindow(position: CGPoint) {
492 guard let window = dragFloatingWindow else { return }
493 let size = window.frame.size
494 // Center the floating window on the cursor
495 window.setFrameOrigin(NSPoint(x: position.x - size.width / 2,
496 y: position.y - size.height / 2))
497 }
498
499 private func removeDragFloatingWindow() {
500 dragFloatingWindow?.orderOut(nil)
501 dragFloatingWindow = nil
502 }
503
504 private func handleDrop(at point: CGPoint) {
505 guard let dragState = gestureProgress.dragState else { return }
506 let windowInfo = dragState.windowInfo
507
508 // Find which screen the cursor landed on
509 let targetScreen = NSScreen.screens.first(where: { $0.frame.contains(point) })
510 ?? NSScreen.screens.first!
511 let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
512
513 // Center the window on the target screen (Quartz coords: top-left origin)
514 let targetQuartz = quartzFrame(for: targetScreen, primaryHeight: primaryHeight)
515 let winW = windowInfo.frame.width
516 let winH = windowInfo.frame.height
517 let dropPoint = CGPoint(
518 x: targetQuartz.midX - winW / 2,
519 y: targetQuartz.midY - winH / 2
520 )
521
522 // Move the window
523 _ = AccessibilityBridge.moveWindow(pid: windowInfo.pid, windowID: windowInfo.windowID, to: dropPoint)
524
525 // Clear drag state
526 gestureProgress.dragState = nil
527 removeDragFloatingWindow()
528
529 // Invalidate layout engines so both source and target screens recompute fresh grids
530 let sourceKey = dragState.sourceScreenKey
531 let targetKey = ScreenKey(targetScreen.frame)
532 layoutEngines[sourceKey]?.invalidate()
533 layoutEngines[targetKey]?.invalidate()
534
535 // Re-enumerate and patch the moved window's frame immediately,
536 // since CGWindowList may not yet reflect the AX position change.
537 captureGeneration += 1
538 let gen = captureGeneration
539 var windows = WindowEnumerator.enumerateWindows()
540 applyCachedThumbnails(to: &windows)
541 let newFrame = CGRect(origin: dropPoint, size: windowInfo.frame.size)
542 for i in windows.indices where windows[i].windowID == windowInfo.windowID {
543 windows[i] = WindowInfo(
544 windowID: windows[i].windowID,
545 title: windows[i].title,
546 appName: windows[i].appName,
547 appBundleID: windows[i].appBundleID,
548 appIcon: windows[i].appIcon,
549 frame: newFrame,
550 pid: windows[i].pid,
551 isOnScreen: windows[i].isOnScreen,
552 thumbnail: windows[i].thumbnail
553 )
554 }
555 applyThumbnails(windows: windows)
556
557 // Re-order panels to front so they aren't hidden behind each other
558 for panel in panels {
559 panel.orderFrontRegardless()
560 }
561
562 // Background: re-enumerate with fresh system data + thumbnails
563 Task {
564 try? await Task.sleep(nanoseconds: 200_000_000) // 200ms for AX move to propagate
565 guard gen == self.captureGeneration else { return }
566 var freshWindows = WindowEnumerator.enumerateWindows()
567 self.applyCachedThumbnails(to: &freshWindows)
568 await ThumbnailCapture.captureThumbnails(for: &freshWindows)
569 let captured = freshWindows
570 await MainActor.run {
571 guard gen == self.captureGeneration else { return }
572 self.updateThumbnailCache(from: captured)
573 self.applyThumbnails(windows: captured)
574 }
575 }
576 }
577
578 // MARK: - Helpers
579
580 private func setProgress(_ value: CGFloat, phase: GesturePhase) {
581 gestureProgress.progress = value
582 gestureProgress.phase = phase
583 }
584
585 private func applyThumbnails(windows: [WindowInfo]) {
586 let screens = NSScreen.screens
587 let primaryHeight = screens.first?.frame.height ?? 0
588
589 var layouts: [ScreenKey: LayoutResult] = [:]
590 for screen in screens {
591 let key = ScreenKey(screen.frame)
592
593 // Convert screen frame from AppKit coords (bottom-left origin) to
594 // Quartz coords (top-left origin) to match WindowInfo.frame
595 let qFrame = quartzFrame(for: screen, primaryHeight: primaryHeight)
596
597 // Only include windows whose center falls on this screen
598 let screenWindows = windows.filter { window in
599 let center = CGPoint(x: window.frame.midX, y: window.frame.midY)
600 return qFrame.contains(center)
601 }
602
603 let groups = WindowEnumerator.groupWindows(screenWindows)
604 let snapshot = WindowSnapshot(groups: groups)
605
606 if layoutEngines[key] == nil {
607 layoutEngines[key] = LayoutEngine()
608 }
609 let layout = layoutEngines[key]!.layout(snapshot: snapshot, screenBounds: screen.frame)
610 layouts[key] = layout
611 }
612
613 gestureProgress.layouts = layouts
614 gestureProgress.thumbnailsReady = true
615 gestureProgress.thumbnailGeneration += 1
616 }
617
618 /// Apply cached thumbnails to windows that have a cache hit.
619 private func applyCachedThumbnails(to windows: inout [WindowInfo]) {
620 for i in windows.indices {
621 if let cached = thumbnailCache[windows[i].windowID] {
622 windows[i].thumbnail = cached
623 }
624 }
625 }
626
627 /// Update the thumbnail cache from freshly captured windows.
628 private func updateThumbnailCache(from windows: [WindowInfo]) {
629 for window in windows {
630 if let thumb = window.thumbnail {
631 thumbnailCache[window.windowID] = thumb
632 }
633 }
634 // Prune stale entries for windows that no longer exist
635 let currentIDs = Set(windows.map { $0.windowID })
636 for key in thumbnailCache.keys where !currentIDs.contains(key) {
637 thumbnailCache.removeValue(forKey: key)
638 }
639 }
640
641 /// Convert an NSScreen frame (AppKit bottom-left origin) to Quartz display
642 /// coordinates (top-left origin) so we can compare with WindowInfo.frame.
643 private func quartzFrame(for screen: NSScreen, primaryHeight: CGFloat) -> CGRect {
644 let f = screen.frame
645 return CGRect(x: f.origin.x,
646 y: primaryHeight - f.origin.y - f.height,
647 width: f.width,
648 height: f.height)
649 }
650
651 private func selectWindow(_ windowInfo: WindowInfo) {
652 dismiss()
653 onWindowSelected?(windowInfo)
654 _ = AccessibilityBridge.activateWindow(pid: windowInfo.pid, windowID: windowInfo.windowID)
655 }
656
657 private func clamp(_ value: Float, min minVal: Float, max maxVal: Float) -> Float {
658 Swift.min(Swift.max(value, minVal), maxVal)
659 }
660}
661
662// MARK: - Custom Panel
663
664private final class OverlayPanel: NSPanel {
665 override var canBecomeKey: Bool { true }
666 override var canBecomeMain: Bool { true }
667}