Mission Control Turbo: macOS multitasking turbocharged
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}