diff --git a/.gitignore b/.gitignore index f72bcb5..69b64f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Build output build/ +# Local config +.env + # Xcode user-specific files *.xcuserstate xcuserdata/ diff --git a/MCT/API/AccessibilityBridge.swift b/MCT/API/AccessibilityBridge.swift index 61ac2e7..2ee7160 100644 --- a/MCT/API/AccessibilityBridge.swift +++ b/MCT/API/AccessibilityBridge.swift @@ -60,6 +60,27 @@ enum AccessibilityBridge { return nil } + /// Move a window to a new position using AXUIElement. + /// The point should be in Quartz coordinates (top-left origin). + static func moveWindow(pid: pid_t, windowID: CGWindowID, to point: CGPoint) -> Bool { + let appElement = AXUIElementCreateApplication(pid) + var windowsRef: CFTypeRef? + let result = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) + guard result == .success, let axWindows = windowsRef as? [AXUIElement] else { + return false + } + + for axWindow in axWindows { + if let cgWinID = axWindowID(for: axWindow), cgWinID == windowID { + var position = point + guard let axValue = AXValueCreate(.cgPoint, &position) else { return false } + let setResult = AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, axValue) + return setResult == .success + } + } + return false + } + // MARK: - Private /// Map an AXUIElement to a CGWindowID using the private _AXUIElementGetWindow API. diff --git a/MCT/API/ThumbnailCapture.swift b/MCT/API/ThumbnailCapture.swift index a238ae2..46239a5 100644 --- a/MCT/API/ThumbnailCapture.swift +++ b/MCT/API/ThumbnailCapture.swift @@ -3,11 +3,22 @@ import ScreenCaptureKit /// Captures window thumbnails using 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 { + /// Max thumbnail dimension — large enough for sharp display at overlay sizes, + /// small enough for fast capture. 800px wide covers most overlay thumbnail widths at 2x. + private static let maxDimension: Int = 800 + + /// Pre-warm ScreenCaptureKit so the first real capture is fast. + static func warmUp() async { + _ = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + } + + /// Capture thumbnails for all windows in parallel. + static func captureThumbnails(for windows: inout [WindowInfo]) async { + let t0 = CFAbsoluteTimeGetCurrent() guard let content = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) else { return } + let t1 = CFAbsoluteTimeGetCurrent() // Build a lookup from windowID to SCWindow var scWindowsByID: [CGWindowID: SCWindow] = [:] @@ -15,42 +26,52 @@ enum ThumbnailCapture { scWindowsByID[scWindow.windowID] = scWindow } - for i in windows.indices { - 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 + // Capture all windows in parallel + let captures: [(Int, CGImage)] = await withTaskGroup(of: (Int, CGImage?).self) { group in + for i in windows.indices { + guard let scWindow = scWindowsByID[windows[i].windowID] else { continue } + let windowFrame = windows[i].frame + let idx = i + + group.addTask { + let filter = SCContentFilter(desktopIndependentWindow: scWindow) + let config = SCStreamConfiguration() + + // Scale down to maxDimension while preserving aspect ratio + let aspect = windowFrame.width / max(windowFrame.height, 1) + if aspect >= 1 { + config.width = maxDimension + config.height = max(1, Int(Double(maxDimension) / aspect)) + } else { + config.height = maxDimension + config.width = max(1, Int(Double(maxDimension) * aspect)) + } + config.scalesToFit = true + config.showsCursor = false + + let image = try? await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config + ) + return (idx, image) + } + } + + var results: [(Int, CGImage)] = [] + for await (idx, image) in group { + if let image = image { + results.append((idx, image)) + } } + return results } - } - /// Capture a single window thumbnail. - 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 + for (idx, image) in captures { + windows[idx].thumbnail = image } - 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 - ) + let t2 = CFAbsoluteTimeGetCurrent() + mctLog("[MCT] capture: shareable=%.0fms parallel=%.0fms total=%.0fms (%d windows)", + (t1 - t0) * 1000, (t2 - t1) * 1000, (t2 - t0) * 1000, captures.count) } } diff --git a/MCT/API/WindowEnumerator.swift b/MCT/API/WindowEnumerator.swift index 0be92cf..d6ba152 100644 --- a/MCT/API/WindowEnumerator.swift +++ b/MCT/API/WindowEnumerator.swift @@ -74,7 +74,14 @@ enum WindowEnumerator { } } - // Sort alphabetically by app name for stable ordering across activations + // Sort windows within each group by title for stable ordering + for key in groupMap.keys { + groupMap[key]!.windows.sort { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + } + + // Sort groups alphabetically by app name return groupMap.values.sorted { a, b in a.appName.localizedCaseInsensitiveCompare(b.appName) == .orderedAscending } diff --git a/MCT/App/AppDelegate.swift b/MCT/App/AppDelegate.swift index f91a385..a906200 100644 --- a/MCT/App/AppDelegate.swift +++ b/MCT/App/AppDelegate.swift @@ -1,4 +1,19 @@ import AppKit +import SwiftUI + +nonisolated func mctLog(_ format: String, _ args: CVarArg...) { + let msg = String(format: format, arguments: args) + let line = "\(msg)\n" + // Write to both stderr and file + fputs(line, stderr) + guard let data = line.data(using: .utf8) else { return } + let path = "/tmp/mct_debug.log" + let fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0o644) + if fd >= 0 { + _ = data.withUnsafeBytes { write(fd, $0.baseAddress!, $0.count) } + close(fd) + } +} @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { @@ -6,10 +21,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private lazy var overlayController = OverlayWindowController(gestureProgress: gestureProgressModel) private var hotKeyManager: HotKeyManager? private var gestureMonitor: GestureMonitor? + private var settingsWindow: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { + mctLog("[MCT] app launched") checkPermissions() setupHotKey() + Task { await ThumbnailCapture.warmUp() } setupGestureMonitor() } @@ -18,8 +36,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func showSettings() { - NSApp.activate() - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + if let window = settingsWindow { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 450, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "MCT Settings" + window.contentView = NSHostingView(rootView: SettingsView()) + window.center() + window.isReleasedWhenClosed = false + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + settingsWindow = window } // MARK: - Private diff --git a/MCT/App/MCTApp.swift b/MCT/App/MCTApp.swift index 552643e..5159df3 100644 --- a/MCT/App/MCTApp.swift +++ b/MCT/App/MCTApp.swift @@ -26,8 +26,5 @@ struct MCTApp: App { .keyboardShortcut("q", modifiers: [.command]) } - Settings { - SettingsView() - } } } diff --git a/MCT/Layout/LayoutEngine.swift b/MCT/Layout/LayoutEngine.swift index b3ca8da..2132141 100644 --- a/MCT/Layout/LayoutEngine.swift +++ b/MCT/Layout/LayoutEngine.swift @@ -32,23 +32,23 @@ struct LayoutResult { 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. +/// Core layout engine: flat grid placement maximizing thumbnail size. final class LayoutEngine { private let layoutState = LayoutState() /// Compute layout for a snapshot on the given screen bounds. + /// Uses a flat grid — all windows in one grid, sorted by app, no per-app rows. func layout(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) + + // Flatten all windows in group order (already sorted by app name) + let allWindows = snapshot.groups.flatMap { $0.windows } // Check stability against previous layout let shouldReuse: Bool = { @@ -58,51 +58,52 @@ final class LayoutEngine { let intersection = currentKeys.intersection(previousKeys) let union = currentKeys.union(previousKeys) guard !union.isEmpty else { return false } - let ratio = Double(intersection.count) / Double(union.count) - return ratio > 0.7 + return Double(intersection.count) / Double(union.count) > 0.7 }() - // Compute uniform thumbnail size - let groupWindowCounts = snapshot.groups.map { $0.windows.count } - let (thumbSize, cols) = LayoutGeometry.uniformThumbnailSize( - groupWindowCounts: groupWindowCounts, + // Compute optimal grid + let (thumbWidth, cols) = LayoutGeometry.optimalGrid( + windows: allWindows, availableWidth: availableWidth, availableHeight: availableHeight ) - // Place items + // Place all items in a flat grid var items: [LayoutItem] = [] - var headers: [LayoutGroupHeader] = [] - var groupLayouts: [GroupLayout] = [] - 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 + let rowCount = Int(ceil(Double(allWindows.count) / Double(cols))) + for row in 0.. 0 ? w.frame.height / w.frame.width : 0.625 + let cellH = thumbWidth * ratio + LayoutGeometry.titleHeight + maxRowH = max(maxRowH, cellH) + } - // Group header - let headerFrame = CGRect( - x: screenBounds.origin.x + LayoutGeometry.margin, - y: y, - width: availableWidth, - height: headerH - ) - headers.append(LayoutGroupHeader(group: group, frame: headerFrame)) - y += headerH + // Center this row's items horizontally + let windowsInRow = rowEnd - rowStart + let gridWidth = CGFloat(windowsInRow) * thumbWidth + CGFloat(max(0, windowsInRow - 1)) * LayoutGeometry.itemSpacing + let xOffset = screenBounds.origin.x + LayoutGeometry.margin + (availableWidth - gridWidth) / 2 - let itemStart = items.count + for idx in rowStart.. 0 ? window.frame.height / window.frame.width : 0.625 + let cellH = thumbWidth * ratio + LayoutGeometry.titleHeight - for (windowIndex, window) in group.windows.enumerated() { - let col = windowIndex % cols - let row = windowIndex / cols + let x = xOffset + CGFloat(col) * (thumbWidth + LayoutGeometry.itemSpacing) + let cellY = y + (maxRowH - cellH) / 2 - let x = xOffset + CGFloat(col) * (thumbSize.width + LayoutGeometry.itemSpacing) - let itemY = y + CGFloat(row) * (thumbSize.height + LayoutGeometry.itemSpacing) + // Find group index for this window + let groupIndex = snapshot.groups.firstIndex(where: { $0.appBundleID == window.appBundleID }) ?? 0 - var frame = CGRect(x: x, y: itemY, width: thumbSize.width, height: thumbSize.height) + var frame = CGRect(x: x, y: cellY, width: thumbWidth, height: cellH) if shouldReuse, let stored = layoutState.position(for: window.fingerprintKey) { frame = stored.absoluteRect(in: screenBounds) @@ -111,22 +112,13 @@ final class LayoutEngine { items.append(LayoutItem(windowInfo: window, 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.. 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], + static let margin: CGFloat = 40 + static let itemSpacing: CGFloat = 16 + static let titleHeight: CGFloat = 36 + static let minThumbnailWidth: CGFloat = 120 + + /// Find the optimal column count and thumbnail width for a flat grid of windows. + /// Tries each column count and picks the one that produces the largest thumbnails + /// while fitting everything on screen. + static func optimalGrid( + windows: [WindowInfo], availableWidth: CGFloat, availableHeight: CGFloat - ) -> (size: CGSize, columns: Int) { - let groupCount = groupWindowCounts.count - guard groupCount > 0 else { return (CGSize(width: 200, height: 150), 1) } + ) -> (width: CGFloat, columns: Int) { + let count = windows.count + guard count > 0 else { return (200, 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))) } - } + let maxCols = max(1, Int((availableWidth + itemSpacing) / (minThumbnailWidth + itemSpacing))) 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)) + let thumbWidth = (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 { + let needed = totalHeight(windows: windows, thumbWidth: thumbWidth, cols: cols) + if needed <= availableHeight && thumbWidth > bestWidth { bestWidth = thumbWidth - bestHeight = thumbHeight bestCols = cols } } - // If nothing fit with minThumbnailWidth, binary search below it + // Binary search fallback if nothing fit if bestWidth == 0 { var lo: CGFloat = 20 - var hi = min(maxThumbnailWidth, availableWidth) + var hi = 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 + let needed = totalHeight(windows: windows, thumbWidth: mid, cols: cols) - if neededHeight <= contentHeight { + if needed <= availableHeight { bestWidth = mid - bestHeight = thumbHeight bestCols = cols lo = mid } else { @@ -85,15 +53,35 @@ enum LayoutGeometry { } } - // Absolute fallback if bestWidth == 0 { - bestCols = maxCols + bestCols = max(1, Int(ceil(sqrt(Double(count))))) bestWidth = max(20, (availableWidth - CGFloat(bestCols - 1) * itemSpacing) / CGFloat(bestCols)) - bestHeight = bestWidth * 0.625 + titleHeight } } - return (CGSize(width: bestWidth, height: bestHeight), bestCols) + return (bestWidth, bestCols) + } + + /// Compute total height for a flat grid layout. + static func totalHeight(windows: [WindowInfo], thumbWidth: CGFloat, cols: Int) -> CGFloat { + let rowCount = Int(ceil(Double(windows.count) / Double(cols))) + var height: CGFloat = 0 + + for row in 0.. 0 ? w.frame.height / w.frame.width : 0.625 + let cellH = thumbWidth * ratio + titleHeight + maxRowH = max(maxRowH, cellH) + } + height += maxRowH + if row < rowCount - 1 { height += itemSpacing } + } + + return height } /// Fit a source rect into a target rect maintaining aspect ratio. diff --git a/MCT/Model/GestureProgress.swift b/MCT/Model/GestureProgress.swift index 9711b91..9658e65 100644 --- a/MCT/Model/GestureProgress.swift +++ b/MCT/Model/GestureProgress.swift @@ -11,6 +11,12 @@ enum GesturePhase { case dismissing } +struct DragState { + let windowInfo: WindowInfo + let sourceScreenKey: ScreenKey + var currentScreenPosition: CGPoint // global AppKit screen coords +} + @MainActor final class GestureProgress: ObservableObject { @Published var phase: GesturePhase = .idle @@ -18,6 +24,8 @@ final class GestureProgress: ObservableObject { @Published var velocity: CGFloat = 0.0 @Published var thumbnailsReady: Bool = false @Published var layouts: [ScreenKey: LayoutResult] = [:] + @Published var thumbnailGeneration: Int = 0 + @Published var dragState: DragState? = nil } /// Hashable wrapper for CGRect to use as dictionary key for per-screen layouts. diff --git a/MCT/Settings/SettingsView.swift b/MCT/Settings/SettingsView.swift index ab7cadb..5b84632 100644 --- a/MCT/Settings/SettingsView.swift +++ b/MCT/Settings/SettingsView.swift @@ -46,6 +46,9 @@ struct SettingsView: View { LabeledContent("Version") { Text("1.0") } + LabeledContent("Source") { + Link("tangled.org/pierrelf.com/mct", destination: URL(string: "https://tangled.org/pierrelf.com/mct")!) + } Text("Mission Control Turbo") .font(.headline) } diff --git a/MCT/UI/AppGroupHeaderView.swift b/MCT/UI/AppGroupHeaderView.swift index 9d42e9f..fa8b850 100644 --- a/MCT/UI/AppGroupHeaderView.swift +++ b/MCT/UI/AppGroupHeaderView.swift @@ -11,11 +11,11 @@ struct AppGroupHeaderView: View { if let icon = appIcon { Image(nsImage: icon) .resizable() - .frame(width: 20, height: 20) + .frame(width: 28, height: 28) } Text(appName) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.white) Spacer() diff --git a/MCT/UI/OverlayView.swift b/MCT/UI/OverlayView.swift index 1714815..58185ec 100644 --- a/MCT/UI/OverlayView.swift +++ b/MCT/UI/OverlayView.swift @@ -6,12 +6,19 @@ struct OverlayView: View { 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 } + 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)] @@ -24,30 +31,16 @@ struct OverlayView: View { .opacity(progress) Color.black.opacity(0.25 * progress) - .contentShape(Rectangle()) - .onTapGesture { - if isInteractive { - onDismiss() + .overlay( + ClickHandlerView { + if isInteractive { + onDismiss() + } } - } + ) if let layout = layout { - // 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 - ) - .opacity(progress) - } - } - + 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))) @@ -62,8 +55,16 @@ struct OverlayView: View { .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 { + if isInteractive && gestureProgress.dragState == nil { hoveredIndex = hovering ? index : nil if hovering { selectedIndex = nil @@ -73,11 +74,35 @@ struct OverlayView: View { } } } - .onTapGesture { - if isInteractive { - onSelect(item.windowInfo) - } - } + .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 @@ -94,7 +119,11 @@ struct OverlayView: View { .background( KeyEventHandlingView( onEscape: { - if isInteractive { onDismiss() } + if gestureProgress.dragState != nil { + gestureProgress.dragState = nil + } else if isInteractive { + onDismiss() + } }, onArrow: { direction in if isInteractive { @@ -279,6 +308,92 @@ final class KeyCaptureNSView: NSView { } } +/// 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 diff --git a/MCT/UI/OverlayWindowController.swift b/MCT/UI/OverlayWindowController.swift index 7fcec6f..6a35bd1 100644 --- a/MCT/UI/OverlayWindowController.swift +++ b/MCT/UI/OverlayWindowController.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI /// Manages the full-screen overlay NSPanel for each display with @@ -19,11 +20,18 @@ final class OverlayWindowController { private var state: State = .idle private var panels: [NSPanel] = [] - private let layoutEngine = LayoutEngine() + 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)? @@ -43,11 +51,13 @@ final class OverlayWindowController { 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 @@ -68,13 +78,19 @@ final class OverlayWindowController { createPanels(interactive: false) setProgress(0.0, phase: .tracking) - // Kick off async thumbnail capture + // 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 windows = WindowEnumerator.enumerateWindows() - await ThumbnailCapture.captureThumbnails(for: &windows) - let captured = windows + 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) } } @@ -154,6 +170,7 @@ final class OverlayWindowController { // 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 @@ -163,29 +180,60 @@ final class OverlayWindowController { captureGeneration += 1 let gen = captureGeneration - createPanels(interactive: false) + createPanels(interactive: true) setProgress(0.0, phase: .committing) - // Kick off async thumbnail capture + 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 windows = WindowEnumerator.enumerateWindows() - await ThumbnailCapture.captureThumbnails(for: &windows) - let captured = windows + 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) + animateToCommit(from: 0, velocity: 0, stiffness: 800.0) case .shown: - animateToDismiss(from: 1.0, velocity: 0) + 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: - // If in the middle of a gesture, treat as dismiss let current = gestureProgress.progress - animateToDismiss(from: current, velocity: 0) + animateToDismiss(from: current, velocity: 0, stiffness: 800.0) } } @@ -193,18 +241,18 @@ final class OverlayWindowController { func dismiss() { switch state { case .shown: - animateToDismiss(from: 1.0, velocity: 0) + animateToDismiss(from: 1.0, velocity: 0, stiffness: 800.0) case .idle: break default: let current = gestureProgress.progress - animateToDismiss(from: current, velocity: 0) + animateToDismiss(from: current, velocity: 0, stiffness: 800.0) } } // MARK: - Spring animations - private func animateToCommit(from current: CGFloat, velocity: CGFloat) { + private func animateToCommit(from current: CGFloat, velocity: CGFloat, stiffness: CGFloat = 300.0) { state = .committing gestureProgress.phase = .committing springAnimator?.stop() @@ -212,6 +260,7 @@ final class OverlayWindowController { from: current, to: 1.0, initialVelocity: velocity, + stiffness: stiffness, onUpdate: { [weak self] value in self?.gestureProgress.progress = value }, @@ -240,7 +289,7 @@ final class OverlayWindowController { springAnimator?.start() } - private func animateToDismiss(from current: CGFloat, velocity: CGFloat) { + private func animateToDismiss(from current: CGFloat, velocity: CGFloat, stiffness: CGFloat = 300.0) { state = .dismissing gestureProgress.phase = .dismissing springAnimator?.stop() @@ -248,6 +297,7 @@ final class OverlayWindowController { from: current, to: 0.0, initialVelocity: velocity, + stiffness: stiffness, onUpdate: { [weak self] value in self?.gestureProgress.progress = value }, @@ -297,7 +347,10 @@ final class OverlayWindowController { gestureProgress.phase = .idle gestureProgress.thumbnailsReady = false gestureProgress.layouts.removeAll() - layoutEngine.invalidate() + gestureProgress.dragState = nil + layoutEngines.values.forEach { $0.invalidate() } + layoutEngines.removeAll() + removeDragFloatingWindow() removePanels() } @@ -307,7 +360,10 @@ final class OverlayWindowController { gestureProgress.phase = .idle gestureProgress.thumbnailsReady = false gestureProgress.layouts.removeAll() - layoutEngine.invalidate() + gestureProgress.dragState = nil + layoutEngines.values.forEach { $0.invalidate() } + layoutEngines.removeAll() + removeDragFloatingWindow() onDismiss?() removePanels() } @@ -330,6 +386,9 @@ final class OverlayWindowController { }, onDismiss: { [weak self] in self?.dismiss() + }, + onDrop: { [weak self] point in + self?.handleDrop(at: point) } ) @@ -373,6 +432,149 @@ final class OverlayWindowController { 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) { @@ -381,17 +583,69 @@ final class OverlayWindowController { } private func applyThumbnails(windows: [WindowInfo]) { - let groups = WindowEnumerator.groupWindows(windows) - let snapshot = WindowSnapshot(groups: groups) + let screens = NSScreen.screens + let primaryHeight = screens.first?.frame.height ?? 0 var layouts: [ScreenKey: LayoutResult] = [:] - for screen in NSScreen.screens { - let layout = layoutEngine.layout(snapshot: snapshot, screenBounds: screen.frame) - layouts[ScreenKey(screen.frame)] = layout + 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) { diff --git a/MCT/UI/SpringAnimator.swift b/MCT/UI/SpringAnimator.swift index 36819f7..78186a5 100644 --- a/MCT/UI/SpringAnimator.swift +++ b/MCT/UI/SpringAnimator.swift @@ -11,19 +11,21 @@ final class SpringAnimator { private let onComplete: () -> Void // Spring parameters (critically damped) - private let stiffness: CGFloat = 300.0 + private let stiffness: CGFloat private let damping: CGFloat // computed for critical damping: 2 * sqrt(stiffness) init( from: CGFloat, to target: CGFloat, initialVelocity: CGFloat = 0, + stiffness: CGFloat = 300.0, onUpdate: @escaping (CGFloat) -> Void, onComplete: @escaping () -> Void ) { self.current = from self.target = target self.velocity = initialVelocity + self.stiffness = stiffness self.onUpdate = onUpdate self.onComplete = onComplete self.damping = 2.0 * sqrt(stiffness) diff --git a/MCT/UI/WindowThumbnailView.swift b/MCT/UI/WindowThumbnailView.swift index d5dbd80..a9b7c20 100644 --- a/MCT/UI/WindowThumbnailView.swift +++ b/MCT/UI/WindowThumbnailView.swift @@ -5,7 +5,7 @@ struct WindowThumbnailView: View { let windowInfo: WindowInfo let size: CGSize let isSelected: Bool - var showAppBadge: Bool = false + var showAppBadge: Bool = true private var thumbnailHeight: CGFloat { size.height - LayoutGeometry.titleHeight @@ -21,9 +21,10 @@ struct WindowThumbnailView: View { if let thumbnail = windowInfo.thumbnail { Image(decorative: thumbnail, scale: 1.0) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFill() + .frame(width: size.width, height: thumbnailHeight) + .clipped() .clipShape(RoundedRectangle(cornerRadius: 6)) - .padding(2) } else { Image(systemName: "macwindow") .font(.system(size: min(32, size.width * 0.3))) @@ -35,9 +36,9 @@ struct WindowThumbnailView: View { 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: 80, height: 80) + .shadow(color: .black.opacity(0.5), radius: 3) + .padding(6) } } .frame(width: size.width, height: thumbnailHeight) @@ -49,7 +50,7 @@ struct WindowThumbnailView: View { // Always-visible title (includes app name in compact mode) Text(displayTitle) - .font(.system(size: 11, weight: .medium)) + .font(.system(size: 16, weight: .medium)) .foregroundStyle(.white) .lineLimit(1) .truncationMode(.middle) @@ -59,9 +60,6 @@ struct WindowThumbnailView: View { } private var displayTitle: String { - if showAppBadge { - return "\(windowInfo.appName) — \(windowInfo.title)" - } - return windowInfo.title + return "\(windowInfo.appName) — \(windowInfo.title)" } } diff --git a/README.md b/README.md index 52fc2f0..79f77e2 100644 --- a/README.md +++ b/README.md @@ -39,5 +39,22 @@ You'll be prompted to grant these on first launch. They can also be managed in * The built app bundle will be at `build/MCT.app`. +### Code signing + +The build script ad-hoc signs the app by default (using `codesign -s -`). This is sufficient to run locally, but macOS will reset permissions (Accessibility, Screen Recording) every time you rebuild. + +To preserve permissions across builds, sign with a self-signed certificate: + +1. Open **Keychain Access** +2. Go to **Keychain Access > Certificate Assistant > Create a Certificate...** +3. Enter a name (e.g. `Your Name`), set **Certificate Type** to **Code Signing**, click **Create** +4. Create a `.env` file in the project root (git-ignored): + +```bash +echo 'CERT_NAME="Your Name"' > .env +``` + +The build script reads `CERT_NAME` from `.env` automatically. When set, the app is signed with your certificate and macOS will remember granted permissions between rebuilds. + ## AI disclaimer This project was largely developed using Claude Code Opus 4.6 as an experiment in AI-assisted software development. \ No newline at end of file diff --git a/build.sh b/build.sh index 879c177..087590e 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,16 @@ #!/bin/bash set -euo pipefail +# Load .env if present (for CERT_NAME etc.) +ENV_FILE="$(dirname "$0")/.env" +if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a +fi + +CERT_NAME="${CERT_NAME:-}" + APP_DIR="$(dirname "$0")/build/MCT.app" SDK=$(xcrun --show-sdk-path) @@ -84,8 +94,12 @@ 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" +# Code-sign the app so macOS recognizes it for permission grants +if [ -n "$CERT_NAME" ]; then + codesign --force --sign "$CERT_NAME" "$APP_DIR" +else + codesign --force --sign - "$APP_DIR" +fi echo "Built: $APP_DIR" open "$APP_DIR"