diff --git a/MCT/API/AccessibilityBridge.swift b/MCT/API/AccessibilityBridge.swift index c795c1c..61ac2e7 100644 --- a/MCT/API/AccessibilityBridge.swift +++ b/MCT/API/AccessibilityBridge.swift @@ -10,7 +10,7 @@ enum AccessibilityBridge { /// Prompt the user to grant Accessibility permission if not already granted. static func requestPermissionIfNeeded() { - let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as CFDictionary + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary AXIsProcessTrustedWithOptions(options) } diff --git a/MCT/API/GestureMonitor.swift b/MCT/API/GestureMonitor.swift new file mode 100644 index 0000000..097b1a8 --- /dev/null +++ b/MCT/API/GestureMonitor.swift @@ -0,0 +1,181 @@ +import AppKit +import CoreGraphics + +/// Monitors for four-finger swipe-up gesture to trigger the overlay. +/// +/// Uses NSEvent.addGlobalMonitorForEvents to detect swipe/scroll gestures. +/// Requires Accessibility permission for global event monitoring. +final class GestureMonitor { + typealias Handler = () -> Void + + private let handler: Handler + private var monitors: [Any] = [] + private var lastTriggerTime: CFAbsoluteTime = 0 + + // Scroll phase tracking + private var phaseActive = false + private var accumulatedScrollY: CGFloat = 0 + + // Debug logging to file + private let logFile: FileHandle? + private var logCount = 0 + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + + /// Shared for C callback + nonisolated(unsafe) static var shared: GestureMonitor? + + init(handler: @escaping Handler) { + self.handler = handler + let logPath = "/tmp/mct_gesture.log" + FileManager.default.createFile(atPath: logPath, contents: nil) + logFile = FileHandle(forWritingAtPath: logPath) + logFile?.seekToEndOfFile() + log("init") + } + + deinit { + stop() + logFile?.closeFile() + } + + private func log(_ msg: String) { + let line = "[\(Date())] \(msg)\n" + logFile?.write(line.data(using: .utf8) ?? Data()) + logFile?.synchronizeFile() + } + + func start() { + stop() + + // Delay registration to ensure the run loop is fully active + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.doStart() + } + } + + private func doStart() { + GestureMonitor.shared = self + log("doStart called on main thread: \(Thread.isMainThread)") + + // Approach 1: CGEventTap (most reliable for system-wide events) + let eventMask: CGEventMask = (1 << CGEventType.scrollWheel.rawValue) | + (1 << 29) | // gesture + (1 << 31) | // swipe + (1 << CGEventType.mouseMoved.rawValue) + + if let tap = CGEvent.tapCreate( + tap: .cghidEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: eventMask, + callback: { (proxy, type, event, refcon) -> Unmanaged? in + GestureMonitor.shared?.handleCGEvent(type, event) + return Unmanaged.passUnretained(event) + }, + userInfo: nil + ) { + eventTap = tap + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + if let source = runLoopSource { + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + } + CGEvent.tapEnable(tap: tap, enable: true) + log("CGEventTap created and enabled") + } else { + log("ERROR: CGEventTap creation FAILED — check Input Monitoring permission") + } + + // Approach 2: NSEvent global monitors as fallback + if let m = NSEvent.addGlobalMonitorForEvents(matching: [.scrollWheel, .swipe], handler: { [weak self] event in + self?.handleNSEvent(event) + }) { + monitors.append(m) + log("NSEvent global monitor registered") + } + + log("AXIsProcessTrusted = \(AXIsProcessTrusted())") + log("NSApp.isRunning = \(NSApp.isRunning)") + } + + func stop() { + GestureMonitor.shared = nil + for monitor in monitors { + NSEvent.removeMonitor(monitor) + } + monitors.removeAll() + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + eventTap = nil + } + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + runLoopSource = nil + } + } + + // MARK: - Event handlers + + private var debugCounter = 0 + + private func handleCGEvent(_ type: CGEventType, _ event: CGEvent) { + debugCounter += 1 + if debugCounter <= 30 { + log("CGEvent type=\(type.rawValue)") + } + + guard type == .scrollWheel else { return } + + let phase = event.getIntegerValueField(.scrollWheelEventScrollPhase) + let momentum = event.getIntegerValueField(.scrollWheelEventMomentumPhase) + let deltaY = event.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1) + + if debugCounter <= 30 { + log(" scroll phase=\(phase) momentum=\(momentum) dY=\(deltaY)") + } + + guard momentum == 0 else { return } + + switch phase { + case 1: // began + phaseActive = true + accumulatedScrollY = 0 + case 2 where phaseActive: // changed + accumulatedScrollY += CGFloat(deltaY) + case 4 where phaseActive: // ended + phaseActive = false + accumulatedScrollY += CGFloat(deltaY) + if accumulatedScrollY < -200 { + log("Scroll trigger: \(accumulatedScrollY)") + trigger() + } + accumulatedScrollY = 0 + case 8, 128: + phaseActive = false + accumulatedScrollY = 0 + default: + break + } + } + + private func handleNSEvent(_ event: NSEvent) { + logCount += 1 + if logCount <= 30 { + log("NSEvent type=\(event.type.rawValue)") + } + if event.type == .swipe && event.deltaY < 0 { + trigger() + } + } + + private func trigger() { + let now = CFAbsoluteTimeGetCurrent() + guard now - lastTriggerTime > 0.5 else { return } + lastTriggerTime = now + log("TRIGGERED!") + DispatchQueue.main.async { [weak self] in + self?.handler() + } + } +} diff --git a/MCT/API/ThumbnailCapture.swift b/MCT/API/ThumbnailCapture.swift index 53ecff3..a238ae2 100644 --- a/MCT/API/ThumbnailCapture.swift +++ b/MCT/API/ThumbnailCapture.swift @@ -5,15 +5,29 @@ import ScreenCaptureKit enum ThumbnailCapture { /// Capture thumbnails for all windows, updating them in place. static func captureThumbnails(for windows: inout [WindowInfo], maxSize: CGSize = CGSize(width: 320, height: 240)) async { - // Use CGWindowListCreateImage as the primary approach — it's simpler - // and doesn't require the async SCShareableContent dance for per-window shots. + guard let content = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) else { + return + } + + // Build a lookup from windowID to SCWindow + var scWindowsByID: [CGWindowID: SCWindow] = [:] + for scWindow in content.windows { + scWindowsByID[scWindow.windowID] = scWindow + } + for i in windows.indices { - let wid = windows[i].windowID - if let image = CGWindowListCreateImage( - .null, - .optionIncludingWindow, - wid, - [.boundsIgnoreFraming, .bestResolution] + guard let scWindow = scWindowsByID[windows[i].windowID] else { continue } + + let filter = SCContentFilter(desktopIndependentWindow: scWindow) + let config = SCStreamConfiguration() + config.width = Int(maxSize.width) + config.height = Int(maxSize.height) + config.scalesToFit = true + config.showsCursor = false + + if let image = try? await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config ) { windows[i].thumbnail = image } @@ -21,12 +35,22 @@ enum ThumbnailCapture { } /// Capture a single window thumbnail. - static func captureThumbnail(windowID: CGWindowID) -> CGImage? { - CGWindowListCreateImage( - .null, - .optionIncludingWindow, - windowID, - [.boundsIgnoreFraming, .bestResolution] + static func captureThumbnail(windowID: CGWindowID) async -> CGImage? { + guard let content = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true), + let scWindow = content.windows.first(where: { $0.windowID == windowID }) else { + return nil + } + + let filter = SCContentFilter(desktopIndependentWindow: scWindow) + let config = SCStreamConfiguration() + config.width = 320 + config.height = 240 + config.scalesToFit = true + config.showsCursor = false + + return try? await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config ) } } diff --git a/MCT/App/AppDelegate.swift b/MCT/App/AppDelegate.swift index 166661a..ad3522f 100644 --- a/MCT/App/AppDelegate.swift +++ b/MCT/App/AppDelegate.swift @@ -3,10 +3,12 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { private let overlayController = OverlayWindowController() private var hotKeyManager: HotKeyManager? + private var gestureMonitor: GestureMonitor? func applicationDidFinishLaunching(_ notification: Notification) { checkPermissions() setupHotKey() + setupGestureMonitor() } func toggleOverlay() { @@ -21,14 +23,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Private private func checkPermissions() { - // Check Accessibility + // Only trigger the system accessibility prompt once. + // After that, check silently — ad-hoc signed builds change identity + // on each rebuild, so AXIsProcessTrusted() may return false even + // though the user already granted permission to a previous build. if !AccessibilityBridge.hasPermission { - AccessibilityBridge.requestPermissionIfNeeded() + let hasPromptedBefore = UserDefaults.standard.bool(forKey: "hasPromptedAccessibility") + if !hasPromptedBefore { + AccessibilityBridge.requestPermissionIfNeeded() + UserDefaults.standard.set(true, forKey: "hasPromptedAccessibility") + } } // Screen Recording permission is checked implicitly when we first capture. - // CGWindowListCreateImage will return nil if not granted, and ScreenCaptureKit - // will prompt automatically. + // ScreenCaptureKit will prompt automatically if not granted. } private func setupHotKey() { @@ -41,4 +49,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { modifiers: prefs.hotKeyModifiers ) } + + private func setupGestureMonitor() { + gestureMonitor = GestureMonitor { [weak self] in + self?.toggleOverlay() + } + gestureMonitor?.start() + } } diff --git a/MCT/Layout/LayoutEngine.swift b/MCT/Layout/LayoutEngine.swift index 9191d1f..2c3d978 100644 --- a/MCT/Layout/LayoutEngine.swift +++ b/MCT/Layout/LayoutEngine.swift @@ -18,12 +18,24 @@ struct LayoutGroupHeader: Identifiable { var id: String { group.id } } +/// Per-group layout metadata for keyboard navigation. +struct GroupLayout { + let columns: Int + let thumbnailSize: CGSize + let itemRange: Range // range of indices into LayoutResult.items +} + /// Complete layout result for one overlay activation. struct LayoutResult { let items: [LayoutItem] let headers: [LayoutGroupHeader] - let thumbnailSize: CGSize + let groupLayouts: [GroupLayout] let totalHeight: CGFloat + let isCompactHeaders: Bool + + // Keep for backward compat + var thumbnailSize: CGSize { groupLayouts.first?.thumbnailSize ?? CGSize(width: 200, height: 150) } + var columns: Int { groupLayouts.first?.columns ?? 1 } } /// Core layout engine: grouping, grid placement, and stable reuse. @@ -32,16 +44,11 @@ final class LayoutEngine { /// Compute layout for a snapshot on the given screen bounds. func layout(snapshot: WindowSnapshot, screenBounds: CGRect) -> LayoutResult { - let availableSize = CGSize( - width: screenBounds.width - LayoutGeometry.margin * 2, - height: screenBounds.height - LayoutGeometry.margin * 2 - ) - - let thumbSize = LayoutGeometry.thumbnailSize( - itemCount: snapshot.totalWindowCount, - groupCount: snapshot.groups.count, - availableSize: CGSize(width: screenBounds.width, height: screenBounds.height) - ) + let availableWidth = screenBounds.width - LayoutGeometry.margin * 2 + let availableHeight = screenBounds.height - LayoutGeometry.margin * 2 + let groupCount = snapshot.groups.count + let isCompact = groupCount > LayoutGeometry.compactGroupThreshold + let headerH = LayoutGeometry.effectiveHeaderHeight(groupCount: groupCount) // Check stability against previous layout let shouldReuse: Bool = { @@ -55,34 +62,48 @@ final class LayoutEngine { return ratio > 0.7 }() + // Compute uniform thumbnail size + let groupWindowCounts = snapshot.groups.map { $0.windows.count } + let (thumbSize, cols) = LayoutGeometry.uniformThumbnailSize( + groupWindowCounts: groupWindowCounts, + availableWidth: availableWidth, + availableHeight: availableHeight + ) + + // Place items var items: [LayoutItem] = [] var headers: [LayoutGroupHeader] = [] + var groupLayouts: [GroupLayout] = [] - let cols = max(1, Int(availableSize.width / (thumbSize.width + LayoutGeometry.itemSpacing))) var y = screenBounds.origin.y + LayoutGeometry.margin for (groupIndex, group) in snapshot.groups.enumerated() { + // Center the group's grid horizontally + let windowsInRow = min(cols, group.windows.count) + let gridWidth = CGFloat(windowsInRow) * thumbSize.width + CGFloat(max(0, windowsInRow - 1)) * LayoutGeometry.itemSpacing + let xOffset = screenBounds.origin.x + LayoutGeometry.margin + (availableWidth - gridWidth) / 2 + // Group header let headerFrame = CGRect( x: screenBounds.origin.x + LayoutGeometry.margin, y: y, - width: availableSize.width, - height: LayoutGeometry.groupHeaderHeight + width: availableWidth, + height: headerH ) headers.append(LayoutGroupHeader(group: group, frame: headerFrame)) - y += LayoutGeometry.groupHeaderHeight + y += headerH + + let itemStart = items.count - // Lay out windows in rows for (windowIndex, window) in group.windows.enumerated() { let col = windowIndex % cols let row = windowIndex / cols - let x = screenBounds.origin.x + LayoutGeometry.margin + CGFloat(col) * (thumbSize.width + LayoutGeometry.itemSpacing) + let x = xOffset + CGFloat(col) * (thumbSize.width + LayoutGeometry.itemSpacing) let itemY = y + CGFloat(row) * (thumbSize.height + LayoutGeometry.itemSpacing) var frame = CGRect(x: x, y: itemY, width: thumbSize.width, height: thumbSize.height) - // If reusing stable positions, try to get the stored position if shouldReuse, let stored = layoutState.position(for: window.fingerprintKey) { frame = stored.absoluteRect(in: screenBounds) } @@ -90,8 +111,16 @@ final class LayoutEngine { items.append(LayoutItem(windowInfo: window, frame: frame, groupIndex: groupIndex)) } - let rowsInGroup = ceil(Double(group.windows.count) / Double(cols)) - y += CGFloat(rowsInGroup) * (thumbSize.height + LayoutGeometry.itemSpacing) + LayoutGeometry.groupSpacing + let rowsInGroup = Int(ceil(Double(group.windows.count) / Double(cols))) + let groupContentHeight = CGFloat(rowsInGroup) * thumbSize.height + CGFloat(max(0, rowsInGroup - 1)) * LayoutGeometry.itemSpacing + + groupLayouts.append(GroupLayout( + columns: cols, + thumbnailSize: thumbSize, + itemRange: itemStart.. CGSize { - guard itemCount > 0 else { return CGSize(width: 200, height: 150) } - - let availableWidth = availableSize.width - margin * 2 - let headerOverhead = CGFloat(groupCount) * (groupHeaderHeight + groupSpacing) - let availableHeight = availableSize.height - margin * 2 - headerOverhead - - // Try different column counts and pick the best fit - var bestSize = CGSize(width: minThumbnailWidth, height: minThumbnailWidth * 0.625) - - for cols in 1...max(1, Int(availableWidth / minThumbnailWidth)) { - let thumbWidth = (availableWidth - CGFloat(cols - 1) * itemSpacing) / CGFloat(cols) + static let margin: CGFloat = 24 + static let groupHeaderHeight: CGFloat = 28 + static let compactGroupHeaderHeight: CGFloat = 4 // thin separator for many groups + static let groupSpacing: CGFloat = 8 + static let itemSpacing: CGFloat = 8 + static let titleHeight: CGFloat = 22 + static let minThumbnailWidth: CGFloat = 80 + static let maxThumbnailWidth: CGFloat = 480 + + /// Threshold: when more groups than this, use compact headers. + static let compactGroupThreshold = 6 + + /// The effective header height based on group count. + static func effectiveHeaderHeight(groupCount: Int) -> CGFloat { + groupCount > compactGroupThreshold ? compactGroupHeaderHeight : groupHeaderHeight + } + + /// Calculate a uniform thumbnail size and column count that fits all groups on screen. + /// Each group uses the same thumbnail size but has its own row count. + static func uniformThumbnailSize( + groupWindowCounts: [Int], + availableWidth: CGFloat, + availableHeight: CGFloat + ) -> (size: CGSize, columns: Int) { + let groupCount = groupWindowCounts.count + guard groupCount > 0 else { return (CGSize(width: 200, height: 150), 1) } + + let headerH = effectiveHeaderHeight(groupCount: groupCount) + let headerOverhead = CGFloat(groupCount) * (headerH + groupSpacing) + let contentHeight = availableHeight - headerOverhead + + guard contentHeight > 0 else { + return (CGSize(width: minThumbnailWidth, height: minThumbnailWidth * 0.625 + titleHeight), 1) + } + + /// Total rows across all groups for a given column count. + func totalRows(cols: Int) -> Int { + groupWindowCounts.reduce(0) { $0 + Int(ceil(Double($1) / Double(cols))) } + } + + var bestWidth: CGFloat = 0 + var bestHeight: CGFloat = 0 + var bestCols = 1 + + let maxCols = max(1, Int((availableWidth + itemSpacing) / (minThumbnailWidth + itemSpacing))) + + // Try each column count and find the largest thumbnail that fits + for cols in 1...maxCols { + let thumbWidth = min(maxThumbnailWidth, (availableWidth - CGFloat(cols - 1) * itemSpacing) / CGFloat(cols)) guard thumbWidth >= minThumbnailWidth else { continue } - let clampedWidth = min(thumbWidth, maxThumbnailWidth) - let thumbHeight = clampedWidth * 0.625 + titleHeight // 16:10 aspect + title - let rows = ceil(Double(itemCount) / Double(cols)) - let totalHeight = rows * Double(thumbHeight + itemSpacing) + let thumbHeight = thumbWidth * 0.625 + titleHeight + let rows = totalRows(cols: cols) + let neededHeight = CGFloat(rows) * thumbHeight + CGFloat(max(0, rows - 1)) * itemSpacing - if totalHeight <= Double(availableHeight) && clampedWidth > bestSize.width { - bestSize = CGSize(width: clampedWidth, height: thumbHeight) + if neededHeight <= contentHeight && thumbWidth > bestWidth { + bestWidth = thumbWidth + bestHeight = thumbHeight + bestCols = cols } } - // If nothing fits well, scale down to fit - if bestSize.width == minThumbnailWidth { - let cols = max(1, Int(availableWidth / (minThumbnailWidth + itemSpacing))) - let thumbWidth = min(maxThumbnailWidth, (availableWidth - CGFloat(cols - 1) * itemSpacing) / CGFloat(cols)) - bestSize = CGSize(width: max(minThumbnailWidth, thumbWidth), height: max(minThumbnailWidth, thumbWidth) * 0.625 + titleHeight) + // If nothing fit with minThumbnailWidth, binary search below it + if bestWidth == 0 { + var lo: CGFloat = 20 + var hi = min(maxThumbnailWidth, availableWidth) + + for _ in 0..<40 { + let mid = (lo + hi) / 2 + let cols = max(1, Int((availableWidth + itemSpacing) / (mid + itemSpacing))) + let thumbHeight = mid * 0.625 + titleHeight + let rows = totalRows(cols: cols) + let neededHeight = CGFloat(rows) * thumbHeight + CGFloat(max(0, rows - 1)) * itemSpacing + + if neededHeight <= contentHeight { + bestWidth = mid + bestHeight = thumbHeight + bestCols = cols + lo = mid + } else { + hi = mid + } + } + + // Absolute fallback + if bestWidth == 0 { + bestCols = maxCols + bestWidth = max(20, (availableWidth - CGFloat(bestCols - 1) * itemSpacing) / CGFloat(bestCols)) + bestHeight = bestWidth * 0.625 + titleHeight + } } - return bestSize + return (CGSize(width: bestWidth, height: bestHeight), bestCols) } /// Fit a source rect into a target rect maintaining aspect ratio. diff --git a/MCT/UI/OverlayView.swift b/MCT/UI/OverlayView.swift index 3e62450..936aa5b 100644 --- a/MCT/UI/OverlayView.swift +++ b/MCT/UI/OverlayView.swift @@ -12,31 +12,37 @@ struct OverlayView: View { var body: some View { ZStack { - // Dimmed background - Color.black.opacity(appeared ? 0.4 : 0) + // Blurred + dimmed background + VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) + .opacity(appeared ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: appeared) + Color.black.opacity(appeared ? 0.25 : 0) .animation(.easeInOut(duration: 0.2), value: appeared) .onTapGesture { onDismiss() } - // Window thumbnails - ForEach(layout.headers) { header in - AppGroupHeaderView( - appName: header.group.appName, - appIcon: header.group.appIcon - ) - .frame(width: header.frame.width, height: header.frame.height) - .position( - x: header.frame.midX - screenBounds.origin.x, - y: header.frame.midY - screenBounds.origin.y - ) + // Group headers (hidden in compact mode) + if !layout.isCompactHeaders { + ForEach(layout.headers) { header in + AppGroupHeaderView( + appName: header.group.appName, + appIcon: header.group.appIcon + ) + .frame(width: header.frame.width, height: header.frame.height) + .position( + x: header.frame.midX - screenBounds.origin.x, + y: header.frame.midY - screenBounds.origin.y + ) + } } ForEach(Array(layout.items.enumerated()), id: \.element.id) { index, item in WindowThumbnailView( windowInfo: item.windowInfo, size: CGSize(width: item.frame.width, height: item.frame.height), - isSelected: selectedIndex == index + isSelected: selectedIndex == index, + showAppBadge: layout.isCompactHeaders ) .scaleEffect(appeared ? 1.0 : 0.9) .animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(index) * 0.02), value: appeared) @@ -68,34 +74,61 @@ struct OverlayView: View { private func handleArrow(_ direction: ArrowDirection) { let count = layout.items.count - guard count > 0 else { return } + let groupLayouts = layout.groupLayouts + guard count > 0, !groupLayouts.isEmpty else { return } if selectedIndex == nil { selectedIndex = 0 return } - guard var idx = selectedIndex else { 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: - idx = (idx - 1 + count) % count + if col > 0 { + selectedIndex = idx - 1 + } else if idx > 0 { + selectedIndex = idx - 1 + } case .right: - idx = (idx + 1) % count + if col < cols - 1 && localIdx + 1 < gl.itemRange.count { + selectedIndex = idx + 1 + } else if idx + 1 < count { + selectedIndex = idx + 1 + } case .up: - idx = max(0, idx - columnsInCurrentRow(for: idx)) + 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: - idx = min(count - 1, idx + columnsInCurrentRow(for: idx)) + 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 + } } - - selectedIndex = idx - } - - private func columnsInCurrentRow(for index: Int) -> Int { - // Estimate columns from layout geometry - let thumbWidth = layout.thumbnailSize.width + LayoutGeometry.itemSpacing - let availableWidth = screenBounds.width - LayoutGeometry.margin * 2 - return max(1, Int(availableWidth / thumbWidth)) } } @@ -114,9 +147,17 @@ struct KeyEventHandlingView: NSViewRepresentable { view.onEscape = onEscape view.onArrow = onArrow view.onReturn = onReturn - DispatchQueue.main.async { - view.window?.makeFirstResponder(view) + // 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 } @@ -153,3 +194,22 @@ final class KeyCaptureNSView: NSView { } } } + +/// 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 + } +} diff --git a/MCT/UI/OverlayWindowController.swift b/MCT/UI/OverlayWindowController.swift index b3f6f52..e131c53 100644 --- a/MCT/UI/OverlayWindowController.swift +++ b/MCT/UI/OverlayWindowController.swift @@ -76,6 +76,7 @@ final class OverlayWindowController { panel.contentView = NSHostingView(rootView: overlayView) panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() NSAnimationContext.runAnimationGroup { context in @@ -85,10 +86,16 @@ final class OverlayWindowController { panels.append(panel) } + + // Activate the app and ensure the first panel is key so it receives keyboard events + NSApp.activate(ignoringOtherApps: true) + if let firstPanel = panels.first { + firstPanel.makeKey() + } } private func createPanel(for screen: NSScreen) -> NSPanel { - let panel = NSPanel( + let panel = OverlayPanel( contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, @@ -102,6 +109,7 @@ final class OverlayWindowController { panel.hasShadow = false panel.hidesOnDeactivate = false panel.acceptsMouseMovedEvents = true + panel.ignoresMouseEvents = false return panel } @@ -113,3 +121,11 @@ final class OverlayWindowController { _ = AccessibilityBridge.activateWindow(pid: windowInfo.pid, windowID: windowInfo.windowID) } } + +// MARK: - Custom Panel + +/// NSPanel subclass that accepts key window status so keyboard events are delivered. +private final class OverlayPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} diff --git a/MCT/UI/WindowThumbnailView.swift b/MCT/UI/WindowThumbnailView.swift index 0971cde..d5dbd80 100644 --- a/MCT/UI/WindowThumbnailView.swift +++ b/MCT/UI/WindowThumbnailView.swift @@ -5,6 +5,7 @@ struct WindowThumbnailView: View { let windowInfo: WindowInfo let size: CGSize let isSelected: Bool + var showAppBadge: Bool = false private var thumbnailHeight: CGFloat { size.height - LayoutGeometry.titleHeight @@ -13,7 +14,7 @@ struct WindowThumbnailView: View { var body: some View { VStack(spacing: 0) { // Thumbnail - ZStack { + ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: 8) .fill(.ultraThinMaterial) @@ -25,8 +26,18 @@ struct WindowThumbnailView: View { .padding(2) } else { Image(systemName: "macwindow") - .font(.system(size: 32)) + .font(.system(size: min(32, size.width * 0.3))) .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // App icon badge in compact mode + if showAppBadge, let icon = windowInfo.appIcon { + Image(nsImage: icon) + .resizable() + .frame(width: min(20, size.width * 0.15), height: min(20, size.width * 0.15)) + .shadow(radius: 2) + .padding(4) } } .frame(width: size.width, height: thumbnailHeight) @@ -36,8 +47,8 @@ struct WindowThumbnailView: View { ) .shadow(color: .black.opacity(0.3), radius: isSelected ? 8 : 4) - // Always-visible title - Text(windowInfo.title) + // Always-visible title (includes app name in compact mode) + Text(displayTitle) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.white) .lineLimit(1) @@ -46,4 +57,11 @@ struct WindowThumbnailView: View { } .contentShape(Rectangle()) } + + private var displayTitle: String { + if showAppBadge { + return "\(windowInfo.appName) — \(windowInfo.title)" + } + return windowInfo.title + } } diff --git a/MCTTests/LayoutEngineTests.swift b/MCTTests/LayoutEngineTests.swift index 86c0582..1e3553c 100644 --- a/MCTTests/LayoutEngineTests.swift +++ b/MCTTests/LayoutEngineTests.swift @@ -162,13 +162,14 @@ final class LayoutEngineTests: XCTestCase { // MARK: - Geometry Tests - func testThumbnailSizeRespectsMinimum() { - let size = LayoutGeometry.thumbnailSize( - itemCount: 100, - groupCount: 5, - availableSize: CGSize(width: 1920, height: 1080) + func testThumbnailSizeFitsOnScreen() { + let (size, cols) = LayoutGeometry.uniformThumbnailSize( + groupWindowCounts: [4, 4, 4, 4, 4], + availableWidth: 1920 - LayoutGeometry.margin * 2, + availableHeight: 1080 - LayoutGeometry.margin * 2 ) - XCTAssertGreaterThanOrEqual(size.width, LayoutGeometry.minThumbnailWidth) + XCTAssertGreaterThan(size.width, 0) + XCTAssertGreaterThan(cols, 0) } func testAspectFit() { diff --git a/build.sh b/build.sh index b556f78..7ad3f25 100755 --- a/build.sh +++ b/build.sh @@ -6,6 +6,10 @@ SDK=$(xcrun --show-sdk-path) echo "Building MCT..." +# Kill running instance if any +pkill -x MCT 2>/dev/null || true +sleep 0.3 + # Create bundle structure mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" @@ -26,6 +30,7 @@ swiftc \ MCT/API/ThumbnailCapture.swift \ MCT/API/AccessibilityBridge.swift \ MCT/API/HotKeyManager.swift \ + MCT/API/GestureMonitor.swift \ MCT/Layout/LayoutGeometry.swift \ MCT/Layout/LayoutEngine.swift \ MCT/UI/OverlayWindowController.swift \ @@ -52,7 +57,7 @@ cat > "$APP_DIR/Contents/Info.plist" << 'PLIST' CFBundleInfoDictionaryVersion 6.0 CFBundleName - MCT + Mission Control Turbo CFBundleDisplayName Mission Control Turbo CFBundlePackageType @@ -77,5 +82,8 @@ PLIST echo -n "APPL????" > "$APP_DIR/Contents/PkgInfo" +# Ad-hoc sign so macOS recognizes the app for permission grants +codesign --force --sign "Pierre Le Fevre" "$APP_DIR" + echo "Built: $APP_DIR" -echo "Run with: open $APP_DIR" +open "$APP_DIR" diff --git a/test_layout.swift b/test_layout.swift new file mode 100644 index 0000000..9802a7e --- /dev/null +++ b/test_layout.swift @@ -0,0 +1,391 @@ +#!/usr/bin/env swift +// Layout test harness — compiles standalone, no Xcode needed. +// Usage: swift test_layout.swift + +import Foundation +import CoreGraphics + +// ─── Minimal model types ─────────────────────────────────────────────── + +struct WindowInfo { + let windowID: CGWindowID + let title: String + let appName: String + let appBundleID: String + let frame: CGRect + let pid: pid_t + let isOnScreen: Bool + var thumbnail: CGImage? = nil + var appIcon: Any? = nil // NSImage not available without AppKit in script mode + + var id: CGWindowID { windowID } + var fingerprintKey: String { "\(appBundleID)|\(title)" } +} + +struct WindowGroup: Identifiable { + let appBundleID: String + let appName: String + var windows: [WindowInfo] + var frontmostIndex: Int = Int.max + var id: String { appBundleID } + var appIcon: Any? { nil } +} + +struct WindowSnapshot { + let groups: [WindowGroup] + let fingerprint: Set + let totalWindowCount: Int + + init(groups: [WindowGroup]) { + self.groups = groups + self.totalWindowCount = groups.reduce(0) { $0 + $1.windows.count } + var fp = Set() + for group in groups { + for window in group.windows { + fp.insert(window.fingerprintKey) + } + } + self.fingerprint = fp + } +} + +// ─── Copy of LayoutGeometry ──────────────────────────────────────────── + +enum LayoutGeometry { + static let margin: CGFloat = 24 + static let groupHeaderHeight: CGFloat = 28 + static let compactGroupHeaderHeight: CGFloat = 4 + static let groupSpacing: CGFloat = 8 + static let itemSpacing: CGFloat = 8 + static let titleHeight: CGFloat = 22 + static let minThumbnailWidth: CGFloat = 80 + static let maxThumbnailWidth: CGFloat = 480 + static let compactGroupThreshold = 6 + + static func effectiveHeaderHeight(groupCount: Int) -> CGFloat { + groupCount > compactGroupThreshold ? compactGroupHeaderHeight : groupHeaderHeight + } + + static func uniformThumbnailSize( + groupWindowCounts: [Int], + availableWidth: CGFloat, + availableHeight: CGFloat + ) -> (size: CGSize, columns: Int) { + let groupCount = groupWindowCounts.count + guard groupCount > 0 else { return (CGSize(width: 200, height: 150), 1) } + + let headerH = effectiveHeaderHeight(groupCount: groupCount) + let headerOverhead = CGFloat(groupCount) * (headerH + groupSpacing) + let contentHeight = availableHeight - headerOverhead + + guard contentHeight > 0 else { + return (CGSize(width: minThumbnailWidth, height: minThumbnailWidth * 0.625 + titleHeight), 1) + } + + func totalRows(cols: Int) -> Int { + groupWindowCounts.reduce(0) { $0 + Int(ceil(Double($1) / Double(cols))) } + } + + var bestWidth: CGFloat = 0 + var bestHeight: CGFloat = 0 + var bestCols = 1 + + let maxCols = max(1, Int((availableWidth + itemSpacing) / (minThumbnailWidth + itemSpacing))) + + for cols in 1...maxCols { + let thumbWidth = min(maxThumbnailWidth, (availableWidth - CGFloat(cols - 1) * itemSpacing) / CGFloat(cols)) + guard thumbWidth >= minThumbnailWidth else { continue } + let thumbHeight = thumbWidth * 0.625 + titleHeight + let rows = totalRows(cols: cols) + let neededHeight = CGFloat(rows) * thumbHeight + CGFloat(max(0, rows - 1)) * itemSpacing + + if neededHeight <= contentHeight && thumbWidth > bestWidth { + bestWidth = thumbWidth + bestHeight = thumbHeight + bestCols = cols + } + } + + if bestWidth == 0 { + var lo: CGFloat = 20 + var hi = min(maxThumbnailWidth, availableWidth) + + for _ in 0..<40 { + let mid = (lo + hi) / 2 + let cols = max(1, Int((availableWidth + itemSpacing) / (mid + itemSpacing))) + let thumbHeight = mid * 0.625 + titleHeight + let rows = totalRows(cols: cols) + let neededHeight = CGFloat(rows) * thumbHeight + CGFloat(max(0, rows - 1)) * itemSpacing + + if neededHeight <= contentHeight { + bestWidth = mid + bestHeight = thumbHeight + bestCols = cols + lo = mid + } else { + hi = mid + } + } + + if bestWidth == 0 { + bestCols = maxCols + bestWidth = max(20, (availableWidth - CGFloat(bestCols - 1) * itemSpacing) / CGFloat(bestCols)) + bestHeight = bestWidth * 0.625 + titleHeight + } + } + + return (CGSize(width: bestWidth, height: bestHeight), bestCols) + } +} + +// ─── Simplified layout engine (no stability/reuse) ───────────────────── + +struct GroupLayout { + let columns: Int + let thumbnailSize: CGSize + let itemRange: Range +} + +struct LayoutItem { + let title: String + let appName: String + let frame: CGRect + let groupIndex: Int +} + +struct LayoutGroupHeader { + let appName: String + let frame: CGRect +} + +struct LayoutResult { + let items: [LayoutItem] + let headers: [LayoutGroupHeader] + let groupLayouts: [GroupLayout] + let totalHeight: CGFloat + let isCompactHeaders: Bool +} + +func computeLayout(snapshot: WindowSnapshot, screenBounds: CGRect) -> LayoutResult { + let availableWidth = screenBounds.width - LayoutGeometry.margin * 2 + let availableHeight = screenBounds.height - LayoutGeometry.margin * 2 + let groupCount = snapshot.groups.count + let isCompact = groupCount > LayoutGeometry.compactGroupThreshold + let headerH = LayoutGeometry.effectiveHeaderHeight(groupCount: groupCount) + + let groupWindowCounts = snapshot.groups.map { $0.windows.count } + let (thumbSize, cols) = LayoutGeometry.uniformThumbnailSize( + groupWindowCounts: groupWindowCounts, + availableWidth: availableWidth, + availableHeight: availableHeight + ) + + var items: [LayoutItem] = [] + var headers: [LayoutGroupHeader] = [] + var groupLayouts: [GroupLayout] = [] + + var y = screenBounds.origin.y + LayoutGeometry.margin + + for (groupIndex, group) in snapshot.groups.enumerated() { + let windowsInRow = min(cols, group.windows.count) + let gridWidth = CGFloat(windowsInRow) * thumbSize.width + CGFloat(max(0, windowsInRow - 1)) * LayoutGeometry.itemSpacing + let xOffset = screenBounds.origin.x + LayoutGeometry.margin + (availableWidth - gridWidth) / 2 + + let headerFrame = CGRect( + x: screenBounds.origin.x + LayoutGeometry.margin, + y: y, + width: availableWidth, + height: headerH + ) + headers.append(LayoutGroupHeader(appName: group.appName, frame: headerFrame)) + y += headerH + + let itemStart = items.count + + for (windowIndex, window) in group.windows.enumerated() { + let col = windowIndex % cols + let row = windowIndex / cols + + let x = xOffset + CGFloat(col) * (thumbSize.width + LayoutGeometry.itemSpacing) + let itemY = y + CGFloat(row) * (thumbSize.height + LayoutGeometry.itemSpacing) + + let frame = CGRect(x: x, y: itemY, width: thumbSize.width, height: thumbSize.height) + items.append(LayoutItem(title: window.title, appName: window.appName, frame: frame, groupIndex: groupIndex)) + } + + let rowsInGroup = Int(ceil(Double(group.windows.count) / Double(cols))) + let groupContentHeight = CGFloat(rowsInGroup) * thumbSize.height + CGFloat(max(0, rowsInGroup - 1)) * LayoutGeometry.itemSpacing + + groupLayouts.append(GroupLayout( + columns: cols, + thumbnailSize: thumbSize, + itemRange: itemStart.. WindowInfo { + WindowInfo( + windowID: CGWindowID(id), + title: title, + appName: appName, + appBundleID: bundleID, + frame: CGRect(x: 0, y: 0, width: 800, height: 600), + pid: pid_t(id), + isOnScreen: true + ) +} + +func groupWindows(_ windows: [WindowInfo]) -> [WindowGroup] { + var dict: [String: WindowGroup] = [:] + for (i, w) in windows.enumerated() { + if dict[w.appBundleID] == nil { + dict[w.appBundleID] = WindowGroup(appBundleID: w.appBundleID, appName: w.appName, windows: []) + } + dict[w.appBundleID]!.windows.append(w) + dict[w.appBundleID]!.frontmostIndex = min(dict[w.appBundleID]!.frontmostIndex, i) + } + return dict.values.sorted { $0.frontmostIndex < $1.frontmostIndex } +} + +func printResult(_ result: LayoutResult, screenBounds: CGRect, label: String) { + print("═══════════════════════════════════════════════════════════════") + print("TEST: \(label)") + print("Screen: \(Int(screenBounds.width))×\(Int(screenBounds.height))") + print("Total height used: \(Int(result.totalHeight)) / \(Int(screenBounds.height))") + print("Compact headers: \(result.isCompactHeaders)") + print("") + + var allFit = true + + for (gi, gl) in result.groupLayouts.enumerated() { + let header = result.headers[gi] + print(" Group \(gi): \(header.appName)") + print(" Header at y=\(Int(header.frame.minY)), h=\(Int(header.frame.height))") + print(" Thumbnail: \(Int(gl.thumbnailSize.width))×\(Int(gl.thumbnailSize.height)), cols=\(gl.columns)") + + for idx in gl.itemRange { + let item = result.items[idx] + let f = item.frame + let withinScreen = f.minX >= screenBounds.minX && f.minY >= screenBounds.minY && + f.maxX <= screenBounds.maxX && f.maxY <= screenBounds.maxY + let marker = withinScreen ? "✓" : "✗ OFF-SCREEN" + if !withinScreen { allFit = false } + print(" [\(idx)] \"\(item.title)\" @ (\(Int(f.minX)),\(Int(f.minY))) \(Int(f.width))×\(Int(f.height)) \(marker)") + } + } + + // Check uniform sizing within each group + for (gi, gl) in result.groupLayouts.enumerated() { + let sizes = Set(gl.itemRange.map { idx -> String in + let f = result.items[idx].frame + return "\(Int(f.width))×\(Int(f.height))" + }) + if sizes.count > 1 { + print(" ⚠️ Group \(gi) has NON-UNIFORM sizes: \(sizes)") + } + } + + // Check sizes are consistent across groups (ideally similar or justified) + let allSizes = result.groupLayouts.map { "\(Int($0.thumbnailSize.width))×\(Int($0.thumbnailSize.height))" } + let uniqueSizes = Set(allSizes) + if uniqueSizes.count > 1 { + print("\n ℹ️ Different thumbnail sizes across groups: \(uniqueSizes)") + let widths = result.groupLayouts.map { $0.thumbnailSize.width } + let ratio = (widths.max() ?? 1) / max(widths.min() ?? 1, 1) + if ratio > 3.0 { + print(" ⚠️ Size ratio \(String(format: "%.1f", ratio))x between largest and smallest — may look unbalanced") + } + } + + print("\n All items on screen: \(allFit ? "✓ YES" : "✗ NO")") + print("") +} + +// ─── Run tests ───────────────────────────────────────────────────────── + +let screen1080 = CGRect(x: 0, y: 0, width: 1920, height: 1080) +let screen1440 = CGRect(x: 0, y: 0, width: 2560, height: 1440) +let screenLaptop = CGRect(x: 0, y: 0, width: 1470, height: 956) // 14" MacBook Pro effective + +// Test 1: Few windows, few apps +do { + let windows = [ + makeWindow(id: 1, title: "Document", appName: "Safari", bundleID: "com.apple.Safari"), + makeWindow(id: 2, title: "Gmail", appName: "Safari", bundleID: "com.apple.Safari"), + makeWindow(id: 3, title: "Main.swift", appName: "Xcode", bundleID: "com.apple.dt.Xcode"), + ] + let groups = groupWindows(windows) + let snapshot = WindowSnapshot(groups: groups) + let result = computeLayout(snapshot: snapshot, screenBounds: screen1080) + printResult(result, screenBounds: screen1080, label: "3 windows, 2 apps — 1080p") +} + +// Test 2: Many windows, many apps +do { + var windows: [WindowInfo] = [] + var id = 1 + for app in ["Safari", "Xcode", "Terminal", "Finder", "Slack", "Discord", "Notes", "Mail"] { + let bundleID = "com.test.\(app.lowercased())" + let count = app == "Safari" ? 5 : (app == "Xcode" ? 3 : (app == "Terminal" ? 4 : 1)) + for w in 0..