From 333978c90f9485fc6f72fc63c1f06250c56ac6e6 Mon Sep 17 00:00:00 2001 From: p4p8 Date: Wed, 17 Jun 2026 19:01:53 +0200 Subject: [PATCH] Add all-spaces window scope --- .changeset/f3ac57bd.md | 5 + Sources/cmdcmd/CmdChord.swift | 108 +++- Sources/cmdcmd/Config.swift | 13 +- Sources/cmdcmd/Overlay.swift | 960 ++++++++++++++++++++++++++-- Sources/cmdcmd/SettingsWindow.swift | 21 +- Sources/cmdcmd/SpaceTracker.swift | 68 +- Sources/cmdcmd/Tile.swift | 26 +- Sources/cmdcmd/WindowInfo.swift | 65 +- Sources/cmdcmd/main.swift | 9 + 9 files changed, 1190 insertions(+), 85 deletions(-) create mode 100644 .changeset/f3ac57bd.md diff --git a/.changeset/f3ac57bd.md b/.changeset/f3ac57bd.md new file mode 100644 index 0000000..36ecb44 --- /dev/null +++ b/.changeset/f3ac57bd.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Add an All Spaces window scope, and make repeated Command-key chords trigger reliably. diff --git a/Sources/cmdcmd/CmdChord.swift b/Sources/cmdcmd/CmdChord.swift index 80f09ad..5b74ea6 100644 --- a/Sources/cmdcmd/CmdChord.swift +++ b/Sources/cmdcmd/CmdChord.swift @@ -1,16 +1,58 @@ import AppKit import Carbon.HIToolbox +private struct CmdChordStateMachine { + private var leftDown = false + private var rightDown = false + private var contaminated = false + private var fired = false + + mutating func markContaminated() { + if leftDown || rightDown { contaminated = true } + } + + mutating func handleFlags(keyCode: Int, flags: CGEventFlags) -> Bool { + let wasBothDown = leftDown && rightDown + let raw = flags.rawValue + switch keyCode { + case kVK_Command: + leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0 + case kVK_RightCommand: + rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0 + default: + return false + } + + if !leftDown && !rightDown { + contaminated = false + fired = false + return false + } + + // A clean chord may be repeated while one Command key stays held: + // both down -> exactly one down resets `fired`, so pressing the other + // Command again can toggle. If any normal key contaminated the chord, + // keep blocking until both Command keys are released. + let isExactlyOneDown = leftDown != rightDown + if wasBothDown && isExactlyOneDown && !contaminated { + fired = false + } + + if leftDown && rightDown && !contaminated && !fired { + fired = true + return true + } + return false + } +} + /// Fires when both the left and right Command keys are held simultaneously, /// with no other key pressed during the chord. final class CmdChord { private var monitors: [Any] = [] private var eventTap: CFMachPort? private var eventTapRunLoopSource: CFRunLoopSource? - private var leftDown = false - private var rightDown = false - private var contaminated = false - private var fired = false + private var state = CmdChordStateMachine() private let handler: () -> Void init(handler: @escaping () -> Void) { @@ -78,7 +120,7 @@ final class CmdChord { } private func markContaminated() { - if leftDown || rightDown { contaminated = true } + state.markContaminated() } private func handleFlags(_ event: NSEvent) { @@ -87,25 +129,51 @@ final class CmdChord { } private func handleFlags(keyCode: Int, flags: CGEventFlags) { - let raw = flags.rawValue - switch keyCode { - case kVK_Command: - leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0 - case kVK_RightCommand: - rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0 - default: - return + if state.handleFlags(keyCode: keyCode, flags: flags) { + DispatchQueue.main.async { self.handler() } } + } - if !leftDown && !rightDown { - contaminated = false - fired = false - return + static func selfTestFailures() -> [String] { + let left = CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | 0x8) + let right = CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | 0x10) + let both = CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | 0x8 | 0x10) + let none = CGEventFlags(rawValue: 0) + var failures: [String] = [] + + func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { failures.append(message) } } - if leftDown && rightDown && !contaminated && !fired { - fired = true - DispatchQueue.main.async { self.handler() } + do { + var s = CmdChordStateMachine() + expect(!s.handleFlags(keyCode: kVK_Command, flags: left), "left down alone fired") + expect(s.handleFlags(keyCode: kVK_RightCommand, flags: both), "left-held/right-tap did not fire") + expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: left), "right release fired") + expect(s.handleFlags(keyCode: kVK_RightCommand, flags: both), "left-held second right-tap did not fire") + } + + do { + var s = CmdChordStateMachine() + expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: right), "right down alone fired") + expect(s.handleFlags(keyCode: kVK_Command, flags: both), "right-held/left-tap did not fire") + expect(!s.handleFlags(keyCode: kVK_Command, flags: right), "left release fired") + expect(s.handleFlags(keyCode: kVK_Command, flags: both), "right-held second left-tap did not fire") } + + do { + var s = CmdChordStateMachine() + expect(!s.handleFlags(keyCode: kVK_Command, flags: left), "contamination setup fired") + s.markContaminated() + expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: both), "contaminated chord fired") + expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: left), "contaminated right release fired") + expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: both), "contaminated repeat fired before full release") + expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: left), "contaminated repeat release fired") + expect(!s.handleFlags(keyCode: kVK_Command, flags: none), "full release fired") + expect(!s.handleFlags(keyCode: kVK_Command, flags: left), "clean restart left down fired") + expect(s.handleFlags(keyCode: kVK_RightCommand, flags: both), "clean chord after full release did not fire") + } + + return failures } } diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 2ebb9aa..cec68f6 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -11,6 +11,11 @@ enum TilePicks: String, Codable, CaseIterable { case letters } +enum WindowScope: String, Codable, CaseIterable { + case currentSpace = "current-space" + case allSpaces = "all-spaces" +} + struct Config: Codable { var animations: Bool var animationSpeed: Double? @@ -21,6 +26,7 @@ struct Config: Codable { var letterJump: Bool? var usageOrdering: Bool? var tilePicks: TilePicks? + var windowScope: String? var animationSpeedOrDefault: Double { guard let animationSpeed, animationSpeed.isFinite else { return 1.0 } @@ -32,8 +38,9 @@ struct Config: Codable { var letterJumpEnabled: Bool { letterJump ?? true } var usageOrderingEnabled: Bool { usageOrdering ?? false } var tilePicksMode: TilePicks { tilePicks ?? .letters } + var windowScopeMode: WindowScope { WindowScope(rawValue: windowScope ?? "") ?? .currentSpace } - static let `default` = Config(animations: true, animationSpeed: nil, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil) + static let `default` = Config(animations: true, animationSpeed: nil, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil, windowScope: nil) static var fileURL: URL { URL(fileURLWithPath: NSHomeDirectory()) @@ -370,6 +377,10 @@ struct Config: Codable { lines.append(" // faster and lighter, especially with many windows open.") lines.append(" \"livePreviews\": true,") lines.append("") + lines.append(" // Which windows to show: \"current-space\" keeps existing behavior;") + lines.append(" // \"all-spaces\" also includes windows from other macOS Spaces.") + lines.append(" \"windowScope\": \"current-space\",") + lines.append("") lines.append(" // What summons the overlay. \"cmd-cmd\" is the both-Command-keys chord.") lines.append(" // Anything else is a normal hotkey: \"cmd+shift+space\", \"f13\", etc.") lines.append(" \"trigger\": \"cmd-cmd\",") diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 1faf24f..6a2be88 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -3,20 +3,30 @@ import AppKit @_silgen_name("_AXUIElementGetWindow") private func _AXUIElementGetWindow(_ axEl: AXUIElement, _ wid: UnsafeMutablePointer) -> AXError +private extension CGRect { + var center: CGPoint { CGPoint(x: midX, y: midY) } +} + final class Overlay { private var window: NSWindow? private var view: OverlayView? private var backgroundLayer: CALayer? + private var windowsByDisplay: [String: NSWindow] = [:] + private var backgroundLayersByDisplay: [String: CALayer] = [:] private var visible = false private var allTiles: [Tile] = [] private var tiles: [Tile] = [] private var gridCols: Int = 1 private var selectedIndex: Int = 0 + private var spaceHeaderLayers: [CALayer] = [] private var isZoomed = false private var savedFrames: [CGRect] = [] private var prevFrontPID: pid_t = 0 + private var prevFrontWindowID: CGWindowID? private var prevFrontTitle: String = "" private var prevPickedWindowID: CGWindowID? + private var preferredSelection: PreferredSelection? + private var returnFocus: PreferredSelection? private var dragState: DragState? private var lastLetterJump: String? private let tracker: SpaceTracker @@ -42,6 +52,26 @@ final class Overlay { var moved: Bool } + private struct PreferredSelection { + var windowID: CGWindowID? + let processID: pid_t + let bundleIdentifier: String? + let title: String? + let targetFrame: CGRect? + let targetDisplayKey: String? + let moveDX: Int + let moveDY: Int + } + + private struct WindowMoveResult { + let windowID: CGWindowID? + } + + private struct FocusedWindow { + let windowID: CGWindowID? + let title: String? + } + private var savedOrder: [CGWindowID] { get { (UserDefaults.standard.array(forKey: "tileOrder.\(displayKey)") as? [NSNumber] ?? []) @@ -54,6 +84,7 @@ final class Overlay { private var workspaceObserver: NSObjectProtocol? private var appActivationObserver: NSObjectProtocol? + private var activeSpaceObserver: NSObjectProtocol? private var activityTimer: Timer? private let search = SearchField() private var searchQuery: String = "" @@ -96,15 +127,27 @@ final class Overlay { queue: .main ) { [weak self] _ in guard let self, self.visible, !self.isPicking else { return } + // In all-spaces mode the overlay intentionally follows Space + // switches, so don't dismiss just because macOS briefly changes + // focus while moving between desktops. + guard self.config.windowScopeMode != .allSpaces else { return } self.hide(activatePrevious: false) } appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main - ) { notification in + ) { [weak self] notification in guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } Self.recordUse(of: app) + self?.frontmostApplicationChanged(app) + } + activeSpaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.activeSpaceDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.activeSpaceDidChange() } } @@ -117,6 +160,9 @@ final class Overlay { if let o = appActivationObserver { NSWorkspace.shared.notificationCenter.removeObserver(o) } + if let o = activeSpaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(o) + } } func toggle() { @@ -147,17 +193,21 @@ final class Overlay { let prevApp = NSWorkspace.shared.frontmostApplication prevFrontPID = prevApp?.processIdentifier ?? 0 if let prevApp { Self.recordUse(of: prevApp) } - prevFrontTitle = focusedWindowTitle(pid: prevFrontPID) ?? "" + let focused = focusedWindow(pid: prevFrontPID) + prevFrontWindowID = focused?.windowID + prevFrontTitle = focused?.title ?? "" + returnFocus = nil let screen = Self.cursorScreen() activeScreen = screen - displayKey = Self.displayKeyString(for: screen) + let baseDisplayKey = Self.displayKeyString(for: screen) + displayKey = config.windowScopeMode == .allSpaces ? "all-displays" : baseDisplayKey visible = true refreshGeneration &+= 1 let gen = refreshGeneration startActivityTimer() - Log.debug(String(format: "show: setup=%.1fms prevFrontPID=%d title=\"%@\"", + Log.debug(String(format: "show: setup=%.1fms prevFrontPID=%d wid=%@ title=\"%@\"", (CFAbsoluteTimeGetCurrent() - t0) * 1000, - prevFrontPID, prevFrontTitle as NSString)) + prevFrontPID, prevFrontWindowID.map(String.init) ?? "nil", prevFrontTitle as NSString)) Task { await prepareAndShow(gen: gen, screen: screen) } } @@ -165,20 +215,45 @@ final class Overlay { private func renderOverlay(windows: [WindowInfo], screen: NSScreen) { guard visible else { return } let t0 = CFAbsoluteTimeGetCurrent() - let displayBounds = CGDisplayBounds(Self.displayID(for: screen)) + let scope = config.windowScopeMode + let screens = scope == .allSpaces ? NSScreen.screens : [screen] let visibleFrame = screen.visibleFrame + let displayBounds = CGDisplayBounds(Self.displayID(for: screen)) let candidates = windows .filter(Self.isCapturable) - .filter { Self.windowMostlyOn(displayBounds: displayBounds, window: $0) } + .filter { w in + switch scope { + case .currentSpace: + return Self.windowMostlyOn(displayBounds: displayBounds, window: w) + case .allSpaces: + return Self.screenForWindow(w, in: screens) != nil + } + } + if scope == .allSpaces { + let summary = Dictionary(grouping: candidates) { w in + Self.screenForWindow(w, in: screens).map(Self.displayKeyString(for:)) ?? "unknown" + } + .map { "\($0.key)=\($0.value.count)" } + .sorted() + .joined(separator: ", ") + Log.write("all-displays candidates n=\(candidates.count) screens=[\(summary)]") + } let tFilter = CFAbsoluteTimeGetCurrent() let createdWindow = window == nil - let w = window ?? makeWindow(frame: visibleFrame) - window = w - w.setFrame(visibleFrame, display: false) - w.alphaValue = 1 + let primaryWindow: NSWindow + if scope == .allSpaces { + primaryWindow = ensureWindows(for: screens, primary: screen) + } else { + let w = window ?? makeWindow(frame: visibleFrame) + window = w + w.setFrame(visibleFrame, display: false) + w.alphaValue = 1 + primaryWindow = w + } let tWindow = CFAbsoluteTimeGetCurrent() CATransaction.begin() CATransaction.setDisableActions(true) + clearTilesForRerender() installTiles(candidates: candidates) // Match each tile's z-order to its source window's WindowServer // z-order (candidates[0] is front-most) so tiles overlap correctly @@ -193,20 +268,24 @@ final class Overlay { // Capture each tile's final grid frame, then teleport to its source // window frame so animateShow can fly all tiles in Exposé-style. let gridFrames = tiles.map { $0.layer.frame } - if config.animations { + if config.animations && scope == .currentSpace { backgroundLayer?.opacity = 0 for t in tiles { - let src = Self.contentLocalRect(forSourceCGFrame: t.window.frame, overlayWindow: w) + let src = Self.contentLocalRect(forSourceCGFrame: t.window.frame, overlayWindow: primaryWindow) t.setFrame(src) } } CATransaction.commit() let tTiles = CFAbsoluteTimeGetCurrent() - w.makeKeyAndOrderFront(nil) + if scope == .allSpaces { + for w in windowsByDisplay.values { w.orderFrontRegardless() } + } + primaryWindow.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - if let v = view { w.makeFirstResponder(v) } + if let v = view { primaryWindow.makeFirstResponder(v) } let tFront = CFAbsoluteTimeGetCurrent() - animateShow(gridFrames: gridFrames) + if scope == .allSpaces { updateSelection() } + else { animateShow(gridFrames: gridFrames) } let tEnd = CFAbsoluteTimeGetCurrent() Log.debug(String(format: "render: filter=%.1f window=%.1f(new=%@) installTiles=%.1f orderFront+activate=%.1f animate=%.1f total=%.1f n=%d", (tFilter - t0) * 1000, @@ -237,6 +316,45 @@ final class Overlay { return "id-\(id)" } + private static func allScreensVisibleFrame() -> CGRect { + NSScreen.screens.map(\.visibleFrame).reduce(CGRect.null) { $0.union($1) } + } + + private func ensureWindows(for screens: [NSScreen], primary: NSScreen) -> NSWindow { + let validKeys = Set(screens.map(Self.displayKeyString(for:))) + for (key, win) in windowsByDisplay where !validKeys.contains(key) { + win.orderOut(nil) + windowsByDisplay.removeValue(forKey: key) + backgroundLayersByDisplay.removeValue(forKey: key) + } + + var primaryWindow: NSWindow? + for screen in screens { + let key = Self.displayKeyString(for: screen) + let w: NSWindow + if let existing = windowsByDisplay[key] { + w = existing + w.setFrame(screen.visibleFrame, display: false) + } else { + w = makeWindow(frame: screen.visibleFrame) + windowsByDisplay[key] = w + if let bg = backgroundLayer { backgroundLayersByDisplay[key] = bg } + } + w.alphaValue = 1 + if key == Self.displayKeyString(for: primary) { + primaryWindow = w + window = w + } + } + if primaryWindow == nil { + primaryWindow = windowsByDisplay[Self.displayKeyString(for: primary)] ?? windowsByDisplay.values.first + } + if let primaryWindow, let primaryView = primaryWindow.contentView as? OverlayView { + view = primaryView + } + return primaryWindow ?? makeWindow(frame: primary.visibleFrame) + } + private func startActivityTimer() { activityTimer?.invalidate() guard config.livePreviewsEnabled else { return } @@ -252,20 +370,54 @@ final class Overlay { activityTimer = nil } - private func focusedWindowTitle(pid: pid_t) -> String? { + private func activeSpaceDidChange() { + guard visible, config.windowScopeMode == .allSpaces, !isPicking else { return } + let screen = Self.cursorScreen() + activeScreen = screen + displayKey = "all-displays" + refreshGeneration &+= 1 + let gen = refreshGeneration + Log.write("active space changed; refreshing overlay for space=\(tracker.activeSpace())") + Task { await prepareAndShow(gen: gen, screen: screen) } + } + + private func focusedWindow(pid: pid_t) -> FocusedWindow? { guard pid > 0 else { return nil } let app = AXUIElementCreateApplication(pid) var win: CFTypeRef? guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &win) == .success, CFGetTypeID(win) == AXUIElementGetTypeID() else { return nil } let axWin = win as! AXUIElement - var title: CFTypeRef? - guard AXUIElementCopyAttributeValue(axWin, kAXTitleAttribute as CFString, &title) == .success else { return nil } - return title as? String + return FocusedWindow(windowID: axWindowID(axWin), title: axTitle(axWin)) + } + + private func frontmostApplicationChanged(_ app: NSRunningApplication) { + guard app.processIdentifier != getpid(), app.activationPolicy == .regular else { return } + prevFrontPID = app.processIdentifier + let focused = focusedWindow(pid: prevFrontPID) + prevFrontWindowID = focused?.windowID + prevFrontTitle = focused?.title ?? "" + if visible, !isPicking { + preferredSelection = nil + returnFocus = nil + selectActualFocusedWindow() + } + } + + private func selectActualFocusedWindow() { + let idMatch = prevFrontWindowID.flatMap { wid in + tiles.firstIndex(where: { CGWindowID($0.window.windowID) == wid }) + } + let titleMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID && ($0.window.title ?? "") == prevFrontTitle }) + let pidMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID }) + guard let idx = idMatch ?? titleMatch ?? pidMatch else { return } + selectedIndex = idx + updateSelection() + Log.write("focus changed selected wid=\(tiles[idx].window.windowID) pid=\(prevFrontPID) title=\(prevFrontTitle)") } private func prepareAndShow(gen: Int, screen: NSScreen) async { - let windows = WindowInfo.enumerate() + let windows = WindowInfo.enumerate(scope: config.windowScopeMode, tracker: tracker) await MainActor.run { guard self.visible, gen == self.refreshGeneration else { return } self.renderOverlay(windows: windows, screen: screen) @@ -339,7 +491,33 @@ final class Overlay { ) } -private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> Bool { + private static func visibleCGBounds(for screen: NSScreen) -> CGRect { + nsFrameToCG(screen.visibleFrame) + } + + private static func nsFrameToCG(_ ns: CGRect) -> CGRect { + guard let primary = NSScreen.screens.first else { return ns } + let primaryMaxY = primary.frame.maxY + return CGRect(x: ns.minX, y: primaryMaxY - ns.maxY, width: ns.width, height: ns.height) + } + + private static func frame(_ a: CGRect, matches b: CGRect, tolerance: CGFloat = 8) -> Bool { + abs(a.minX - b.minX) <= tolerance && + abs(a.minY - b.minY) <= tolerance && + abs(a.width - b.width) <= tolerance && + abs(a.height - b.height) <= tolerance + } + + private static func roundedFrame(_ frame: CGRect) -> CGRect { + CGRect( + x: frame.origin.x.rounded(), + y: frame.origin.y.rounded(), + width: frame.width.rounded(), + height: frame.height.rounded() + ) + } + + private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> Bool { let inter = window.frame.intersection(displayBounds) guard !inter.isNull else { return false } let interArea = inter.width * inter.height @@ -347,6 +525,19 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> return total > 0 && interArea / total >= 0.5 } + private static func screenForWindow(_ window: WindowInfo, in screens: [NSScreen]) -> NSScreen? { + var best: (screen: NSScreen, area: CGFloat)? + for screen in screens { + let inter = window.frame.intersection(CGDisplayBounds(displayID(for: screen))) + guard !inter.isNull else { continue } + let area = inter.width * inter.height + if area > (best?.area ?? 0) { + best = (screen, area) + } + } + return best?.area ?? 0 > 0 ? best?.screen : nil + } + private func orderTiles(_ tiles: [Tile]) -> [Tile] { let saved = savedOrder if config.usageOrderingEnabled { @@ -374,6 +565,24 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> } } + private func clearTilesForRerender() { + clearSpaceHeaders() + let oldTiles = allTiles + allTiles = [] + tiles = [] + selectedIndex = 0 + for t in oldTiles { t.layer.removeFromSuperlayer() } + if !oldTiles.isEmpty { + Task(priority: .utility) { + await withTaskGroup(of: Void.self) { group in + for t in oldTiles { + group.addTask(priority: .utility) { await t.stop() } + } + } + } + } + } + private func installTiles(candidates: [WindowInfo]) { let mcTiles: [Tile] = candidates.map { w in Tile(window: w, ownerPID: w.processID) @@ -384,15 +593,42 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> allTiles = ordered for t in ordered { - window?.contentView?.layer?.addSublayer(t.layer) + overlayWindow(for: t.window)?.contentView?.layer?.addSublayer(t.layer) } rebuildDisplayed() - let widMatch = prevPickedWindowID.flatMap { wid in tiles.firstIndex(where: { CGWindowID($0.window.windowID) == wid }) } - let titleMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID && ($0.window.title ?? "") == prevFrontTitle }) - let pidMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID }) - if let i = widMatch ?? titleMatch ?? pidMatch { + let preferredMatch = preferredSelection.flatMap { preferredSelectionIndex(in: tiles, for: $0) } + if let preferred = preferredSelection, let i = preferredMatch { + let tile = tiles[i] + var updatedPreferred = preferred + updatedPreferred.windowID = CGWindowID(tile.window.windowID) + preferredSelection = updatedPreferred + returnFocus = updatedPreferred + rememberFocus(on: tile) + if let targetScreen = screen(forDisplayKey: updatedPreferred.targetDisplayKey) ?? Self.screenForWindow(tile.window, in: NSScreen.screens), + let movedIndex = placeMovedTile(tile, targetScreen: targetScreen, dx: updatedPreferred.moveDX, dy: updatedPreferred.moveDY) { + selectedIndex = movedIndex + renumberTiles() + Log.write("move selection matched wid=\(tile.window.windowID) display=\(Self.displayKeyString(for: targetScreen)) index=\(movedIndex) screenPos=\(screenPosition(of: tile) ?? -1)") + } + } + let fallbackMatch: Int? + if preferredSelection == nil { + let focusIDMatch = prevFrontWindowID.flatMap { wid in tiles.firstIndex(where: { CGWindowID($0.window.windowID) == wid }) } + let titleMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID && ($0.window.title ?? "") == prevFrontTitle }) + let pidMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID }) + let pickedMatch = prevPickedWindowID.flatMap { wid in tiles.firstIndex(where: { CGWindowID($0.window.windowID) == wid }) } + fallbackMatch = focusIDMatch ?? titleMatch ?? pidMatch ?? pickedMatch + } else { + fallbackMatch = nil + } + if preferredMatch == nil, let i = fallbackMatch { selectedIndex = i updateSelection() + } else if preferredMatch != nil { + updateSelection() + } + if preferredMatch != nil { + preferredSelection = nil } let live = config.livePreviewsEnabled Task { @@ -407,7 +643,200 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> } } + private func preferredSelectionIndex(in tiles: [Tile], for preferred: PreferredSelection) -> Int? { + let candidates = tiles.indices.filter { tiles[$0].ownerPID == preferred.processID } + guard !candidates.isEmpty else { return nil } + + if let id = preferred.windowID, + let exact = candidates.first(where: { CGWindowID(tiles[$0].window.windowID) == id }), + preferred.targetFrame.map({ Self.framesAreClose(tiles[exact].window.frame, to: $0) }) ?? true { + return exact + } + + let scored = candidates.map { idx in + (idx, preferredSelectionScore(tile: tiles[idx], preferred: preferred)) + } + guard let best = scored.min(by: { $0.1 < $1.1 }) else { return nil } + if let target = preferred.targetFrame { + let distance = Self.centerDistance(tiles[best.0].window.frame, target) + let maxDistance = max(CGFloat(300), hypot(target.width, target.height) * 0.75) + guard distance <= maxDistance else { return nil } + } + return best.0 + } + + private func preferredSelectionScore(tile: Tile, preferred: PreferredSelection) -> CGFloat { + var score: CGFloat = 0 + if let target = preferred.targetFrame { + score += Self.frameDistanceScore(tile.window.frame, target) + } + if let bundle = preferred.bundleIdentifier, + let tileBundle = tile.window.bundleIdentifier, + bundle != tileBundle { + score += 5_000 + } + let wantedTitle = Self.normalizedTitle(preferred.title) + if !wantedTitle.isEmpty, Self.normalizedTitle(tile.window.title) != wantedTitle { + score += 3_000 + } + return score + } + + private static func normalizedTitle(_ title: String?) -> String { + (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func framesAreClose(_ a: CGRect, to b: CGRect) -> Bool { + centerDistance(a, b) <= max(CGFloat(120), hypot(b.width, b.height) * 0.25) + } + + private static func frameDistanceScore(_ a: CGRect, _ b: CGRect) -> CGFloat { + centerDistance(a, b) + abs(a.width - b.width) * 0.25 + abs(a.height - b.height) * 0.25 + } + + private static func centerDistance(_ a: CGRect, _ b: CGRect) -> CGFloat { + hypot(a.midX - b.midX, a.midY - b.midY) + } + + private func screen(forDisplayKey key: String?) -> NSScreen? { + guard let key else { return nil } + return NSScreen.screens.first { Self.displayKeyString(for: $0) == key } + } + + @discardableResult + private func placeMovedTile(_ tile: Tile, targetScreen: NSScreen, dx: Int, dy: Int) -> Int? { + let allResult = reorderedTiles(allTiles, moving: tile, targetScreen: targetScreen, dx: dx, dy: dy) + allTiles = allResult.tiles + + let visibleResult = reorderedTiles(tiles, moving: tile, targetScreen: targetScreen, dx: dx, dy: dy) + tiles = visibleResult.tiles + + savedOrder = allTiles.map { CGWindowID($0.window.windowID) } + return visibleResult.index ?? tiles.firstIndex(where: { $0 === tile }) + } + + private func reorderedTiles(_ source: [Tile], moving tile: Tile, targetScreen: NSScreen, dx: Int, dy: Int) -> (tiles: [Tile], index: Int?) { + var list = source + guard let oldIndex = list.firstIndex(where: { $0 === tile }) else { return (source, nil) } + list.remove(at: oldIndex) + + let targetIndexes = list.indices.filter { idx in + Self.screenForWindow(list[idx].window, in: [targetScreen]) != nil + } + let slot = targetInsertionSlot( + countAfterInsert: targetIndexes.count + 1, + targetScreen: targetScreen, + targetFrame: tile.window.frame, + dx: dx, + dy: dy + ) + let insertIndex = insertionIndex(in: list, targetIndexes: Array(targetIndexes), localSlot: slot, targetScreen: targetScreen) + list.insert(tile, at: insertIndex) + return (list, insertIndex) + } + + private func insertionIndex(in list: [Tile], targetIndexes: [Int], localSlot: Int, targetScreen: NSScreen) -> Int { + guard !targetIndexes.isEmpty else { return emptyDisplayInsertionIndex(in: list, targetScreen: targetScreen) } + if localSlot <= 0 { return targetIndexes[0] } + if localSlot >= targetIndexes.count { return targetIndexes[targetIndexes.count - 1] + 1 } + return targetIndexes[localSlot] + } + + private func emptyDisplayInsertionIndex(in list: [Tile], targetScreen: NSScreen) -> Int { + let orderedScreens = Self.orderedScreens() + guard let targetOrder = orderedScreens.firstIndex(of: targetScreen) else { return list.count } + for (idx, tile) in list.enumerated() { + guard let screen = Self.screenForWindow(tile.window, in: NSScreen.screens), + let order = orderedScreens.firstIndex(of: screen) else { continue } + if order > targetOrder { return idx } + } + return list.count + } + + private func targetInsertionSlot(countAfterInsert count: Int, targetScreen: NSScreen, targetFrame: CGRect, dx: Int, dy: Int) -> Int { + guard count > 0 else { return 0 } + let bounds = layoutBounds(for: targetScreen) + let ar = targetScreen.visibleFrame.width / max(1, targetScreen.visibleFrame.height) + let rects = GridLayout.frames(count: count, bounds: bounds, aspectRatio: ar).frames + guard !rects.isEmpty else { return 0 } + + let desired = desiredLocalPoint(forTargetFrame: targetFrame, on: targetScreen, bounds: bounds) + let edgeIndexes: [Int] + if dx < 0 { + let edge = rects.map(\.midX).max() ?? desired.x + edgeIndexes = rects.indices.filter { abs(rects[$0].midX - edge) < 1 } + } else if dx > 0 { + let edge = rects.map(\.midX).min() ?? desired.x + edgeIndexes = rects.indices.filter { abs(rects[$0].midX - edge) < 1 } + } else if dy > 0 { + let edge = rects.map(\.midY).min() ?? desired.y + edgeIndexes = rects.indices.filter { abs(rects[$0].midY - edge) < 1 } + } else if dy < 0 { + let edge = rects.map(\.midY).max() ?? desired.y + edgeIndexes = rects.indices.filter { abs(rects[$0].midY - edge) < 1 } + } else { + edgeIndexes = Array(rects.indices) + } + + return (edgeIndexes.isEmpty ? Array(rects.indices) : edgeIndexes).min { a, b in + hypot(rects[a].midX - desired.x, rects[a].midY - desired.y) < + hypot(rects[b].midX - desired.x, rects[b].midY - desired.y) + } ?? 0 + } + + private func layoutBounds(for screen: NSScreen) -> CGRect { + if let root = windowsByDisplay[Self.displayKeyString(for: screen)]?.contentView?.layer { + return root.bounds + } + return CGRect(origin: .zero, size: screen.visibleFrame.size) + } + + private func desiredLocalPoint(forTargetFrame frame: CGRect, on screen: NSScreen, bounds: CGRect) -> CGPoint { + let displayBounds = CGDisplayBounds(Self.displayID(for: screen)) + let xFrac = Self.clamped((frame.midX - displayBounds.minX) / max(1, displayBounds.width), 0, 1) + let yFracFromTop = Self.clamped((frame.midY - displayBounds.minY) / max(1, displayBounds.height), 0, 1) + return CGPoint( + x: bounds.minX + bounds.width * xFrac, + y: bounds.minY + bounds.height * (1 - yFracFromTop) + ) + } + + private static func clamped(_ value: CGFloat, _ low: CGFloat, _ high: CGFloat) -> CGFloat { + min(high, max(low, value)) + } + + private static func orderedScreens() -> [NSScreen] { + NSScreen.screens.sorted { a, b in + if abs(a.frame.minX - b.frame.minX) > 1 { return a.frame.minX < b.frame.minX } + return a.frame.minY < b.frame.minY + } + } + + private func screenPosition(of tile: Tile) -> Int? { + guard let screen = Self.screenForWindow(tile.window, in: NSScreen.screens) else { return nil } + let screenTiles = tiles.filter { Self.screenForWindow($0.window, in: [screen]) != nil } + guard let idx = screenTiles.firstIndex(where: { $0 === tile }) else { return nil } + return idx + 1 + } + + private func rememberFocus(on tile: Tile) { + let id = CGWindowID(tile.window.windowID) + prevPickedWindowID = id + prevFrontWindowID = id + prevFrontPID = tile.ownerPID + prevFrontTitle = tile.window.title ?? "" + } + + private func overlayWindow(for source: WindowInfo) -> NSWindow? { + guard config.windowScopeMode == .allSpaces, + let screen = Self.screenForWindow(source, in: NSScreen.screens) else { + return window + } + return windowsByDisplay[Self.displayKeyString(for: screen)] ?? window + } + private func rebuildDisplayed() { + clearSpaceHeaders() let displayed = allTiles.filter { Self.matches(tile: $0, query: searchQuery) } let visibleSet = Set(displayed.map { ObjectIdentifier($0) }) for t in allTiles { @@ -549,10 +978,18 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> case .moveRight: move(dx: 1, dy: 0) case .moveUp: move(dx: 0, dy: -1) case .moveDown: move(dx: 0, dy: 1) - case .swapLeft: swapSelected(dx: -1, dy: 0) - case .swapRight: swapSelected(dx: 1, dy: 0) - case .swapUp: swapSelected(dx: 0, dy: -1) - case .swapDown: swapSelected(dx: 0, dy: 1) + case .swapLeft: + if config.windowScopeMode == .allSpaces { swapWithinDisplayOrMoveToDisplay(dx: -1, dy: 0, screenDx: -1, screenDy: 0) } + else { swapSelected(dx: -1, dy: 0) } + case .swapRight: + if config.windowScopeMode == .allSpaces { swapWithinDisplayOrMoveToDisplay(dx: 1, dy: 0, screenDx: 1, screenDy: 0) } + else { swapSelected(dx: 1, dy: 0) } + case .swapUp: + if config.windowScopeMode == .allSpaces { swapWithinDisplayOrMoveToDisplay(dx: 0, dy: -1, screenDx: 0, screenDy: 1) } + else { swapSelected(dx: 0, dy: -1) } + case .swapDown: + if config.windowScopeMode == .allSpaces { swapWithinDisplayOrMoveToDisplay(dx: 0, dy: 1, screenDx: 0, screenDy: -1) } + else { swapSelected(dx: 0, dy: 1) } case .close: closeSelected() case .tagGreen: tagSelectedColor("green") case .tagBlue: tagSelectedColor("blue") @@ -595,6 +1032,268 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> updateSelection() } + private func swapWithinDisplayOrMoveToDisplay(dx: Int, dy: Int, screenDx: Int, screenDy: Int) { + if swapSelectedWithinDisplay(dx: dx, dy: dy) { return } + moveSelectedWindowToAdjacentDisplay(dx: screenDx, dy: screenDy) + } + + private func swapSelectedWithinDisplay(dx: Int, dy: Int) -> Bool { + guard tiles.indices.contains(selectedIndex), + let selectedScreen = Self.screenForWindow(tiles[selectedIndex].window, in: NSScreen.screens) else { return false } + let source = tiles[selectedIndex].layer.frame.center + let candidates = tiles.indices.filter { idx in + guard idx != selectedIndex, + Self.screenForWindow(tiles[idx].window, in: [selectedScreen]) != nil else { return false } + let c = tiles[idx].layer.frame.center + if dx < 0 { return c.x < source.x - 4 } + if dx > 0 { return c.x > source.x + 4 } + if dy < 0 { return c.y > source.y + 4 } + if dy > 0 { return c.y < source.y - 4 } + return false + } + guard let target = bestSpatialCandidate(from: source, candidates: candidates, dx: dx, dy: dy) else { return false } + let a = tiles[selectedIndex] + let b = tiles[target] + tiles.swapAt(selectedIndex, target) + if let ai = allTiles.firstIndex(where: { $0 === a }), + let bi = allTiles.firstIndex(where: { $0 === b }) { + allTiles.swapAt(ai, bi) + } + savedOrder = allTiles.map { CGWindowID($0.window.windowID) } + selectedIndex = target + renumberTiles() + layoutTilesAnimated() + updateSelection() + return true + } + + private func moveSelectedWindowToAdjacentDisplay(dx: Int, dy: Int) { + guard tiles.indices.contains(selectedIndex) else { return } + let tile = tiles[selectedIndex] + guard let sourceScreen = Self.screenForWindow(tile.window, in: NSScreen.screens), + let targetScreen = adjacentScreen(from: sourceScreen, dx: dx, dy: dy) else { return } + let newFrame = frameForMoving(tile.window.frame, from: sourceScreen, to: targetScreen, dx: dx, dy: dy) + applyWindowMove(tile: tile, frame: newFrame, targetScreen: targetScreen, dx: dx, dy: dy, note: "to display=\(Self.displayKeyString(for: targetScreen))") + } + + private func frameForMoving(_ oldFrame: CGRect, from sourceScreen: NSScreen, to targetScreen: NSScreen, dx: Int, dy: Int) -> CGRect { + let sourceBounds = Self.visibleCGBounds(for: sourceScreen) + let targetBounds = Self.visibleCGBounds(for: targetScreen) + + // A maximized/"full-screen within the desktop" window should stay + // maximized. The old 95% clamp made these land a few pixels inset. + if Self.frame(oldFrame, matches: sourceBounds) { + return Self.roundedFrame(targetBounds) + } + + let relativeCenterX = sourceBounds.width > 0 ? (oldFrame.midX - sourceBounds.minX) / sourceBounds.width : 0.5 + let relativeCenterY = sourceBounds.height > 0 ? (oldFrame.midY - sourceBounds.minY) / sourceBounds.height : 0.5 + let scale = min(targetBounds.width / max(1, sourceBounds.width), targetBounds.height / max(1, sourceBounds.height)) + let minWidth = min(CGFloat(240), targetBounds.width) + let minHeight = min(CGFloat(180), targetBounds.height) + let newSize = CGSize( + width: min(targetBounds.width, max(minWidth, oldFrame.width * scale)), + height: min(targetBounds.height, max(minHeight, oldFrame.height * scale)) + ) + var newFrame = CGRect( + x: targetBounds.minX + Self.clamped(relativeCenterX, 0, 1) * targetBounds.width - newSize.width / 2, + y: targetBounds.minY + Self.clamped(relativeCenterY, 0, 1) * targetBounds.height - newSize.height / 2, + width: newSize.width, + height: newSize.height + ) + newFrame.origin.x = min(max(targetBounds.minX, newFrame.origin.x), targetBounds.maxX - newFrame.width) + newFrame.origin.y = min(max(targetBounds.minY, newFrame.origin.y), targetBounds.maxY - newFrame.height) + return Self.roundedFrame(newFrame) + } + + private func applyWindowMove(tile: Tile, frame: CGRect, targetScreen: NSScreen, dx: Int, dy: Int, note: String) { + let oldWindowID = CGWindowID(tile.window.windowID) + if let result = setAXWindowFrame(pid: tile.ownerPID, windowID: tile.window.windowID, title: tile.window.title, frame: frame) { + let selection = PreferredSelection( + windowID: result.windowID ?? oldWindowID, + processID: tile.ownerPID, + bundleIdentifier: tile.window.bundleIdentifier, + title: tile.window.title, + targetFrame: frame, + targetDisplayKey: Self.displayKeyString(for: targetScreen), + moveDX: dx, + moveDY: dy + ) + preferredSelection = selection + returnFocus = selection + optimisticallyMove(tile: tile, to: frame, targetScreen: targetScreen, windowID: selection.windowID, dx: dx, dy: dy) + focusOverlay(on: targetScreen) + Log.write("moved window wid=\(oldWindowID) newWid=\(selection.windowID.map(String.init) ?? "unknown") \(note) frame=\(frame)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self, self.visible else { return } + var refreshedSelection = selection + if let resolvedID = self.resolveMovedWindowID(for: refreshedSelection) { + refreshedSelection.windowID = resolvedID + } + self.preferredSelection = refreshedSelection + self.returnFocus = refreshedSelection + self.refreshGeneration &+= 1 + let gen = self.refreshGeneration + Task { await self.prepareAndShow(gen: gen, screen: targetScreen) } + } + } else { + Log.write("move window failed wid=\(tile.window.windowID)") + } + } + + private func optimisticallyMove(tile: Tile, to frame: CGRect, targetScreen: NSScreen, windowID: CGWindowID?, dx: Int, dy: Int) { + tile.window = tile.window.replacing(windowID: windowID, frame: frame, isOnScreen: true, isOnActiveSpace: true) + rememberFocus(on: tile) + if config.windowScopeMode == .allSpaces, + let root = windowsByDisplay[Self.displayKeyString(for: targetScreen)]?.contentView?.layer, + tile.layer.superlayer !== root { + root.addSublayer(tile.layer) + } + if let idx = placeMovedTile(tile, targetScreen: targetScreen, dx: dx, dy: dy) { + selectedIndex = idx + } + renumberTiles() + layoutTilesAnimated() + updateSelection() + Log.write("move optimistic wid=\(tile.window.windowID) display=\(Self.displayKeyString(for: targetScreen)) index=\(selectedIndex) screenPos=\(screenPosition(of: tile) ?? -1)") + } + + private func focusOverlay(on screen: NSScreen) { + activeScreen = screen + guard config.windowScopeMode == .allSpaces else { return } + let key = Self.displayKeyString(for: screen) + guard let targetWindow = windowsByDisplay[key] else { return } + window = targetWindow + if let targetView = targetWindow.contentView as? OverlayView { + view = targetView + targetWindow.makeKeyAndOrderFront(nil) + targetWindow.makeFirstResponder(targetView) + } else { + targetWindow.makeKeyAndOrderFront(nil) + } + } + + private func adjacentScreen(from source: NSScreen, dx: Int, dy: Int) -> NSScreen? { + let sourceCenter = source.frame.center + let candidates = NSScreen.screens.filter { $0 != source }.filter { screen in + let c = screen.frame.center + if dx < 0 { return c.x < sourceCenter.x - 1 } + if dx > 0 { return c.x > sourceCenter.x + 1 } + if dy < 0 { return c.y < sourceCenter.y - 1 } + if dy > 0 { return c.y > sourceCenter.y + 1 } + return false + } + return candidates.min { a, b in + let ca = a.frame.center + let cb = b.frame.center + let primaryA = dx == 0 ? abs(ca.y - sourceCenter.y) : abs(ca.x - sourceCenter.x) + let primaryB = dx == 0 ? abs(cb.y - sourceCenter.y) : abs(cb.x - sourceCenter.x) + if abs(primaryA - primaryB) > 1 { return primaryA < primaryB } + let crossA = dx == 0 ? abs(ca.x - sourceCenter.x) : abs(ca.y - sourceCenter.y) + let crossB = dx == 0 ? abs(cb.x - sourceCenter.x) : abs(cb.y - sourceCenter.y) + return crossA < crossB + } + } + + private func activatePreferredWindow(_ selection: PreferredSelection) { + let resolvedID = resolveMovedWindowID(for: selection) ?? selection.windowID ?? 0 + raiseAXWindow(pid: selection.processID, windowID: resolvedID, title: selection.title) + if let app = NSRunningApplication(processIdentifier: selection.processID) { + app.activate(options: [.activateAllWindows]) + } + raiseAXWindow(pid: selection.processID, windowID: resolvedID, title: selection.title) + Log.write("return focus wid=\(resolvedID) pid=\(selection.processID) title=\(selection.title ?? "")") + } + + private func resolveMovedWindowID(for selection: PreferredSelection) -> CGWindowID? { + let app = AXUIElementCreateApplication(selection.processID) + var windowsRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &windowsRef) == .success, + let windows = windowsRef as? [AXUIElement] else { return nil } + + let wantedTitle = Self.normalizedTitle(selection.title) + let scored: [(id: CGWindowID, score: CGFloat)] = windows.compactMap { win in + guard let id = axWindowID(win) else { return nil } + var score: CGFloat = 0 + if let targetFrame = selection.targetFrame { + guard let frame = axFrame(win) else { return nil } + let distance = Self.centerDistance(frame, targetFrame) + let maxDistance = max(CGFloat(300), hypot(targetFrame.width, targetFrame.height) * 0.75) + guard distance <= maxDistance else { return nil } + score += Self.frameDistanceScore(frame, targetFrame) + } + if !wantedTitle.isEmpty, Self.normalizedTitle(axTitle(win)) != wantedTitle { + score += 3_000 + } + return (id, score) + } + return scored.min(by: { $0.score < $1.score })?.id + } + + private func axWindowID(_ win: AXUIElement) -> CGWindowID? { + var wid: CGWindowID = 0 + guard _AXUIElementGetWindow(win, &wid) == .success, wid != 0 else { return nil } + return wid + } + + private func axTitle(_ win: AXUIElement) -> String? { + var titleRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef) == .success else { return nil } + return titleRef as? String + } + + private func axFrame(_ win: AXUIElement) -> CGRect? { + var positionRef: CFTypeRef? + var sizeRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &positionRef) == .success, + AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef) == .success, + let positionValue = positionRef, + let sizeValue = sizeRef, + CFGetTypeID(positionValue) == AXValueGetTypeID(), + CFGetTypeID(sizeValue) == AXValueGetTypeID() else { return nil } + var position = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &position), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil } + return CGRect(origin: position, size: size) + } + + private func setAXWindowFrame(pid: pid_t, windowID: CGWindowID, title: String?, frame: CGRect) -> WindowMoveResult? { + let app = AXUIElementCreateApplication(pid) + var windowsRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &windowsRef) == .success, + let windows = windowsRef as? [AXUIElement] else { return nil } + let target = axWindow(in: windows, windowID: windowID, title: title) + guard let target else { return nil } + var position = CGPoint(x: frame.minX, y: frame.minY) + var size = CGSize(width: frame.width, height: frame.height) + guard let positionValue = AXValueCreate(.cgPoint, &position), + let sizeValue = AXValueCreate(.cgSize, &size) else { return nil } + let sizeResult = AXUIElementSetAttributeValue(target, kAXSizeAttribute as CFString, sizeValue) + let posResult = AXUIElementSetAttributeValue(target, kAXPositionAttribute as CFString, positionValue) + guard posResult == .success && sizeResult == .success else { return nil } + + var newWindowID: CGWindowID = 0 + let resolvedID = (_AXUIElementGetWindow(target, &newWindowID) == .success && newWindowID != 0) ? newWindowID : nil + return WindowMoveResult(windowID: resolvedID) + } + + private func axWindow(in windows: [AXUIElement], windowID: CGWindowID, title: String?) -> AXUIElement? { + for win in windows { + var wid: CGWindowID = 0 + if _AXUIElementGetWindow(win, &wid) == .success, wid == windowID { + return win + } + } + guard let title, !title.isEmpty else { return nil } + for win in windows { + var titleRef: CFTypeRef? + AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef) + if let t = titleRef as? String, t == title { return win } + } + return nil + } + private func pressCloseButton(pid: pid_t, windowID: CGWindowID) { let app = AXUIElementCreateApplication(pid) var windowsRef: CFTypeRef? @@ -629,17 +1328,24 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> for t in toStop { t.suppressFrames = true } stopActivityTimer() let w = window - let animate = config.animations && w != nil && w!.alphaValue > 0 + let animate = config.windowScopeMode != .allSpaces && config.animations && w != nil && w!.alphaValue > 0 visible = false - if activatePrevious, prevFrontPID != 0, - let app = NSRunningApplication(processIdentifier: prevFrontPID) { - app.activate() + if activatePrevious { + if let focus = returnFocus { + activatePreferredWindow(focus) + } else if prevFrontPID != 0, + let app = NSRunningApplication(processIdentifier: prevFrontPID) { + app.activate() + } } prevFrontPID = 0 + prevFrontWindowID = nil tiles = [] allTiles = [] selectedIndex = 0 lastLetterJump = nil + preferredSelection = nil + returnFocus = nil searching = false searchQuery = "" pickBuffer = "" @@ -656,16 +1362,24 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> savedFrames = [] let clearLayers = { [weak self] in guard let self else { return } - if let root = self.window?.contentView?.layer { - root.sublayers?.forEach { layer in - if layer !== self.backgroundLayer { layer.removeFromSuperlayer() } + let windows = self.windowsByDisplay.isEmpty ? self.window.map { [$0] } ?? [] : Array(self.windowsByDisplay.values) + let backgrounds = Set(self.backgroundLayersByDisplay.values.map { ObjectIdentifier($0) }) + for win in windows { + if let root = win.contentView?.layer { + root.sublayers?.forEach { layer in + if !backgrounds.contains(ObjectIdentifier(layer)), layer !== self.backgroundLayer { + layer.removeFromSuperlayer() + } + } } } + self.spaceHeaderLayers = [] // Reset the backdrop so the next show starts opaque again. // pick() animates this to 0 and we never animate it back up. CATransaction.begin() CATransaction.setDisableActions(true) self.backgroundLayer?.opacity = 1 + for bg in self.backgroundLayersByDisplay.values { bg.opacity = 1 } CATransaction.commit() } if animate, let w { @@ -677,7 +1391,11 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> } } } else { - w?.orderOut(nil) + if self.config.windowScopeMode == .allSpaces { + for win in windowsByDisplay.values { win.orderOut(nil) } + } else { + w?.orderOut(nil) + } clearLayers() } } @@ -691,23 +1409,77 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> } private func layoutTiles(in bounds: NSRect) { + if config.windowScopeMode == .allSpaces { + layoutTilesAcrossDisplays() + return + } + let screenSize = activeScreen?.frame.size ?? NSScreen.main?.frame.size ?? bounds.size let ar = screenSize.width / max(1, screenSize.height) let (rects, cols) = GridLayout.frames(count: tiles.count, bounds: bounds, aspectRatio: ar) gridCols = cols for (tile, cell) in zip(tiles, rects) { - let src = tile.window.frame - let srcAR = src.width / max(1, src.height) - let cellAR = cell.width / max(1, cell.height) - let fitted: CGRect - if srcAR > cellAR { - let h = cell.width / srcAR - fitted = CGRect(x: cell.minX, y: cell.midY - h / 2, width: cell.width, height: h) - } else { - let w = cell.height * srcAR - fitted = CGRect(x: cell.midX - w / 2, y: cell.minY, width: w, height: cell.height) + tile.setFrame(fittedFrame(for: tile, in: cell)) + } + } + + private func fittedFrame(for tile: Tile, in cell: CGRect) -> CGRect { + let src = tile.window.frame + let srcAR = src.width / max(1, src.height) + let cellAR = cell.width / max(1, cell.height) + if srcAR > cellAR { + let h = cell.width / srcAR + return CGRect(x: cell.minX, y: cell.midY - h / 2, width: cell.width, height: h) + } else { + let w = cell.height * srcAR + return CGRect(x: cell.midX - w / 2, y: cell.minY, width: w, height: cell.height) + } + } + + private func clearSpaceHeaders() { + for layer in spaceHeaderLayers { layer.removeFromSuperlayer() } + spaceHeaderLayers = [] + } + + private func layoutTilesAcrossDisplays() { + clearSpaceHeaders() + gridCols = 1 + for screen in NSScreen.screens { + let screenTiles = tiles.filter { tile in + Self.screenForWindow(tile.window, in: [screen]) != nil + } + guard !screenTiles.isEmpty, + let w = windowsByDisplay[Self.displayKeyString(for: screen)], + let root = w.contentView?.layer else { continue } + let screenRect = root.bounds + let layoutBounds = screenRect + let ar = screen.visibleFrame.width / max(1, screen.visibleFrame.height) + let (rects, cols) = GridLayout.frames(count: screenTiles.count, bounds: layoutBounds, aspectRatio: ar) + gridCols = max(gridCols, cols) + for (tile, cell) in zip(screenTiles, rects) { + tile.setFrame(fittedFrame(for: tile, in: cell)) } - tile.setFrame(fitted) + } + } + + private func orderedBySpace(_ source: [Tile]) -> [(spaceID: CGSSpaceID?, title: String, tiles: [Tile])] { + guard !source.isEmpty else { return [] } + let grouped = Dictionary(grouping: source) { $0.window.spaceID } + let spaces = tracker.spaces() + let present = Set(source.compactMap { $0.window.spaceID }) + var orderedIDs: [CGSSpaceID?] = [] + orderedIDs.append(contentsOf: spaces.filter { $0.isActive && present.contains($0.id) }.map { Optional($0.id) }) + orderedIDs.append(contentsOf: spaces.filter { !$0.isActive && present.contains($0.id) }.map { Optional($0.id) }) + for id in present where !orderedIDs.contains(Optional(id)) { orderedIDs.append(Optional(id)) } + if grouped[nil] != nil { orderedIDs.append(nil) } + + return orderedIDs.compactMap { id in + guard let groupTiles = grouped[id], !groupTiles.isEmpty else { return nil } + let sample = groupTiles[0].window + let base = sample.spaceLabel ?? sample.spaceID.map { "Space \($0)" } ?? "Unknown Space" + let current = sample.isOnActiveSpace ? "\(base) (current)" : base + let title = groupTiles.count == 1 ? current : "\(current) · \(groupTiles.count) windows" + return (spaceID: id, title: title, tiles: groupTiles) } } @@ -719,6 +1491,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> private func move(dx: Int, dy: Int) { guard !tiles.isEmpty, !isZoomed else { return } + if config.windowScopeMode == .allSpaces { + moveWithinSelectedDisplay(dx: dx, dy: dy) + return + } let cols = max(1, gridCols) let row = selectedIndex / cols let col = selectedIndex % cols @@ -733,6 +1509,73 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> updateSelection() } + private func moveWithinSelectedDisplay(dx: Int, dy: Int) { + guard tiles.indices.contains(selectedIndex), + let selectedScreen = Self.screenForWindow(tiles[selectedIndex].window, in: NSScreen.screens) else { return } + let source = tiles[selectedIndex].layer.frame.center + let sameScreenIndexes = tiles.indices.filter { idx in + Self.screenForWindow(tiles[idx].window, in: [selectedScreen]) != nil + } + let candidateIndexes = sameScreenIndexes.filter { idx in + guard idx != selectedIndex else { return false } + let c = tiles[idx].layer.frame.center + if dx < 0 { return c.x < source.x - 4 } + if dx > 0 { return c.x > source.x + 4 } + if dy < 0 { return c.y > source.y + 4 } + if dy > 0 { return c.y < source.y - 4 } + return false + } + if let best = bestSpatialCandidate(from: source, candidates: candidateIndexes, dx: dx, dy: dy) { + selectedIndex = best + updateSelection() + return + } + + if dx < 0, sameScreenIndexes.min() == selectedIndex { + moveToAdjacentDisplay(from: selectedScreen, direction: -1) + } else if dx > 0, sameScreenIndexes.max() == selectedIndex { + moveToAdjacentDisplay(from: selectedScreen, direction: 1) + } + } + + private func bestSpatialCandidate(from source: CGPoint, candidates: [Int], dx: Int, dy: Int) -> Int? { + candidates.min(by: { a, b in + let ca = tiles[a].layer.frame.center + let cb = tiles[b].layer.frame.center + let primaryA = dx == 0 ? abs(ca.y - source.y) : abs(ca.x - source.x) + let primaryB = dx == 0 ? abs(cb.y - source.y) : abs(cb.x - source.x) + if abs(primaryA - primaryB) > 1 { return primaryA < primaryB } + let crossA = dx == 0 ? abs(ca.x - source.x) : abs(ca.y - source.y) + let crossB = dx == 0 ? abs(cb.x - source.x) : abs(cb.y - source.y) + return crossA < crossB + }) + } + + private func moveToAdjacentDisplay(from screen: NSScreen, direction: Int) { + let orderedScreens = NSScreen.screens.sorted { a, b in + if abs(a.frame.minX - b.frame.minX) > 1 { return a.frame.minX < b.frame.minX } + return a.frame.minY < b.frame.minY + } + guard let pos = orderedScreens.firstIndex(of: screen) else { return } + var targetIndexes: [Int] = [] + var steps = 0 + var nextPos = pos + while steps < orderedScreens.count { + nextPos = (nextPos + direction + orderedScreens.count) % orderedScreens.count + let targetScreen = orderedScreens[nextPos] + targetIndexes = tiles.indices.filter { idx in + Self.screenForWindow(tiles[idx].window, in: [targetScreen]) != nil + } + if !targetIndexes.isEmpty { break } + steps += 1 + } + guard !targetIndexes.isEmpty else { return } + let targetScreen = orderedScreens[nextPos] + selectedIndex = direction < 0 ? (targetIndexes.max() ?? selectedIndex) : (targetIndexes.min() ?? selectedIndex) + focusOverlay(on: targetScreen) + updateSelection() + } + private func pick() { guard tiles.indices.contains(selectedIndex), !isPicking else { return } let tile = tiles[selectedIndex] @@ -743,10 +1586,13 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> prevPickedWindowID = windowID isPicking = true - guard let w = window, config.animations else { + guard let w = overlayWindow(for: tile.window) ?? window, + config.animations, + config.windowScopeMode != .allSpaces else { + activateSpaceIfNeeded(for: tile.window) raiseAXWindow(pid: pid, windowID: windowID, title: title) if let app = NSRunningApplication(processIdentifier: pid) { - app.activate() + app.activate(options: [.activateAllWindows]) } raiseAXWindow(pid: pid, windowID: windowID, title: title) hide(activatePrevious: false) @@ -787,9 +1633,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> // before any real-window reorder happens. DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self else { return } + self.activateSpaceIfNeeded(for: tile.window) self.raiseAXWindow(pid: pid, windowID: windowID, title: title) if let app = NSRunningApplication(processIdentifier: pid) { - app.activate() + app.activate(options: [.activateAllWindows]) } self.raiseAXWindow(pid: pid, windowID: windowID, title: title) // Give WindowServer time to actually reorder before we drop @@ -825,6 +1672,13 @@ private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> CATransaction.commit() } + private func activateSpaceIfNeeded(for window: WindowInfo) { + guard config.windowScopeMode == .allSpaces, + !window.isOnActiveSpace, + let spaceID = window.spaceID else { return } + _ = tracker.activateSpace(spaceID) + } + private func raiseAXWindow(pid: pid_t, windowID: CGWindowID, title: String?) { let app = AXUIElementCreateApplication(pid) var windowsRef: CFTypeRef? diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index 703a785..ae2aa32 100644 --- a/Sources/cmdcmd/SettingsWindow.swift +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -11,7 +11,7 @@ final class SettingsWindowController: NSWindowController { init(config: Config) { model = SettingsModel(config: config) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 460, height: 680), + contentRect: NSRect(x: 0, y: 0, width: 460, height: 740), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -34,6 +34,7 @@ private final class SettingsModel: ObservableObject { @Published var letterJump: Bool { didSet { save() } } @Published var usageOrdering: Bool { didSet { save() } } @Published var tilePicks: TilePicks { didSet { save() } } + @Published var windowScope: WindowScope { didSet { save() } } private var base: Config @Published var status: String = "" var onSave: ((Config) -> Void)? @@ -46,6 +47,7 @@ private final class SettingsModel: ObservableObject { letterJump = config.letterJumpEnabled usageOrdering = config.usageOrderingEnabled tilePicks = config.tilePicksMode + windowScope = config.windowScopeMode base = config } @@ -58,11 +60,13 @@ private final class SettingsModel: ObservableObject { config.letterJump = letterJump config.usageOrdering = usageOrdering config.tilePicks = tilePicks + config.windowScope = windowScope.rawValue do { try Config.patchOnDisk([ ("animations", animations ? "true" : "false"), ("animationSpeed", jsonNumber(animationSpeed)), ("livePreviews", livePreviews ? "true" : "false"), + ("windowScope", "\"\(windowScope.rawValue)\""), ("displayMode", "\"\(displayMode.rawValue)\""), ("letterJump", letterJump ? "true" : "false"), ("usageOrdering", usageOrdering ? "true" : "false"), @@ -164,6 +168,19 @@ private struct SettingsRootView: View { } .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 6) { + Text("Window scope").font(.system(size: 13, weight: .medium)) + Picker("", selection: $model.windowScope) { + Text("Current Space").tag(WindowScope.currentSpace) + Text("All Spaces").tag(WindowScope.allSpaces) + } + .labelsHidden() + .pickerStyle(.segmented) + Text("All Spaces includes windows from other macOS desktops when WindowServer exposes them.") + .font(.caption) + .foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 6) { Text("Tile labels").font(.system(size: 13, weight: .medium)) Picker("", selection: $model.tilePicks) { @@ -203,6 +220,6 @@ private struct SettingsRootView: View { } } .padding(24) - .frame(minWidth: 420, minHeight: 640) + .frame(minWidth: 420, minHeight: 700) } } diff --git a/Sources/cmdcmd/SpaceTracker.swift b/Sources/cmdcmd/SpaceTracker.swift index de31210..d6b3a7a 100644 --- a/Sources/cmdcmd/SpaceTracker.swift +++ b/Sources/cmdcmd/SpaceTracker.swift @@ -15,6 +15,12 @@ func CGSGetActiveSpace(_ cid: CGSConnectionID) -> CGSSpaceID @_silgen_name("CGSCopySpacesForWindows") func CGSCopySpacesForWindows(_ cid: CGSConnectionID, _ mask: Int32, _ windows: CFArray) -> CFArray +@_silgen_name("CGSCopyWindowsWithOptionsAndTags") +func CGSCopyWindowsWithOptionsAndTags(_ cid: CGSConnectionID, _ owner: UInt32, _ spaces: CFArray, _ options: UInt32, _ setTags: UnsafePointer?, _ clearTags: UnsafePointer?) -> CFArray? + +@_silgen_name("CGSManagedDisplaySetCurrentSpace") +func CGSManagedDisplaySetCurrentSpace(_ cid: CGSConnectionID, _ displayUUID: CFString, _ spaceID: CGSSpaceID) -> CGError + enum SpaceType: Int { case user = 0 case fullscreen = 4 @@ -103,7 +109,67 @@ final class SpaceTracker { CGSGetActiveSpace(cid) } - private func spacesForWindows(_ ids: [CGWindowID]) -> [CGWindowID: CGSSpaceID] { + func windowIDsBySpace() -> [(windowID: CGWindowID, spaceID: CGSSpaceID)] { + var result: [(windowID: CGWindowID, spaceID: CGSSpaceID)] = [] + var seen: Set = [] + for space in spaces() where space.type != .system { + var setTags: UInt64 = 0 + var clearTags: UInt64 = 0 + let ids = CGSCopyWindowsWithOptionsAndTags( + cid, + 0, + [NSNumber(value: space.id)] as CFArray, + 0, + &setTags, + &clearTags + ) as? [NSNumber] ?? [] + for n in ids { + let id = CGWindowID(n.uint32Value) + guard !seen.contains(id) else { continue } + seen.insert(id) + result.append((windowID: id, spaceID: space.id)) + } + } + return result + } + + func spaceLabels() -> [CGSSpaceID: String] { + var labels: [CGSSpaceID: String] = [:] + var desktopByDisplay: [String: Int] = [:] + var fullscreenCount = 0 + var tiledCount = 0 + for space in spaces() { + switch space.type { + case .user: + let next = (desktopByDisplay[space.displayUUID] ?? 0) + 1 + desktopByDisplay[space.displayUUID] = next + labels[space.id] = "Desktop \(next)" + case .fullscreen: + fullscreenCount += 1 + labels[space.id] = "Fullscreen \(fullscreenCount)" + case .tiled: + tiledCount += 1 + labels[space.id] = "Tiled \(tiledCount)" + case .system: + labels[space.id] = "System" + } + } + return labels + } + + @discardableResult + func activateSpace(_ id: CGSSpaceID) -> Bool { + guard let space = spaces().first(where: { $0.id == id }) else { return false } + let err = CGSManagedDisplaySetCurrentSpace(cid, space.displayUUID as CFString, id) + if err != .success { + Log.write("space switch failed id=\(id) display=\(space.displayUUID) err=\(err.rawValue)") + return false + } + Log.write("space switch requested id=\(id) display=\(space.displayUUID)") + return true + } + + func spacesForWindows(_ ids: [CGWindowID]) -> [CGWindowID: CGSSpaceID] { guard !ids.isEmpty else { return [:] } let arr = ids.map { NSNumber(value: $0) } as CFArray let result = CGSCopySpacesForWindows(cid, 0x7, arr) diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 2643b3b..03874c1 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -58,6 +58,7 @@ final class Tile: NSObject { private let numberText: CATextLayer private let titlePill: CALayer private let titleText: CATextLayer + private let fallbackText: CATextLayer private let idleDot: CALayer private var lastSignificantChangeAt: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() private(set) var isIdle: Bool = false @@ -103,6 +104,17 @@ final class Tile: NSObject { dot.opacity = 0 inner.addSublayer(dot) + let fallback = CATextLayer() + fallback.alignmentMode = .center + fallback.foregroundColor = NSColor.white.withAlphaComponent(0.72).cgColor + fallback.backgroundColor = NSColor.clear.cgColor + fallback.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + fallback.fontSize = 13 + fallback.isWrapped = true + fallback.truncationMode = .end + fallback.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + inner.addSublayer(fallback) + let chipText = CATextLayer() chipText.alignmentMode = .center chipText.foregroundColor = NSColor.white.cgColor @@ -136,18 +148,26 @@ final class Tile: NSObject { self.numberText = chipText self.titlePill = pill self.titleText = pillText + self.fallbackText = fallback self.idleDot = dot // kCGWindowName needs Screen Recording on macOS 12.3+, which we no // longer ask for. Fall back to the owning-app name so the pill still // labels each tile. let rawTitle = (window.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - self.windowTitle = rawTitle.isEmpty ? window.applicationName : rawTitle + let baseTitle = rawTitle.isEmpty ? window.applicationName : rawTitle + if let spaceLabel = window.spaceLabel, !window.isOnActiveSpace { + self.windowTitle = "\(baseTitle) · \(spaceLabel)" + } else { + self.windowTitle = baseTitle + } + fallback.string = self.windowTitle super.init() if let cached = Tile.cachedFrame(for: window.windowID) { CATransaction.begin() CATransaction.setDisableActions(true) inner.contents = cached + fallback.isHidden = true CATransaction.commit() } } @@ -250,6 +270,8 @@ final class Tile: NSObject { let lineHeight = ceil(font.ascender - font.descender) let textY = (badgeHeight - lineHeight) / 2 + fallbackText.frame = rect.insetBy(dx: 16, dy: 36) + let chipHidden = numberChip.isHidden let chipText = (currentLabel ?? "") let chipWidth: CGFloat @@ -357,6 +379,7 @@ final class Tile: NSObject { CATransaction.begin() CATransaction.setDisableActions(true) self.content.contents = image + self.fallbackText.isHidden = true CATransaction.commit() self.hasRenderedFrame = true self.lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() @@ -385,6 +408,7 @@ final class Tile: NSObject { CATransaction.begin() CATransaction.setDisableActions(true) self.content.contents = image + self.fallbackText.isHidden = true CATransaction.commit() self.hasRenderedFrame = true self.hasRenderedLiveFrame = true diff --git a/Sources/cmdcmd/WindowInfo.swift b/Sources/cmdcmd/WindowInfo.swift index 7bc5506..ce7eb9e 100644 --- a/Sources/cmdcmd/WindowInfo.swift +++ b/Sources/cmdcmd/WindowInfo.swift @@ -14,13 +14,37 @@ struct WindowInfo { let processID: pid_t let layer: Int let isOnScreen: Bool + let spaceID: CGSSpaceID? + let isOnActiveSpace: Bool + let spaceLabel: String? - /// All currently on-screen windows, in WindowServer Z-order (front-most first). - static func enumerate() -> [WindowInfo] { - let opts: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] - guard let raw = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: Any]] else { - return [] + /// Window snapshots in WindowServer Z-order (front-most first). + /// `.currentSpace` uses the old on-screen-only path; `.allSpaces` asks + /// WindowServer for every known window and annotates each one with a Space + /// when CGS exposes that relationship. + static func enumerate(scope: WindowScope = .currentSpace, tracker: SpaceTracker? = nil) -> [WindowInfo] { + let raw: [[String: Any]] + let spaceMap: [CGWindowID: CGSSpaceID] + if scope == .allSpaces, let tracker { + let pairs = tracker.windowIDsBySpace() + raw = pairs.compactMap { pair in + (CGWindowListCopyWindowInfo([.optionIncludingWindow, .excludeDesktopElements], pair.windowID) as? [[String: Any]])?.first + } + spaceMap = Dictionary(uniqueKeysWithValues: pairs.map { ($0.windowID, $0.spaceID) }) + } else { + let opts: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let listed = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: Any]] else { + return [] + } + raw = listed + let ids = raw.compactMap { entry -> CGWindowID? in + guard let id = entry[kCGWindowNumber as String] as? UInt32 else { return nil } + return CGWindowID(id) + } + spaceMap = tracker?.spacesForWindows(ids) ?? [:] } + let activeSpace = tracker?.activeSpace() + let spaceLabels = tracker?.spaceLabels() ?? [:] var bundleCache: [pid_t: String?] = [:] return raw.compactMap { entry in guard let id = entry[kCGWindowNumber as String] as? UInt32, @@ -32,7 +56,15 @@ struct WindowInfo { let owner = (entry[kCGWindowOwnerName as String] as? String) ?? "" let title = entry[kCGWindowName as String] as? String let layer = (entry[kCGWindowLayer as String] as? Int) ?? 0 + let windowID = CGWindowID(id) let onScreen = (entry[kCGWindowIsOnscreen as String] as? Bool) ?? false + let spaceID = spaceMap[windowID] + let isOnActiveSpace: Bool + if let activeSpace, let spaceID { + isOnActiveSpace = onScreen || activeSpace == spaceID + } else { + isOnActiveSpace = onScreen + } let bundleID: String? if let cached = bundleCache[pid] { bundleID = cached @@ -42,15 +74,34 @@ struct WindowInfo { bundleID = resolved } return WindowInfo( - windowID: CGWindowID(id), + windowID: windowID, frame: frame, title: title, applicationName: owner, bundleIdentifier: bundleID, processID: pid, layer: layer, - isOnScreen: onScreen + isOnScreen: onScreen, + spaceID: spaceID, + isOnActiveSpace: isOnActiveSpace, + spaceLabel: spaceID.flatMap { spaceLabels[$0] } ) } } + + func replacing(windowID: CGWindowID? = nil, frame: CGRect, isOnScreen: Bool? = nil, isOnActiveSpace: Bool? = nil) -> WindowInfo { + WindowInfo( + windowID: windowID ?? self.windowID, + frame: frame, + title: title, + applicationName: applicationName, + bundleIdentifier: bundleIdentifier, + processID: processID, + layer: layer, + isOnScreen: isOnScreen ?? self.isOnScreen, + spaceID: spaceID, + isOnActiveSpace: isOnActiveSpace ?? self.isOnActiveSpace, + spaceLabel: spaceLabel + ) + } } diff --git a/Sources/cmdcmd/main.swift b/Sources/cmdcmd/main.swift index 613411d..1751b41 100644 --- a/Sources/cmdcmd/main.swift +++ b/Sources/cmdcmd/main.swift @@ -3,6 +3,15 @@ import CoreGraphics import Sparkle let args = CommandLine.arguments +if args.contains("--self-test-cmd-chord") { + let failures = CmdChord.selfTestFailures() + if failures.isEmpty { + print("cmd chord self-test passed") + exit(0) + } + FileHandle.standardError.write(Data(("cmd chord self-test failed:\n" + failures.map { "- \($0)" }.joined(separator: "\n") + "\n").utf8)) + exit(1) +} if let i = args.firstIndex(of: "--render-iconset"), i + 1 < args.count { let url = URL(fileURLWithPath: args[i + 1]) do {