Mission Control Turbo: macOS multitasking turbocharged
at main 414 lines 15 kB view raw
1import SwiftUI 2 3/// Root SwiftUI view for the overlay, with progress-driven rendering. 4struct OverlayView: View { 5 @ObservedObject var gestureProgress: GestureProgress 6 let screenBounds: CGRect 7 let onSelect: (WindowInfo) -> Void 8 let onDismiss: () -> Void 9 let onDrop: (CGPoint) -> Void 10 11 @State private var selectedIndex: Int? = nil 12 @State private var hoveredIndex: Int? = nil 13 14 private var progress: CGFloat { gestureProgress.progress } 15 private var isInteractive: Bool { 16 gestureProgress.phase == .committed || gestureProgress.phase == .committing 17 } 18 19 private var draggedWindowID: CGWindowID? { 20 gestureProgress.dragState?.windowInfo.windowID 21 } 22 23 private var layout: LayoutResult? { 24 gestureProgress.layouts[ScreenKey(screenBounds)] 25 } 26 27 var body: some View { 28 ZStack { 29 // Blurred + dimmed background always visible, driven by progress 30 VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) 31 .opacity(progress) 32 33 Color.black.opacity(0.25 * progress) 34 .overlay( 35 ClickHandlerView { 36 if isInteractive { 37 onDismiss() 38 } 39 } 40 ) 41 42 if let layout = layout { 43 let _ = gestureProgress.thumbnailGeneration // force re-render on thumbnail update 44 ForEach(Array(layout.items.enumerated()), id: \.element.id) { index, item in 45 let stagger = min(Double(index) * 0.03, 0.3) 46 let itemProgress = max(0, min(1, (progress - stagger) / (1.0 - stagger))) 47 48 WindowThumbnailView( 49 windowInfo: item.windowInfo, 50 size: CGSize(width: item.frame.width, height: item.frame.height), 51 isSelected: hoveredIndex == index || selectedIndex == index, 52 showAppBadge: layout.isCompactHeaders 53 ) 54 .scaleEffect(0.9 + 0.1 * itemProgress) 55 .offset(y: 30 * (1.0 - itemProgress)) 56 .opacity(itemProgress) 57 .contentShape(Rectangle()) 58 .opacity(draggedWindowID == item.windowInfo.windowID ? 0.3 : 1.0) 59 .overlay( 60 draggedWindowID == item.windowInfo.windowID 61 ? RoundedRectangle(cornerRadius: 8) 62 .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6, 4])) 63 .foregroundColor(.white.opacity(0.5)) 64 : nil 65 ) 66 .onHover { hovering in 67 if isInteractive && gestureProgress.dragState == nil { 68 hoveredIndex = hovering ? index : nil 69 if hovering { 70 selectedIndex = nil 71 NSCursor.pointingHand.push() 72 } else { 73 NSCursor.pop() 74 } 75 } 76 } 77 .overlay( 78 ClickHandlerView( 79 onClick: { 80 if isInteractive { 81 onSelect(item.windowInfo) 82 } 83 }, 84 onDragStart: { point in 85 if isInteractive { 86 gestureProgress.dragState = DragState( 87 windowInfo: item.windowInfo, 88 sourceScreenKey: ScreenKey(screenBounds), 89 currentScreenPosition: point 90 ) 91 } 92 }, 93 onDragMove: { point in 94 if gestureProgress.dragState != nil { 95 gestureProgress.dragState?.currentScreenPosition = point 96 } 97 }, 98 onDrop: { point in 99 if gestureProgress.dragState != nil { 100 gestureProgress.dragState?.currentScreenPosition = point 101 onDrop(point) 102 } 103 } 104 ) 105 ) 106 .position( 107 x: item.frame.midX - screenBounds.origin.x, 108 y: item.frame.midY - screenBounds.origin.y 109 ) 110 } 111 } 112 } 113 .frame(width: screenBounds.width, height: screenBounds.height) 114 .onChange(of: isInteractive) { _, interactive in 115 if interactive { 116 updateHoverFromMousePosition() 117 } 118 } 119 .background( 120 KeyEventHandlingView( 121 onEscape: { 122 if gestureProgress.dragState != nil { 123 gestureProgress.dragState = nil 124 } else if isInteractive { 125 onDismiss() 126 } 127 }, 128 onArrow: { direction in 129 if isInteractive { 130 hoveredIndex = nil 131 handleArrow(direction) 132 } 133 }, 134 onReturn: { 135 if isInteractive, let layout = layout { 136 let idx = hoveredIndex ?? selectedIndex 137 if let idx = idx, idx < layout.items.count { 138 onSelect(layout.items[idx].windowInfo) 139 } 140 } 141 }, 142 onTab: { shiftHeld in 143 if isInteractive, let layout = layout { 144 hoveredIndex = nil 145 let count = layout.items.count 146 guard count > 0 else { return } 147 if let idx = selectedIndex { 148 selectedIndex = shiftHeld 149 ? (idx - 1 + count) % count 150 : (idx + 1) % count 151 } else { 152 selectedIndex = shiftHeld ? count - 1 : 0 153 } 154 } 155 } 156 ) 157 ) 158 } 159 160 private func handleArrow(_ direction: ArrowDirection) { 161 guard let layout = layout else { return } 162 let count = layout.items.count 163 let groupLayouts = layout.groupLayouts 164 guard count > 0, !groupLayouts.isEmpty else { return } 165 166 if selectedIndex == nil { 167 selectedIndex = 0 168 return 169 } 170 171 guard let idx = selectedIndex else { return } 172 173 // Find which group the current index is in 174 guard let gi = groupLayouts.firstIndex(where: { $0.itemRange.contains(idx) }) else { return } 175 let gl = groupLayouts[gi] 176 let cols = gl.columns 177 let localIdx = idx - gl.itemRange.lowerBound 178 let col = localIdx % cols 179 let row = localIdx / cols 180 181 switch direction { 182 case .left: 183 if col > 0 { 184 selectedIndex = idx - 1 185 } else if idx > 0 { 186 selectedIndex = idx - 1 187 } 188 case .right: 189 if col < cols - 1 && localIdx + 1 < gl.itemRange.count { 190 selectedIndex = idx + 1 191 } else if idx + 1 < count { 192 selectedIndex = idx + 1 193 } 194 case .up: 195 if row > 0 { 196 selectedIndex = idx - cols 197 } else if gi > 0 { 198 let prevGL = groupLayouts[gi - 1] 199 let prevCols = prevGL.columns 200 let prevCount = prevGL.itemRange.count 201 let prevRows = (prevCount - 1) / prevCols 202 let targetCol = min(col, prevCols - 1) 203 let targetLocal = prevRows * prevCols + targetCol 204 selectedIndex = prevGL.itemRange.lowerBound + min(targetLocal, prevCount - 1) 205 } 206 case .down: 207 let nextRowStart = (row + 1) * cols 208 if nextRowStart < gl.itemRange.count { 209 let target = nextRowStart + col 210 selectedIndex = gl.itemRange.lowerBound + min(target, gl.itemRange.count - 1) 211 } else if gi + 1 < groupLayouts.count { 212 let nextGL = groupLayouts[gi + 1] 213 let targetCol = min(col, nextGL.columns - 1) 214 let targetLocal = min(targetCol, nextGL.itemRange.count - 1) 215 selectedIndex = nextGL.itemRange.lowerBound + targetLocal 216 } 217 } 218 } 219 220 /// One-time hit-test: if the cursor is already over a thumbnail when the 221 /// overlay becomes interactive, highlight it immediately. 222 private func updateHoverFromMousePosition() { 223 guard let layout = layout else { return } 224 let mouse = NSEvent.mouseLocation 225 // Convert AppKit screen coords (bottom-left origin) to overlay SwiftUI coords (top-left origin) 226 let mx = mouse.x - screenBounds.origin.x 227 let my = screenBounds.origin.y + screenBounds.height - mouse.y 228 229 for (index, item) in layout.items.enumerated() { 230 let ix = item.frame.origin.x - screenBounds.origin.x 231 let iy = item.frame.origin.y - screenBounds.origin.y 232 if mx >= ix && mx <= ix + item.frame.width && my >= iy && my <= iy + item.frame.height { 233 hoveredIndex = index 234 selectedIndex = nil 235 NSCursor.pointingHand.push() 236 return 237 } 238 } 239 } 240} 241 242enum ArrowDirection { 243 case left, right, up, down 244} 245 246/// AppKit NSView to capture keyboard events in the overlay. 247struct KeyEventHandlingView: NSViewRepresentable { 248 let onEscape: () -> Void 249 let onArrow: (ArrowDirection) -> Void 250 let onReturn: () -> Void 251 let onTab: (Bool) -> Void // Bool = shift held 252 253 func makeNSView(context: Context) -> KeyCaptureNSView { 254 let view = KeyCaptureNSView() 255 view.onEscape = onEscape 256 view.onArrow = onArrow 257 view.onReturn = onReturn 258 view.onTab = onTab 259 // Retry making first responder until the view is in a key-capable window 260 func attemptFirstResponder(retries: Int = 5) { 261 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { 262 if let window = view.window, window.canBecomeKey { 263 window.makeFirstResponder(view) 264 } else if retries > 0 { 265 attemptFirstResponder(retries: retries - 1) 266 } 267 } 268 } 269 attemptFirstResponder() 270 return view 271 } 272 273 func updateNSView(_ nsView: KeyCaptureNSView, context: Context) { 274 nsView.onEscape = onEscape 275 nsView.onArrow = onArrow 276 nsView.onReturn = onReturn 277 nsView.onTab = onTab 278 } 279} 280 281final class KeyCaptureNSView: NSView { 282 var onEscape: (() -> Void)? 283 var onArrow: ((ArrowDirection) -> Void)? 284 var onReturn: (() -> Void)? 285 var onTab: ((Bool) -> Void)? 286 287 override var acceptsFirstResponder: Bool { true } 288 289 override func keyDown(with event: NSEvent) { 290 switch Int(event.keyCode) { 291 case 53: // Escape 292 onEscape?() 293 case 123: // Left 294 onArrow?(.left) 295 case 124: // Right 296 onArrow?(.right) 297 case 125: // Down 298 onArrow?(.down) 299 case 126: // Up 300 onArrow?(.up) 301 case 36: // Return 302 onReturn?() 303 case 48: // Tab 304 onTab?(event.modifierFlags.contains(.shift)) 305 default: 306 super.keyDown(with: event) 307 } 308 } 309} 310 311/// AppKit-backed click/drag handler more reliable than SwiftUI's onTapGesture 312/// during animations (view rebuilds can reset gesture recognizers). 313struct ClickHandlerView: NSViewRepresentable { 314 let onClick: () -> Void 315 var onDragStart: ((CGPoint) -> Void)? = nil 316 var onDragMove: ((CGPoint) -> Void)? = nil 317 var onDrop: ((CGPoint) -> Void)? = nil 318 319 func makeNSView(context: Context) -> DragClickNSView { 320 let view = DragClickNSView() 321 view.onClick = onClick 322 view.onDragStart = onDragStart 323 view.onDragMove = onDragMove 324 view.onDrop = onDrop 325 return view 326 } 327 328 func updateNSView(_ nsView: DragClickNSView, context: Context) { 329 nsView.onClick = onClick 330 nsView.onDragStart = onDragStart 331 nsView.onDragMove = onDragMove 332 nsView.onDrop = onDrop 333 } 334} 335 336final class DragClickNSView: NSView { 337 var onClick: (() -> Void)? 338 var onDragStart: ((CGPoint) -> Void)? 339 var onDragMove: ((CGPoint) -> Void)? 340 var onDrop: ((CGPoint) -> Void)? 341 342 private var mouseDownPoint: CGPoint? 343 private var isDragging = false 344 private static let dragThreshold: CGFloat = 5.0 345 346 override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } 347 348 override func mouseDown(with event: NSEvent) { 349 mouseDownPoint = NSEvent.mouseLocation 350 isDragging = false 351 } 352 353 override func mouseDragged(with event: NSEvent) { 354 guard let startPoint = mouseDownPoint else { return } 355 let current = NSEvent.mouseLocation 356 let dx = current.x - startPoint.x 357 let dy = current.y - startPoint.y 358 let distance = sqrt(dx * dx + dy * dy) 359 360 if !isDragging && distance > Self.dragThreshold { 361 isDragging = true 362 onDragStart?(current) 363 } 364 365 if isDragging { 366 onDragMove?(current) 367 } 368 } 369 370 override func mouseUp(with event: NSEvent) { 371 if isDragging { 372 let current = NSEvent.mouseLocation 373 onDrop?(current) 374 } else { 375 let loc = convert(event.locationInWindow, from: nil) 376 if bounds.contains(loc) { 377 onClick?() 378 } 379 } 380 mouseDownPoint = nil 381 isDragging = false 382 } 383 384 // Allow hover events to pass through to sibling SwiftUI views 385 override func hitTest(_ point: NSPoint) -> NSView? { 386 guard bounds.contains(point) else { return nil } 387 guard let event = NSApp.currentEvent else { return nil } 388 switch event.type { 389 case .leftMouseDown, .leftMouseUp, .leftMouseDragged: 390 return self 391 default: 392 return nil 393 } 394 } 395} 396 397/// Wraps NSVisualEffectView for a behind-window blur in SwiftUI. 398struct VisualEffectBlur: NSViewRepresentable { 399 let material: NSVisualEffectView.Material 400 let blendingMode: NSVisualEffectView.BlendingMode 401 402 func makeNSView(context: Context) -> NSVisualEffectView { 403 let view = NSVisualEffectView() 404 view.material = material 405 view.blendingMode = blendingMode 406 view.state = .active 407 return view 408 } 409 410 func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 411 nsView.material = material 412 nsView.blendingMode = blendingMode 413 } 414}