Mission Control Turbo: macOS multitasking turbocharged
at main 667 lines 24 kB view raw
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}