import SwiftUI /// Root SwiftUI view for the overlay, with progress-driven rendering. struct OverlayView: View { @ObservedObject var gestureProgress: GestureProgress let screenBounds: CGRect let onSelect: (WindowInfo) -> Void let onDismiss: () -> Void let onDrop: (CGPoint) -> Void @State private var selectedIndex: Int? = nil @State private var hoveredIndex: Int? = nil private var progress: CGFloat { gestureProgress.progress } private var isInteractive: Bool { gestureProgress.phase == .committed || gestureProgress.phase == .committing } private var draggedWindowID: CGWindowID? { gestureProgress.dragState?.windowInfo.windowID } private var layout: LayoutResult? { gestureProgress.layouts[ScreenKey(screenBounds)] } var body: some View { ZStack { // Blurred + dimmed background — always visible, driven by progress VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) .opacity(progress) Color.black.opacity(0.25 * progress) .overlay( ClickHandlerView { if isInteractive { onDismiss() } } ) if let layout = layout { let _ = gestureProgress.thumbnailGeneration // force re-render on thumbnail update ForEach(Array(layout.items.enumerated()), id: \.element.id) { index, item in let stagger = min(Double(index) * 0.03, 0.3) let itemProgress = max(0, min(1, (progress - stagger) / (1.0 - stagger))) WindowThumbnailView( windowInfo: item.windowInfo, size: CGSize(width: item.frame.width, height: item.frame.height), isSelected: hoveredIndex == index || selectedIndex == index, showAppBadge: layout.isCompactHeaders ) .scaleEffect(0.9 + 0.1 * itemProgress) .offset(y: 30 * (1.0 - itemProgress)) .opacity(itemProgress) .contentShape(Rectangle()) .opacity(draggedWindowID == item.windowInfo.windowID ? 0.3 : 1.0) .overlay( draggedWindowID == item.windowInfo.windowID ? RoundedRectangle(cornerRadius: 8) .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6, 4])) .foregroundColor(.white.opacity(0.5)) : nil ) .onHover { hovering in if isInteractive && gestureProgress.dragState == nil { hoveredIndex = hovering ? index : nil if hovering { selectedIndex = nil NSCursor.pointingHand.push() } else { NSCursor.pop() } } } .overlay( ClickHandlerView( onClick: { if isInteractive { onSelect(item.windowInfo) } }, onDragStart: { point in if isInteractive { gestureProgress.dragState = DragState( windowInfo: item.windowInfo, sourceScreenKey: ScreenKey(screenBounds), currentScreenPosition: point ) } }, onDragMove: { point in if gestureProgress.dragState != nil { gestureProgress.dragState?.currentScreenPosition = point } }, onDrop: { point in if gestureProgress.dragState != nil { gestureProgress.dragState?.currentScreenPosition = point onDrop(point) } } ) ) .position( x: item.frame.midX - screenBounds.origin.x, y: item.frame.midY - screenBounds.origin.y ) } } } .frame(width: screenBounds.width, height: screenBounds.height) .onChange(of: isInteractive) { _, interactive in if interactive { updateHoverFromMousePosition() } } .background( KeyEventHandlingView( onEscape: { if gestureProgress.dragState != nil { gestureProgress.dragState = nil } else if isInteractive { onDismiss() } }, onArrow: { direction in if isInteractive { hoveredIndex = nil handleArrow(direction) } }, onReturn: { if isInteractive, let layout = layout { let idx = hoveredIndex ?? selectedIndex if let idx = idx, idx < layout.items.count { onSelect(layout.items[idx].windowInfo) } } }, onTab: { shiftHeld in if isInteractive, let layout = layout { hoveredIndex = nil let count = layout.items.count guard count > 0 else { return } if let idx = selectedIndex { selectedIndex = shiftHeld ? (idx - 1 + count) % count : (idx + 1) % count } else { selectedIndex = shiftHeld ? count - 1 : 0 } } } ) ) } private func handleArrow(_ direction: ArrowDirection) { guard let layout = layout else { return } let count = layout.items.count let groupLayouts = layout.groupLayouts guard count > 0, !groupLayouts.isEmpty else { return } if selectedIndex == nil { selectedIndex = 0 return } guard let idx = selectedIndex else { return } // Find which group the current index is in guard let gi = groupLayouts.firstIndex(where: { $0.itemRange.contains(idx) }) else { return } let gl = groupLayouts[gi] let cols = gl.columns let localIdx = idx - gl.itemRange.lowerBound let col = localIdx % cols let row = localIdx / cols switch direction { case .left: if col > 0 { selectedIndex = idx - 1 } else if idx > 0 { selectedIndex = idx - 1 } case .right: if col < cols - 1 && localIdx + 1 < gl.itemRange.count { selectedIndex = idx + 1 } else if idx + 1 < count { selectedIndex = idx + 1 } case .up: if row > 0 { selectedIndex = idx - cols } else if gi > 0 { let prevGL = groupLayouts[gi - 1] let prevCols = prevGL.columns let prevCount = prevGL.itemRange.count let prevRows = (prevCount - 1) / prevCols let targetCol = min(col, prevCols - 1) let targetLocal = prevRows * prevCols + targetCol selectedIndex = prevGL.itemRange.lowerBound + min(targetLocal, prevCount - 1) } case .down: let nextRowStart = (row + 1) * cols if nextRowStart < gl.itemRange.count { let target = nextRowStart + col selectedIndex = gl.itemRange.lowerBound + min(target, gl.itemRange.count - 1) } else if gi + 1 < groupLayouts.count { let nextGL = groupLayouts[gi + 1] let targetCol = min(col, nextGL.columns - 1) let targetLocal = min(targetCol, nextGL.itemRange.count - 1) selectedIndex = nextGL.itemRange.lowerBound + targetLocal } } } /// One-time hit-test: if the cursor is already over a thumbnail when the /// overlay becomes interactive, highlight it immediately. private func updateHoverFromMousePosition() { guard let layout = layout else { return } let mouse = NSEvent.mouseLocation // Convert AppKit screen coords (bottom-left origin) to overlay SwiftUI coords (top-left origin) let mx = mouse.x - screenBounds.origin.x let my = screenBounds.origin.y + screenBounds.height - mouse.y for (index, item) in layout.items.enumerated() { let ix = item.frame.origin.x - screenBounds.origin.x let iy = item.frame.origin.y - screenBounds.origin.y if mx >= ix && mx <= ix + item.frame.width && my >= iy && my <= iy + item.frame.height { hoveredIndex = index selectedIndex = nil NSCursor.pointingHand.push() return } } } } enum ArrowDirection { case left, right, up, down } /// AppKit NSView to capture keyboard events in the overlay. struct KeyEventHandlingView: NSViewRepresentable { let onEscape: () -> Void let onArrow: (ArrowDirection) -> Void let onReturn: () -> Void let onTab: (Bool) -> Void // Bool = shift held func makeNSView(context: Context) -> KeyCaptureNSView { let view = KeyCaptureNSView() view.onEscape = onEscape view.onArrow = onArrow view.onReturn = onReturn view.onTab = onTab // Retry making first responder until the view is in a key-capable window func attemptFirstResponder(retries: Int = 5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { if let window = view.window, window.canBecomeKey { window.makeFirstResponder(view) } else if retries > 0 { attemptFirstResponder(retries: retries - 1) } } } attemptFirstResponder() return view } func updateNSView(_ nsView: KeyCaptureNSView, context: Context) { nsView.onEscape = onEscape nsView.onArrow = onArrow nsView.onReturn = onReturn nsView.onTab = onTab } } final class KeyCaptureNSView: NSView { var onEscape: (() -> Void)? var onArrow: ((ArrowDirection) -> Void)? var onReturn: (() -> Void)? var onTab: ((Bool) -> Void)? override var acceptsFirstResponder: Bool { true } override func keyDown(with event: NSEvent) { switch Int(event.keyCode) { case 53: // Escape onEscape?() case 123: // Left onArrow?(.left) case 124: // Right onArrow?(.right) case 125: // Down onArrow?(.down) case 126: // Up onArrow?(.up) case 36: // Return onReturn?() case 48: // Tab onTab?(event.modifierFlags.contains(.shift)) default: super.keyDown(with: event) } } } /// AppKit-backed click/drag handler — more reliable than SwiftUI's onTapGesture /// during animations (view rebuilds can reset gesture recognizers). struct ClickHandlerView: NSViewRepresentable { let onClick: () -> Void var onDragStart: ((CGPoint) -> Void)? = nil var onDragMove: ((CGPoint) -> Void)? = nil var onDrop: ((CGPoint) -> Void)? = nil func makeNSView(context: Context) -> DragClickNSView { let view = DragClickNSView() view.onClick = onClick view.onDragStart = onDragStart view.onDragMove = onDragMove view.onDrop = onDrop return view } func updateNSView(_ nsView: DragClickNSView, context: Context) { nsView.onClick = onClick nsView.onDragStart = onDragStart nsView.onDragMove = onDragMove nsView.onDrop = onDrop } } final class DragClickNSView: NSView { var onClick: (() -> Void)? var onDragStart: ((CGPoint) -> Void)? var onDragMove: ((CGPoint) -> Void)? var onDrop: ((CGPoint) -> Void)? private var mouseDownPoint: CGPoint? private var isDragging = false private static let dragThreshold: CGFloat = 5.0 override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func mouseDown(with event: NSEvent) { mouseDownPoint = NSEvent.mouseLocation isDragging = false } override func mouseDragged(with event: NSEvent) { guard let startPoint = mouseDownPoint else { return } let current = NSEvent.mouseLocation let dx = current.x - startPoint.x let dy = current.y - startPoint.y let distance = sqrt(dx * dx + dy * dy) if !isDragging && distance > Self.dragThreshold { isDragging = true onDragStart?(current) } if isDragging { onDragMove?(current) } } override func mouseUp(with event: NSEvent) { if isDragging { let current = NSEvent.mouseLocation onDrop?(current) } else { let loc = convert(event.locationInWindow, from: nil) if bounds.contains(loc) { onClick?() } } mouseDownPoint = nil isDragging = false } // Allow hover events to pass through to sibling SwiftUI views override func hitTest(_ point: NSPoint) -> NSView? { guard bounds.contains(point) else { return nil } guard let event = NSApp.currentEvent else { return nil } switch event.type { case .leftMouseDown, .leftMouseUp, .leftMouseDragged: return self default: return nil } } } /// Wraps NSVisualEffectView for a behind-window blur in SwiftUI. struct VisualEffectBlur: NSViewRepresentable { let material: NSVisualEffectView.Material let blendingMode: NSVisualEffectView.BlendingMode func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.material = material view.blendingMode = blendingMode view.state = .active return view } func updateNSView(_ nsView: NSVisualEffectView, context: Context) { nsView.material = material nsView.blendingMode = blendingMode } }