import AppKit import Combine import SwiftUI /// Manages the full-screen overlay NSPanel for each display with /// interactive gesture-driven transitions. @MainActor final class OverlayWindowController { // MARK: - State private enum State { case idle case tracking // gesture active, panels visible, driving progress case committing // spring animating to 1.0 case shown // fully visible and interactive case dismissTracking // gesture active while shown, driving progress down case dismissing // spring animating to 0.0 (committed dismiss) case cancelling // spring animating back (cancel gesture) } private var state: State = .idle private var panels: [NSPanel] = [] private var layoutEngines: [ScreenKey: LayoutEngine] = [:] private let gestureProgress: GestureProgress private var springAnimator: SpringAnimator? private var captureGeneration: Int = 0 /// Cached thumbnails from previous activation, keyed by window ID. private var thumbnailCache: [CGWindowID: CGImage] = [:] /// Floating window that follows the cursor during cross-monitor drag. private var dragFloatingWindow: NSWindow? private var dragObservation: AnyCancellable? var onWindowSelected: ((WindowInfo) -> Void)? var onDismiss: (() -> Void)? // Normalization: 10% of trackpad range = full reveal private let normalizationDivisor: Float = 0.10 // Commit thresholds private let commitProgressThreshold: CGFloat = 0.3 private let commitVelocityThreshold: Float = 0.2 var isVisible: Bool { switch state { case .idle: return false default: return true } } init(gestureProgress: GestureProgress) { self.gestureProgress = gestureProgress startObservingDrag() } // MARK: - Gesture callbacks func gestureDidBegin() { mctLog("[MCT] gestureDidBegin state=%d", state == .idle ? 0 : state == .shown ? 1 : 2) switch state { case .idle, .cancelling, .dismissing: // Interrupt any running animation and start fresh springAnimator?.stop() springAnimator = nil // If panels exist from a cancelling/dismissing animation, remove them if state != .idle { removePanels() gestureProgress.thumbnailsReady = false gestureProgress.layouts.removeAll() } state = .tracking captureGeneration += 1 let gen = captureGeneration createPanels(interactive: false) setProgress(0.0, phase: .tracking) // Phase 1: enumerate + apply cached thumbnails immediately var windows = WindowEnumerator.enumerateWindows() applyCachedThumbnails(to: &windows) applyThumbnails(windows: windows) // Phase 2: capture fresh thumbnails in background Task { var freshWindows = windows await ThumbnailCapture.captureThumbnails(for: &freshWindows) let captured = freshWindows await MainActor.run { guard gen == self.captureGeneration else { return } self.updateThumbnailCache(from: captured) self.applyThumbnails(windows: captured) } } case .shown, .committing: // Begin dismiss tracking (interrupt commit animation if mid-spring) springAnimator?.stop() springAnimator = nil state = .dismissTracking setInteractive(false) setProgress(gestureProgress.progress, phase: .dismissTracking) case .tracking, .dismissTracking: // Already tracking — ignore duplicate began break } } func gestureDidChange(rawDY: Float, rawVelocity: Float) { switch state { case .tracking: let normalized = CGFloat(clamp(rawDY / normalizationDivisor, min: 0, max: 1)) setProgress(normalized, phase: .tracking) case .dismissTracking: // Negative DY (swipe down) drives dismiss; positive re-opens let normalized = CGFloat(clamp(-rawDY / normalizationDivisor, min: 0, max: 1)) let progress = 1.0 - normalized setProgress(progress, phase: .dismissTracking) default: break } } func gestureDidEnd(rawDY: Float, rawVelocity: Float) { switch state { case .tracking: let normalized = CGFloat(clamp(rawDY / normalizationDivisor, min: 0, max: 1)) let shouldCommit = normalized > commitProgressThreshold || rawVelocity > commitVelocityThreshold if shouldCommit { animateToCommit(from: normalized, velocity: CGFloat(rawVelocity)) } else { animateToCancel(from: normalized, velocity: CGFloat(rawVelocity)) } case .dismissTracking: let dismissNormalized = CGFloat(clamp(-rawDY / normalizationDivisor, min: 0, max: 1)) let currentProgress = 1.0 - dismissNormalized let shouldDismiss = dismissNormalized > commitProgressThreshold || (-rawVelocity) > commitVelocityThreshold if shouldDismiss { animateToDismiss(from: currentProgress, velocity: CGFloat(rawVelocity)) } else { animateToCancelDismiss(from: currentProgress, velocity: CGFloat(rawVelocity)) } default: break } } func gestureDidCancel() { switch state { case .tracking: let current = gestureProgress.progress animateToCancel(from: current, velocity: 0) case .dismissTracking: let current = gestureProgress.progress animateToCancelDismiss(from: current, velocity: 0) default: break } } // MARK: - Hotkey toggle func toggle() { mctLog("[MCT] toggle called, state=%d", state == .idle ? 0 : 1) switch state { case .idle: // Hotkey show: create panels, animate to fully shown state = .committing springAnimator?.stop() springAnimator = nil captureGeneration += 1 let gen = captureGeneration createPanels(interactive: true) setProgress(0.0, phase: .committing) NSApp.activate(ignoringOtherApps: true) if let firstPanel = panels.first { firstPanel.makeKey() } // Phase 1: enumerate + apply cached thumbnails immediately let t0 = CFAbsoluteTimeGetCurrent() var windows = WindowEnumerator.enumerateWindows() applyCachedThumbnails(to: &windows) applyThumbnails(windows: windows) let t1 = CFAbsoluteTimeGetCurrent() mctLog("[MCT] phase1 (cached): %.0f ms (%d windows)", (t1 - t0) * 1000, windows.count) // Phase 2: capture fresh thumbnails in background and swap in Task { var freshWindows = windows await ThumbnailCapture.captureThumbnails(for: &freshWindows) let t2 = CFAbsoluteTimeGetCurrent() mctLog("[MCT] phase2 (capture): %.0f ms", (t2 - t1) * 1000) let captured = freshWindows await MainActor.run { guard gen == self.captureGeneration else { return } self.updateThumbnailCache(from: captured) self.applyThumbnails(windows: captured) } } animateToCommit(from: 0, velocity: 0, stiffness: 800.0) case .shown: animateToDismiss(from: 1.0, velocity: 0, stiffness: 800.0) case .committing: // Reverse: dismiss from current progress let current = gestureProgress.progress animateToDismiss(from: current, velocity: 0, stiffness: 800.0) case .dismissing, .cancelling: // Reverse: reopen from current progress let current = gestureProgress.progress setInteractive(true) NSApp.activate(ignoringOtherApps: true) if let firstPanel = panels.first { firstPanel.makeKey() } animateToCommit(from: current, velocity: 0, stiffness: 800.0) default: let current = gestureProgress.progress animateToDismiss(from: current, velocity: 0, stiffness: 800.0) } } /// Dismiss from external trigger (Escape key, background tap). func dismiss() { switch state { case .shown: animateToDismiss(from: 1.0, velocity: 0, stiffness: 800.0) case .idle: break default: let current = gestureProgress.progress animateToDismiss(from: current, velocity: 0, stiffness: 800.0) } } // MARK: - Spring animations private func animateToCommit(from current: CGFloat, velocity: CGFloat, stiffness: CGFloat = 300.0) { state = .committing gestureProgress.phase = .committing springAnimator?.stop() springAnimator = SpringAnimator( from: current, to: 1.0, initialVelocity: velocity, stiffness: stiffness, onUpdate: { [weak self] value in self?.gestureProgress.progress = value }, onComplete: { [weak self] in self?.commitComplete() } ) springAnimator?.start() } private func animateToCancel(from current: CGFloat, velocity: CGFloat) { state = .cancelling gestureProgress.phase = .cancelling springAnimator?.stop() springAnimator = SpringAnimator( from: current, to: 0.0, initialVelocity: velocity, onUpdate: { [weak self] value in self?.gestureProgress.progress = value }, onComplete: { [weak self] in self?.cancelComplete() } ) springAnimator?.start() } private func animateToDismiss(from current: CGFloat, velocity: CGFloat, stiffness: CGFloat = 300.0) { state = .dismissing gestureProgress.phase = .dismissing springAnimator?.stop() springAnimator = SpringAnimator( from: current, to: 0.0, initialVelocity: velocity, stiffness: stiffness, onUpdate: { [weak self] value in self?.gestureProgress.progress = value }, onComplete: { [weak self] in self?.dismissComplete() } ) springAnimator?.start() } private func animateToCancelDismiss(from current: CGFloat, velocity: CGFloat) { // Animate back to fully shown state = .committing gestureProgress.phase = .committing springAnimator?.stop() springAnimator = SpringAnimator( from: current, to: 1.0, initialVelocity: velocity, onUpdate: { [weak self] value in self?.gestureProgress.progress = value }, onComplete: { [weak self] in self?.commitComplete() } ) springAnimator?.start() } // MARK: - Completion handlers private func commitComplete() { state = .shown gestureProgress.progress = 1.0 gestureProgress.phase = .committed setInteractive(true) NSApp.activate(ignoringOtherApps: true) if let firstPanel = panels.first { firstPanel.makeKey() } } private func cancelComplete() { state = .idle gestureProgress.progress = 0.0 gestureProgress.phase = .idle gestureProgress.thumbnailsReady = false gestureProgress.layouts.removeAll() gestureProgress.dragState = nil layoutEngines.values.forEach { $0.invalidate() } layoutEngines.removeAll() removeDragFloatingWindow() removePanels() } private func dismissComplete() { state = .idle gestureProgress.progress = 0.0 gestureProgress.phase = .idle gestureProgress.thumbnailsReady = false gestureProgress.layouts.removeAll() gestureProgress.dragState = nil layoutEngines.values.forEach { $0.invalidate() } layoutEngines.removeAll() removeDragFloatingWindow() onDismiss?() removePanels() } // MARK: - Panel lifecycle private func createPanels(interactive: Bool) { removePanels() for screen in NSScreen.screens { let panel = createPanel(for: screen) panel.ignoresMouseEvents = !interactive panel.alphaValue = 1.0 let overlayView = OverlayView( gestureProgress: gestureProgress, screenBounds: screen.frame, onSelect: { [weak self] windowInfo in self?.selectWindow(windowInfo) }, onDismiss: { [weak self] in self?.dismiss() }, onDrop: { [weak self] point in self?.handleDrop(at: point) } ) panel.contentView = NSHostingView(rootView: overlayView) panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() panels.append(panel) } } private func removePanels() { for panel in panels { panel.orderOut(nil) } panels.removeAll() } private func setInteractive(_ interactive: Bool) { for panel in panels { panel.ignoresMouseEvents = !interactive } } private func createPanel(for screen: NSScreen) -> NSPanel { let panel = OverlayPanel( contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isFloatingPanel = true panel.level = .screenSaver panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = false panel.hidesOnDeactivate = false panel.acceptsMouseMovedEvents = true panel.ignoresMouseEvents = false return panel } // MARK: - Cross-monitor drag func startObservingDrag() { dragObservation = gestureProgress.$dragState .receive(on: RunLoop.main) .sink { [weak self] state in guard let self else { return } if let state = state { if self.dragFloatingWindow == nil { self.createDragFloatingWindow(for: state) } self.updateDragFloatingWindow(position: state.currentScreenPosition) } else { self.removeDragFloatingWindow() } } } private func createDragFloatingWindow(for state: DragState) { let windowInfo = state.windowInfo // Size: 200px wide, aspect-preserved let aspect = windowInfo.frame.height / max(windowInfo.frame.width, 1) let thumbWidth: CGFloat = 200 let thumbHeight = thumbWidth * aspect let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: thumbWidth, height: thumbHeight), styleMask: [.borderless], backing: .buffered, defer: false ) window.level = NSWindow.Level(Int(CGWindowLevelForKey(.screenSaverWindow)) + 1) window.isOpaque = false window.backgroundColor = .clear window.hasShadow = true window.ignoresMouseEvents = true window.collectionBehavior = [.canJoinAllSpaces] if let thumb = windowInfo.thumbnail { let nsImage = NSImage(cgImage: thumb, size: NSSize(width: thumbWidth, height: thumbHeight)) let imageView = NSImageView(frame: NSRect(x: 0, y: 0, width: thumbWidth, height: thumbHeight)) imageView.image = nsImage imageView.imageScaling = .scaleProportionallyUpOrDown imageView.wantsLayer = true imageView.layer?.cornerRadius = 8 imageView.layer?.masksToBounds = true imageView.layer?.opacity = 0.85 window.contentView = imageView } window.alphaValue = 0.9 window.orderFrontRegardless() dragFloatingWindow = window } private func updateDragFloatingWindow(position: CGPoint) { guard let window = dragFloatingWindow else { return } let size = window.frame.size // Center the floating window on the cursor window.setFrameOrigin(NSPoint(x: position.x - size.width / 2, y: position.y - size.height / 2)) } private func removeDragFloatingWindow() { dragFloatingWindow?.orderOut(nil) dragFloatingWindow = nil } private func handleDrop(at point: CGPoint) { guard let dragState = gestureProgress.dragState else { return } let windowInfo = dragState.windowInfo // Find which screen the cursor landed on let targetScreen = NSScreen.screens.first(where: { $0.frame.contains(point) }) ?? NSScreen.screens.first! let primaryHeight = NSScreen.screens.first?.frame.height ?? 0 // Center the window on the target screen (Quartz coords: top-left origin) let targetQuartz = quartzFrame(for: targetScreen, primaryHeight: primaryHeight) let winW = windowInfo.frame.width let winH = windowInfo.frame.height let dropPoint = CGPoint( x: targetQuartz.midX - winW / 2, y: targetQuartz.midY - winH / 2 ) // Move the window _ = AccessibilityBridge.moveWindow(pid: windowInfo.pid, windowID: windowInfo.windowID, to: dropPoint) // Clear drag state gestureProgress.dragState = nil removeDragFloatingWindow() // Invalidate layout engines so both source and target screens recompute fresh grids let sourceKey = dragState.sourceScreenKey let targetKey = ScreenKey(targetScreen.frame) layoutEngines[sourceKey]?.invalidate() layoutEngines[targetKey]?.invalidate() // Re-enumerate and patch the moved window's frame immediately, // since CGWindowList may not yet reflect the AX position change. captureGeneration += 1 let gen = captureGeneration var windows = WindowEnumerator.enumerateWindows() applyCachedThumbnails(to: &windows) let newFrame = CGRect(origin: dropPoint, size: windowInfo.frame.size) for i in windows.indices where windows[i].windowID == windowInfo.windowID { windows[i] = WindowInfo( windowID: windows[i].windowID, title: windows[i].title, appName: windows[i].appName, appBundleID: windows[i].appBundleID, appIcon: windows[i].appIcon, frame: newFrame, pid: windows[i].pid, isOnScreen: windows[i].isOnScreen, thumbnail: windows[i].thumbnail ) } applyThumbnails(windows: windows) // Re-order panels to front so they aren't hidden behind each other for panel in panels { panel.orderFrontRegardless() } // Background: re-enumerate with fresh system data + thumbnails Task { try? await Task.sleep(nanoseconds: 200_000_000) // 200ms for AX move to propagate guard gen == self.captureGeneration else { return } var freshWindows = WindowEnumerator.enumerateWindows() self.applyCachedThumbnails(to: &freshWindows) await ThumbnailCapture.captureThumbnails(for: &freshWindows) let captured = freshWindows await MainActor.run { guard gen == self.captureGeneration else { return } self.updateThumbnailCache(from: captured) self.applyThumbnails(windows: captured) } } } // MARK: - Helpers private func setProgress(_ value: CGFloat, phase: GesturePhase) { gestureProgress.progress = value gestureProgress.phase = phase } private func applyThumbnails(windows: [WindowInfo]) { let screens = NSScreen.screens let primaryHeight = screens.first?.frame.height ?? 0 var layouts: [ScreenKey: LayoutResult] = [:] for screen in screens { let key = ScreenKey(screen.frame) // Convert screen frame from AppKit coords (bottom-left origin) to // Quartz coords (top-left origin) to match WindowInfo.frame let qFrame = quartzFrame(for: screen, primaryHeight: primaryHeight) // Only include windows whose center falls on this screen let screenWindows = windows.filter { window in let center = CGPoint(x: window.frame.midX, y: window.frame.midY) return qFrame.contains(center) } let groups = WindowEnumerator.groupWindows(screenWindows) let snapshot = WindowSnapshot(groups: groups) if layoutEngines[key] == nil { layoutEngines[key] = LayoutEngine() } let layout = layoutEngines[key]!.layout(snapshot: snapshot, screenBounds: screen.frame) layouts[key] = layout } gestureProgress.layouts = layouts gestureProgress.thumbnailsReady = true gestureProgress.thumbnailGeneration += 1 } /// Apply cached thumbnails to windows that have a cache hit. private func applyCachedThumbnails(to windows: inout [WindowInfo]) { for i in windows.indices { if let cached = thumbnailCache[windows[i].windowID] { windows[i].thumbnail = cached } } } /// Update the thumbnail cache from freshly captured windows. private func updateThumbnailCache(from windows: [WindowInfo]) { for window in windows { if let thumb = window.thumbnail { thumbnailCache[window.windowID] = thumb } } // Prune stale entries for windows that no longer exist let currentIDs = Set(windows.map { $0.windowID }) for key in thumbnailCache.keys where !currentIDs.contains(key) { thumbnailCache.removeValue(forKey: key) } } /// Convert an NSScreen frame (AppKit bottom-left origin) to Quartz display /// coordinates (top-left origin) so we can compare with WindowInfo.frame. private func quartzFrame(for screen: NSScreen, primaryHeight: CGFloat) -> CGRect { let f = screen.frame return CGRect(x: f.origin.x, y: primaryHeight - f.origin.y - f.height, width: f.width, height: f.height) } private func selectWindow(_ windowInfo: WindowInfo) { dismiss() onWindowSelected?(windowInfo) _ = AccessibilityBridge.activateWindow(pid: windowInfo.pid, windowID: windowInfo.windowID) } private func clamp(_ value: Float, min minVal: Float, max maxVal: Float) -> Float { Swift.min(Swift.max(value, minVal), maxVal) } } // MARK: - Custom Panel private final class OverlayPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } }