From 1b65515125ec38beb1131bccae4edca67fb32dfa Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Tue, 25 Aug 2026 21:36:48 +0200 Subject: [PATCH 1/2] feat: add interactive workspace overview - Add configurable workspace overview with zoom and window previews - Support navigation, focus, and drag-and-drop window placement --- CONFIGURATION.md | 40 + CONTEXT.md | 15 + Sources/DefiConfig/Config.swift | 10 + Sources/DefiConfig/ConfigModels.swift | 22 + Sources/DefiCore/Overview.swift | 480 ++++ Sources/DefiDaemon/DaemonCommands.swift | 5 + .../DaemonDesktopReconciliation.swift | 4 +- .../DaemonDesktopSynchronization.swift | 4 + Sources/DefiDaemon/DaemonOverview.swift | 394 ++++ .../DefiDaemon/DaemonStatusLifecycle.swift | 13 +- Sources/DefiDaemon/DefiDaemon.swift | 4 + Sources/DefiMacOS/HotKeyModels.swift | 27 +- Sources/DefiMacOS/HotKeys.swift | 50 + ...acOSPlatform+WindowBorderObservation.swift | 25 +- Sources/DefiMacOS/OverviewController.swift | 2003 +++++++++++++++++ .../DefiMacOS/OverviewPreviewCapture.swift | 241 ++ Sources/DefiMacOS/Platform.swift | 27 + Sources/DefiMacOS/SnapshotEngine.swift | 36 +- Sources/DefiMacOS/WindowBorderManager.swift | 13 + Sources/DefiMacOS/WindowBorderPlanning.swift | 45 + .../DefiMacOS/WindowDiscoverySupport.swift | 81 +- Sources/DefiModel/Command.swift | 1 + Sources/DefiModel/CommandParser.swift | 2 + Sources/DefiRuntime/CommandReducer.swift | 12 +- Sources/DefiRuntime/OverviewRuntime.swift | 345 +++ Support/Defi-Info.plist | 2 + Tests/DefiConfigTests/ConfigTests.swift | 33 + Tests/DefiCoreTests/OverviewTests.swift | 407 ++++ Tests/DefiMacOSTests/DesktopE2ETests.swift | 36 + Tests/DefiMacOSTests/HotKeyTests.swift | 33 +- .../DefiMacOSTests/OverviewPreviewTests.swift | 142 ++ .../DefiMacOSTests/OverviewScrollTests.swift | 120 + Tests/DefiMacOSTests/WindowBorderTests.swift | 77 + .../DefiMacOSTests/WindowDiscoveryTests.swift | 32 + Tests/DefiModelTests/ModelTests.swift | 2 + .../OverviewRuntimeTests.swift | 295 +++ ...-keep-overview-independent-from-capture.md | 15 + docs/plans/overview.md | 305 +++ .../macos-overview-implementations.md | 157 ++ 39 files changed, 5497 insertions(+), 58 deletions(-) create mode 100644 Sources/DefiCore/Overview.swift create mode 100644 Sources/DefiDaemon/DaemonOverview.swift create mode 100644 Sources/DefiMacOS/OverviewController.swift create mode 100644 Sources/DefiMacOS/OverviewPreviewCapture.swift create mode 100644 Sources/DefiRuntime/OverviewRuntime.swift create mode 100644 Tests/DefiCoreTests/OverviewTests.swift create mode 100644 Tests/DefiMacOSTests/OverviewPreviewTests.swift create mode 100644 Tests/DefiMacOSTests/OverviewScrollTests.swift create mode 100644 Tests/DefiRuntimeTests/OverviewRuntimeTests.swift create mode 100644 docs/adr/0003-keep-overview-independent-from-capture.md create mode 100644 docs/plans/overview.md create mode 100644 docs/research/macos-overview-implementations.md diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 9301606..b42c656 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -115,6 +115,40 @@ duration_ms = 35 | `enabled` | `true` | boolean | Enables visual scrolling and managed resize animation. | | `duration_ms` | `35` | integer from `0` to `2000` | Animation duration in milliseconds. `0` disables animation even when `enabled = true`. | +## `[overview]` + +Controls the Overview scale and optional pixels inside window cards. + +```toml +[overview] +zoom = 0.5 +window_previews = false +``` + +| Setting | Default | Values/type | Description | +| --- | --- | --- | --- | +| `zoom` | `0.5` | number from `0` to `0.75` | Scales workspaces and windows. Lower values show more of the neighboring workspaces. | +| `window_previews` | `false` | boolean | Captures a card-sized still image when a window first becomes visible in the current Overview session. | + +With the default `false`, Defi performs no Screen Recording permission check, +request, or ScreenCaptureKit content query. With `true`, the next Overview +opening reuses a valid in-memory preview when available, then captures a fresh +image. Denial, revocation, protected content, and capture errors leave the +icon-and-title cards fully usable and do not trigger repeated prompts in the +same daemon session. + +Previews are memory-only, contain no audio or cursor, and use at most 16 MiB +between Overview sessions. Defi validates the window and process identity before +reuse, requests a fresh image immediately, and removes the remembered image if +that capture fails. A full-display screen share can include the Overview and the +content shown in its cards; sharing one selected application or window does not +normally include Defi's overlay. + +When dragging a tiled card, the outer quarter on either side of a column creates +a neighboring column. Its central half inserts the card into the stack according +to pointer height. The Overview animates the projected ribbons immediately; +native windows receive only the final layout while the overlay is visible. + ## `[decorations.borders]` Controls borders around visible tiled windows. @@ -311,6 +345,7 @@ an entry from `[workspaces].names`. | `join-window left` | Join focused window into stack on left. | `-semicolon` | | `join-window right` | Join focused window into stack on right. | `-quote` | | `unjoin-windows` | Split focused window from stack into new column. | `-r` | +| `toggle-overview` | Open or close the interactive workspace Overview. | `-o` | | `diagnostic-mark` | Record current status and recent trace without changing managed windows. | unset | `previous` may be written as `prev`. `focus-column` also accepts @@ -436,6 +471,10 @@ enabled = true enabled = true duration_ms = 35 +[overview] +zoom = 0.5 +window_previews = false + [decorations.borders] enabled = true width = 4 @@ -456,6 +495,7 @@ default = "1" "alt-right" = "focus-column right" "alt-up" = "focus-window up" "alt-down" = "focus-window down" +"alt-o" = "toggle-overview" "alt-leftbracket" = "focus-column first" "alt-rightbracket" = "focus-column last" diff --git a/CONTEXT.md b/CONTEXT.md index 7dfdae1..09acf24 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,21 @@ The offscreen placement of windows from inactive workspaces without hiding or minimizing them. _Avoid_: Hiding, minimizing +**Overview**: +An interactive stack of full-width workspace ribbons. Each ribbon shows a +uniformly scaled view of its scrolling strip and exposes normal navigation and +window movement. + +**Overview viewport**: +The temporary two-axis view into an Overview. Moving it does not change native +window frames or a workspace's scrolling offset. +_Avoid_: Workspace scroll offset + +**Window preview**: +A non-authoritative captured image of a managed window shown in the Overview. +Its absence or staleness never changes Overview behavior. +_Avoid_: Live window, window mirror, screenshot + ## Focus and responsiveness **Human focus intent**: diff --git a/Sources/DefiConfig/Config.swift b/Sources/DefiConfig/Config.swift index 79b2cbe..20165bd 100644 --- a/Sources/DefiConfig/Config.swift +++ b/Sources/DefiConfig/Config.swift @@ -6,6 +6,7 @@ public struct Config: Equatable, Sendable { public var input: InputConfig public var layout: LayoutConfig public var animation: AnimationConfig + public var overview: OverviewConfig public var decorations: DecorationsConfig public var menuBar: MenuBarConfig public var workspaces: WorkspacesConfig @@ -18,6 +19,7 @@ public struct Config: Equatable, Sendable { input: InputConfig = InputConfig(), layout: LayoutConfig = LayoutConfig(), animation: AnimationConfig = AnimationConfig(), + overview: OverviewConfig = OverviewConfig(), decorations: DecorationsConfig = DecorationsConfig(), menuBar: MenuBarConfig = MenuBarConfig(), workspaces: WorkspacesConfig = WorkspacesConfig(), @@ -29,6 +31,7 @@ public struct Config: Equatable, Sendable { self.input = input self.layout = layout self.animation = animation + self.overview = overview self.decorations = decorations self.menuBar = menuBar self.workspaces = workspaces @@ -49,6 +52,7 @@ public struct Config: Equatable, Sendable { input: raw.input ?? InputConfig(), layout: raw.layout ?? LayoutConfig(), animation: raw.animation ?? AnimationConfig(), + overview: raw.overview ?? OverviewConfig(), decorations: raw.decorations ?? DecorationsConfig(), menuBar: raw.menuBar ?? MenuBarConfig(), workspaces: workspaces, @@ -114,6 +118,9 @@ public struct Config: Equatable, Sendable { guard (0...2_000).contains(animation.durationMS) else { throw ConfigError.invalidValue("animation.duration_ms") } + guard overview.zoom.isFinite, (0...0.75).contains(overview.zoom) else { + throw ConfigError.invalidValue("overview.zoom") + } guard (0...64).contains(decorations.borders.width) else { throw ConfigError.invalidValue("decorations.borders.width") } @@ -220,6 +227,7 @@ public struct Config: Equatable, Sendable { "\(modifier)-semicolon": "join-window left", "\(modifier)-quote": "join-window right", "\(modifier)-r": "unjoin-windows", + "\(modifier)-o": "toggle-overview", ] for (index, workspace) in workspaceNames.prefix(9).enumerated() { let number = index + 1 @@ -250,6 +258,7 @@ private struct RawConfig: Decodable { var input: InputConfig? var layout: LayoutConfig? var animation: AnimationConfig? + var overview: OverviewConfig? var decorations: DecorationsConfig? var menuBar: MenuBarConfig? var workspaces: WorkspacesConfig? @@ -262,6 +271,7 @@ private struct RawConfig: Decodable { case input case layout case animation + case overview case decorations case menuBar = "menu_bar" case workspaces diff --git a/Sources/DefiConfig/ConfigModels.swift b/Sources/DefiConfig/ConfigModels.swift index 2cfee50..0dbd15c 100644 --- a/Sources/DefiConfig/ConfigModels.swift +++ b/Sources/DefiConfig/ConfigModels.swift @@ -154,6 +154,28 @@ public struct AnimationConfig: Codable, Equatable, Sendable { } } +public struct OverviewConfig: Codable, Equatable, Sendable { + public var zoom: Double + public var windowPreviews: Bool + + public init(zoom: Double = 0.5, windowPreviews: Bool = false) { + self.zoom = zoom + self.windowPreviews = windowPreviews + } + + enum CodingKeys: String, CodingKey { + case zoom + case windowPreviews = "window_previews" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + zoom = try values.decodeIfPresent(Double.self, forKey: .zoom) ?? 0.5 + windowPreviews = + try values.decodeIfPresent(Bool.self, forKey: .windowPreviews) ?? false + } +} + public struct LayoutConfig: Codable, Equatable, Sendable { public var defaultColumnWidth: Double public var presetColumnWidths: [Double] diff --git a/Sources/DefiCore/Overview.swift b/Sources/DefiCore/Overview.swift new file mode 100644 index 0000000..6bfb70c --- /dev/null +++ b/Sources/DefiCore/Overview.swift @@ -0,0 +1,480 @@ +import DefiModel + +public struct OverviewPoint: Equatable, Sendable { + public let x: Double + public let y: Double + + public init(x: Double, y: Double) { + self.x = x + self.y = y + } +} + +public struct OverviewSnapshot: Equatable, Sendable { + public let monitors: [Monitor] + public let monitorFrames: [MonitorID: Rect] + public let windows: [WindowID: Window] + public let floatingFrames: [WindowID: Rect] + public let activeMonitorID: MonitorID? + public let nativeFullscreenWindowIDs: Set + + public init( + monitors: [Monitor], + monitorFrames: [MonitorID: Rect], + windows: [WindowID: Window], + floatingFrames: [WindowID: Rect] = [:], + activeMonitorID: MonitorID? = nil, + nativeFullscreenWindowIDs: Set = [] + ) { + self.monitors = monitors + self.monitorFrames = monitorFrames + self.windows = windows + self.floatingFrames = floatingFrames + self.activeMonitorID = activeMonitorID + self.nativeFullscreenWindowIDs = nativeFullscreenWindowIDs + } +} + +public struct OverviewViewport: Equatable, Sendable { + public var workspaceOffset: Double + public var horizontalOffsets: [WorkspaceID: Double] + + public init( + workspaceOffset: Double = 0, + horizontalOffsets: [WorkspaceID: Double] = [:] + ) { + self.workspaceOffset = workspaceOffset + self.horizontalOffsets = horizontalOffsets + } +} + +public func overviewWorkspaceStride( + boundsHeight: Double, + zoom: Double = 0.5, + workspaceGap: Double = 28 +) -> Double { + max((boundsHeight - workspaceGap * 2) * zoom, 1) + workspaceGap +} + +public func interpolateOverviewViewport( + from: OverviewViewport, + to: OverviewViewport, + progress: Double +) -> OverviewViewport { + let progress = min(max(progress, 0), 1) + let workspaceOffset = from.workspaceOffset + + (to.workspaceOffset - from.workspaceOffset) * progress + var horizontalOffsets: [WorkspaceID: Double] = [:] + for workspaceID in Set(from.horizontalOffsets.keys).union(to.horizontalOffsets.keys) { + let start = from.horizontalOffsets[workspaceID] ?? to.horizontalOffsets[workspaceID] ?? 0 + let end = to.horizontalOffsets[workspaceID] ?? start + horizontalOffsets[workspaceID] = start + (end - start) * progress + } + return OverviewViewport( + workspaceOffset: workspaceOffset, + horizontalOffsets: horizontalOffsets + ) +} + +public struct OverviewProjection: Equatable, Sendable { + public let monitorID: MonitorID + public let workspaces: [OverviewWorkspaceProjection] + + public init( + monitorID: MonitorID, + workspaces: [OverviewWorkspaceProjection] + ) { + self.monitorID = monitorID + self.workspaces = workspaces + } + + public func hitTest(_ point: OverviewPoint) -> OverviewHit? { + for workspace in workspaces.reversed() { + for window in workspace.windows.reversed() + where window.frame.contains(point) && workspace.frame.contains(point) { + return .window( + windowID: window.windowID, + monitorID: monitorID, + workspaceID: workspace.workspaceID + ) + } + if workspace.frame.contains(point) { + return .workspace(monitorID: monitorID, workspaceID: workspace.workspaceID) + } + } + return nil + } +} + +public struct OverviewWorkspaceProjection: Equatable, Sendable { + public let workspaceID: WorkspaceID + public let frame: Rect + public let windows: [OverviewWindowProjection] + public let hiddenTiledWindowCountBefore: Int + public let hiddenTiledWindowCountAfter: Int + + public init( + workspaceID: WorkspaceID, + frame: Rect, + windows: [OverviewWindowProjection], + hiddenTiledWindowCountBefore: Int = 0, + hiddenTiledWindowCountAfter: Int = 0 + ) { + self.workspaceID = workspaceID + self.frame = frame + self.windows = windows + self.hiddenTiledWindowCountBefore = hiddenTiledWindowCountBefore + self.hiddenTiledWindowCountAfter = hiddenTiledWindowCountAfter + } +} + +public enum OverviewWindowLayer: Equatable, Sendable { + case tiled(columnIndex: Int, windowIndex: Int) + case floating +} + +public struct OverviewWindowProjection: Equatable, Sendable { + public let windowID: WindowID + public let frame: Rect + public let layer: OverviewWindowLayer + public let isNativeFullscreen: Bool + public let canDrag: Bool + + public init( + windowID: WindowID, + frame: Rect, + layer: OverviewWindowLayer, + isNativeFullscreen: Bool, + canDrag: Bool + ) { + self.windowID = windowID + self.frame = frame + self.layer = layer + self.isNativeFullscreen = isNativeFullscreen + self.canDrag = canDrag + } +} + +public enum OverviewHit: Equatable, Sendable { + case window(windowID: WindowID, monitorID: MonitorID, workspaceID: WorkspaceID) + case workspace(monitorID: MonitorID, workspaceID: WorkspaceID) +} + +public enum OverviewDropTarget: Equatable, Sendable { + case newColumn( + monitorID: MonitorID, + workspaceID: WorkspaceID, + columnIndex: Int + ) + case stack( + monitorID: MonitorID, + workspaceID: WorkspaceID, + columnIndex: Int, + windowIndex: Int + ) + case floating( + monitorID: MonitorID, + workspaceID: WorkspaceID, + relativeFrame: Rect + ) +} + +public func projectOverview( + snapshot: OverviewSnapshot, + monitorID: MonitorID, + bounds: Rect, + viewport: OverviewViewport, + layout: LayoutSettings, + zoom: Double = 0.5, + workspaceGap: Double = 28 +) -> OverviewProjection { + guard let monitor = snapshot.monitors.first(where: { $0.id == monitorID }), + let monitorFrame = snapshot.monitorFrames[monitorID], + monitorFrame.width > 0, + monitorFrame.height > 0, + let activeIndex = monitor.workspaces.firstIndex(where: { + $0.id == monitor.activeWorkspace + }) + else { + return OverviewProjection(monitorID: monitorID, workspaces: []) + } + + let workspaceWidth = bounds.width + let stride = overviewWorkspaceStride( + boundsHeight: bounds.height, + zoom: zoom, + workspaceGap: workspaceGap + ) + let workspaceHeight = stride - workspaceGap + let contentScale = workspaceHeight / monitorFrame.height + let windows = Array(snapshot.windows.values) + var projected: [OverviewWorkspaceProjection] = [] + + for (workspaceIndex, originalWorkspace) in monitor.workspaces.enumerated() { + let centerY = bounds.y + bounds.height / 2 + + (Double(workspaceIndex - activeIndex) - viewport.workspaceOffset) * stride + let workspaceFrame = Rect( + x: bounds.x, + y: centerY - workspaceHeight / 2, + width: workspaceWidth, + height: workspaceHeight + ) + guard workspaceFrame.intersects(bounds) else { continue } + + var workspace = originalWorkspace + workspace.scrollOffset = viewport.horizontalOffsets[workspace.id] + ?? originalWorkspace.scrollOffset + let sourceViewport = Rect( + x: 0, + y: 0, + width: monitorFrame.width, + height: monitorFrame.height + ) + let tiledFrames = Dictionary( + uniqueKeysWithValues: computeLayout( + workspace: workspace, + viewport: sourceViewport, + windows: windows, + settings: layout + ).frames.map { ($0.windowID, $0.frame) } + ) + let tiledTarget = Rect( + x: workspaceFrame.x + workspaceGap, + y: workspaceFrame.y, + width: workspaceFrame.width, + height: workspaceFrame.height + ) + var projectedWindows: [OverviewWindowProjection] = [] + var hiddenTiledWindowCountBefore = 0 + var hiddenTiledWindowCountAfter = 0 + + for (columnIndex, column) in originalWorkspace.columns.enumerated() { + for (windowIndex, windowID) in column.windows.enumerated() { + guard let frame = tiledFrames[windowID] else { continue } + let projectedFrame = transform( + frame, + from: sourceViewport, + to: tiledTarget, + scale: contentScale + ) + if projectedFrame.x + projectedFrame.width <= workspaceFrame.x { + hiddenTiledWindowCountBefore += 1 + continue + } + if projectedFrame.x >= workspaceFrame.x + workspaceFrame.width { + hiddenTiledWindowCountAfter += 1 + continue + } + guard projectedFrame.intersects(workspaceFrame) else { continue } + let isFullscreen = snapshot.nativeFullscreenWindowIDs.contains(windowID) + projectedWindows.append( + OverviewWindowProjection( + windowID: windowID, + frame: projectedFrame, + layer: .tiled(columnIndex: columnIndex, windowIndex: windowIndex), + isNativeFullscreen: isFullscreen, + canDrag: !isFullscreen && snapshot.windows[windowID]?.transientOwnerID == nil + ) + ) + } + } + + for windowID in originalWorkspace.floatingWindows { + guard let frame = snapshot.floatingFrames[windowID] ?? snapshot.windows[windowID]?.frame + else { continue } + let localFrame = Rect( + x: frame.x - monitorFrame.x, + y: frame.y - monitorFrame.y, + width: frame.width, + height: frame.height + ) + let projectedWidth = localFrame.width * contentScale + let sourceRange = max(sourceViewport.width - localFrame.width, 1) + let targetRange = max(workspaceFrame.width - projectedWidth, 0) + let projectedFrame = Rect( + x: workspaceFrame.x + + min(max(localFrame.x / sourceRange, 0), 1) * targetRange, + y: workspaceFrame.y + localFrame.y * contentScale, + width: projectedWidth, + height: localFrame.height * contentScale + ) + guard projectedFrame.intersects(workspaceFrame) else { continue } + let isFullscreen = snapshot.nativeFullscreenWindowIDs.contains(windowID) + projectedWindows.append( + OverviewWindowProjection( + windowID: windowID, + frame: projectedFrame, + layer: .floating, + isNativeFullscreen: isFullscreen, + canDrag: !isFullscreen && snapshot.windows[windowID]?.transientOwnerID == nil + ) + ) + } + + projected.append( + OverviewWorkspaceProjection( + workspaceID: originalWorkspace.id, + frame: workspaceFrame, + windows: projectedWindows, + hiddenTiledWindowCountBefore: hiddenTiledWindowCountBefore, + hiddenTiledWindowCountAfter: hiddenTiledWindowCountAfter + ) + ) + } + + return OverviewProjection(monitorID: monitorID, workspaces: projected) +} + +public func overviewDropTarget( + at point: OverviewPoint, + sourceWindowID: WindowID, + projection: OverviewProjection, + snapshot: OverviewSnapshot +) -> OverviewDropTarget? { + guard let workspaceProjection = projection.workspaces.last(where: { + $0.frame.contains(point) + }), + let monitor = snapshot.monitors.first(where: { + $0.id == projection.monitorID + }), + let workspace = monitor.workspaces.first(where: { + $0.id == workspaceProjection.workspaceID + }), + let sourceWindow = snapshot.windows[sourceWindowID] + else { return nil } + + let sourceIsFloating = snapshot.monitors.contains { monitor in + monitor.workspaces.contains { $0.floatingWindows.contains(sourceWindowID) } + } + if sourceIsFloating { + guard let monitorFrame = snapshot.monitorFrames[projection.monitorID], + workspaceProjection.frame.width > 0, + workspaceProjection.frame.height > 0 + else { return nil } + let sourceFrame = snapshot.floatingFrames[sourceWindowID] ?? sourceWindow.frame + let scale = workspaceProjection.frame.height / monitorFrame.height + let width = max(sourceFrame.width * scale, 1) + let height = max(sourceFrame.height * scale, 1) + let horizontalRange = max(workspaceProjection.frame.width - width, 1) + let verticalRange = max(workspaceProjection.frame.height - height, 1) + let relativeWidth = min(sourceFrame.width / monitorFrame.width, 1) + let relativeHeight = min(sourceFrame.height / monitorFrame.height, 1) + return .floating( + monitorID: projection.monitorID, + workspaceID: workspace.id, + relativeFrame: Rect( + x: min(max( + (point.x - workspaceProjection.frame.x - width / 2) / horizontalRange, + 0 + ), 1) * (1 - relativeWidth), + y: min(max( + (point.y - workspaceProjection.frame.y - height / 2) / verticalRange, + 0 + ), 1) * (1 - relativeHeight), + width: relativeWidth, + height: relativeHeight + ) + ) + } + + let tiled = workspaceProjection.windows.filter { + if case .tiled = $0.layer { return true } + return false + } + var columnFrames: [Int: Rect] = [:] + for window in tiled { + guard case .tiled(let columnIndex, _) = window.layer else { continue } + columnFrames[columnIndex] = columnFrames[columnIndex].map { + union($0, window.frame) + } ?? window.frame + } + + guard !columnFrames.isEmpty else { + return .newColumn( + monitorID: projection.monitorID, + workspaceID: workspace.id, + columnIndex: 0 + ) + } + + for columnIndex in workspace.columns.indices { + guard let frame = columnFrames[columnIndex] else { continue } + if point.x < frame.x { + return .newColumn( + monitorID: projection.monitorID, + workspaceID: workspace.id, + columnIndex: columnIndex + ) + } + if point.x <= frame.x + frame.width { + let newColumnZoneWidth = frame.width / 4 + if point.x <= frame.x + newColumnZoneWidth { + return .newColumn( + monitorID: projection.monitorID, + workspaceID: workspace.id, + columnIndex: columnIndex + ) + } + if point.x >= frame.x + frame.width - newColumnZoneWidth { + return .newColumn( + monitorID: projection.monitorID, + workspaceID: workspace.id, + columnIndex: columnIndex + 1 + ) + } + let windows = tiled.filter { + if case .tiled(let index, _) = $0.layer { return index == columnIndex } + return false + }.sorted { $0.frame.y < $1.frame.y } + let insertionIndex = windows.firstIndex { + point.y < $0.frame.y + $0.frame.height / 2 + } ?? windows.count + return .stack( + monitorID: projection.monitorID, + workspaceID: workspace.id, + columnIndex: columnIndex, + windowIndex: insertionIndex + ) + } + } + + return .newColumn( + monitorID: projection.monitorID, + workspaceID: workspace.id, + columnIndex: workspace.columns.count + ) +} + +private func transform( + _ frame: Rect, + from source: Rect, + to target: Rect, + scale: Double +) -> Rect { + Rect( + x: target.x + (frame.x - source.x) * scale, + y: target.y + (frame.y - source.y) * scale, + width: frame.width * scale, + height: frame.height * scale + ) +} + +private func union(_ lhs: Rect, _ rhs: Rect) -> Rect { + let minX = min(lhs.x, rhs.x) + let minY = min(lhs.y, rhs.y) + let maxX = max(lhs.x + lhs.width, rhs.x + rhs.width) + let maxY = max(lhs.y + lhs.height, rhs.y + rhs.height) + return Rect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) +} + +private extension Rect { + func contains(_ point: OverviewPoint) -> Bool { + point.x >= x && point.x <= x + width + && point.y >= y && point.y <= y + height + } + + func intersects(_ other: Rect) -> Bool { + x < other.x + other.width && x + width > other.x + && y < other.y + other.height && y + height > other.y + } +} diff --git a/Sources/DefiDaemon/DaemonCommands.swift b/Sources/DefiDaemon/DaemonCommands.swift index c464643..7ec4408 100644 --- a/Sources/DefiDaemon/DaemonCommands.swift +++ b/Sources/DefiDaemon/DaemonCommands.swift @@ -217,6 +217,9 @@ extension Daemon { do { let commandStartedAt = ProcessInfo.processInfo.systemUptime let command = try parseCommand(rawCommand) + if command == .toggleOverview { + return toggleOverview() + } let commandMonitorID: MonitorID? if let monitorIndex { guard let monitorID = monitorID(atAppKitIndex: monitorIndex) else { @@ -266,6 +269,7 @@ extension Daemon { platform.recordPerformanceTrace( "command-no-op command=\(rawCommand) ms=\(String(format: "%.2f", lastCommandDurationMS))" ) + updateOverviewIfOpen() return .success() } let previouslySelectedWindowID = commandMonitorID.flatMap { @@ -687,6 +691,7 @@ extension Daemon { performanceLogger.debug( "command applied command=\(rawCommand, privacy: .public) duration_ms=\(self.lastCommandDurationMS, format: .fixed(precision: 2)) animations=\(self.scrollAnimations.count)" ) + updateOverviewIfOpen() return .success() } catch { return .failure(String(describing: error)) diff --git a/Sources/DefiDaemon/DaemonDesktopReconciliation.swift b/Sources/DefiDaemon/DaemonDesktopReconciliation.swift index c055d5f..9234cda 100644 --- a/Sources/DefiDaemon/DaemonDesktopReconciliation.swift +++ b/Sources/DefiDaemon/DaemonDesktopReconciliation.swift @@ -43,9 +43,10 @@ extension Daemon { func schedulePlacementStoreWrite(_ preferences: PlacementPreferences) { placementSaveWorkItem?.cancel() - let item = DispatchWorkItem { [weak self] in + let operation: @Sendable () -> Void = { [weak self] in self?.writePlacementStore(preferences) } + let item = DispatchWorkItem(block: operation) placementSaveWorkItem = item placementSaveQueue.asyncAfter( deadline: .now() + Self.placementSaveDebounce, @@ -101,6 +102,7 @@ extension Daemon { } func scheduleDisplayReconciliation() { + overviewController?.close() displayConfigurationEventCount += 1 let now = ProcessInfo.processInfo.systemUptime pendingDisplaySyncDeadlines = [0.05, 0.2, 0.5, 1.0, 2.0].map { diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index 44ee0f3..3b8069f 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -64,6 +64,9 @@ extension Daemon { } } let snapshotCompletedAt = ProcessInfo.processInfo.systemUptime + if snapshot.nativeFocusChanged { + overviewController?.close() + } nextPeriodicWindowRefreshAt = boundedSnapshotRefreshDeadline( current: nextPeriodicWindowRefreshAt, now: snapshotCompletedAt, @@ -673,6 +676,7 @@ extension Daemon { } persistPlacements() updateMenuBar() + updateOverviewIfOpen() if !snapshot.leftMouseButtonDown && mouseGestureSettlement == nil { mouseGestureScrollAnchor = nil } diff --git a/Sources/DefiDaemon/DaemonOverview.swift b/Sources/DefiDaemon/DaemonOverview.swift new file mode 100644 index 0000000..a4847fc --- /dev/null +++ b/Sources/DefiDaemon/DaemonOverview.swift @@ -0,0 +1,394 @@ +import DefiCore +import DefiIPC +import DefiMacOS +import DefiModel +import DefiRuntime +import Foundation + +@MainActor +extension Daemon { + func toggleOverview() -> CommandResponse { + guard !state.monitors.isEmpty else { + return .failure("overview unavailable before monitor discovery") + } + let controller = overviewController ?? makeOverviewController() + overviewController = controller + controller.toggle( + snapshot: makeOverviewSnapshot(), + layout: state.layout, + borders: config.decorations.borders, + animation: config.animation, + zoom: config.overview.zoom, + windowPreviewsEnabled: config.overview.windowPreviews + ) + return .success() + } + + func updateOverviewIfOpen() { + guard overviewController?.isOpen == true else { return } + overviewController?.update( + snapshot: makeOverviewSnapshot(), + layout: state.layout, + borders: config.decorations.borders, + animation: config.animation, + zoom: config.overview.zoom, + windowPreviewsEnabled: config.overview.windowPreviews + ) + } + + private func makeOverviewController() -> OverviewController { + OverviewController( + focusWindow: { [weak self] windowID, appID, monitorID, workspaceID in + self?.focusFromOverview( + windowID: windowID, + appID: appID, + monitorID: monitorID, + workspaceID: workspaceID + ) + }, + focusWorkspace: { [weak self] monitorID, workspaceID in + self?.focusWorkspaceFromOverview( + monitorID: monitorID, + workspaceID: workspaceID + ) + }, + drop: { + [weak self] windowID, appID, sourceMonitorID, sourceWorkspaceID, target in + self?.dropFromOverview( + windowID: windowID, + appID: appID, + sourceMonitorID: sourceMonitorID, + sourceWorkspaceID: sourceWorkspaceID, + target: target + ) + }, + activateMonitor: { [weak self] monitorID in + guard self?.state.monitors.contains(where: { $0.id == monitorID }) == true + else { return } + self?.activeMonitorID = monitorID + }, + openStateChanged: { [weak self] isOpen in + self?.hotKeys?.setOverviewModeEnabled(isOpen) + self?.platform.setWindowBordersSuppressed(isOpen) + } + ) + } + + private func makeOverviewSnapshot() -> OverviewSnapshot { + OverviewSnapshot( + monitors: logicalOverviewMonitors(), + monitorFrames: Dictionary( + uniqueKeysWithValues: latestMonitors.map { ($0.id, $0.frame) } + ), + windows: state.windows, + floatingFrames: floatingWindowFrames, + activeMonitorID: activeMonitorID, + nativeFullscreenWindowIDs: state.nativeFullscreenWindowIDs + ) + } + + private func logicalOverviewMonitors() -> [Monitor] { + var monitors = state.monitors + for monitorIndex in monitors.indices { + for workspaceIndex in monitors[monitorIndex].workspaces.indices { + for windowID in state.nativeFullscreenWindowIDs { + removeWindow( + windowID, + from: &monitors[monitorIndex].workspaces[workspaceIndex], + settings: state.layout + ) + } + } + } + + for windowID in state.nativeFullscreenFloatingWindowIDs { + guard let location = state.location(containing: windowID), + let monitorIndex = monitors.firstIndex(where: { $0.id == location.monitorID }), + let workspaceIndex = monitors[monitorIndex].workspaces.firstIndex(where: { + $0.id == location.workspaceID + }) + else { continue } + monitors[monitorIndex].workspaces[workspaceIndex].floatingWindows.append(windowID) + } + + let groupedPlacements = Dictionary(grouping: state.nativeFullscreenTiledPlacements) { + _, placement in + OverviewPlacementGroup( + monitorID: placement.monitorID, + workspaceID: placement.workspaceID, + columnIndex: placement.columnIndex + ) + } + for (group, placements) in groupedPlacements.sorted(by: { + $0.key.columnIndex < $1.key.columnIndex + }) { + guard let placement = placements.first?.value, + let monitorIndex = monitors.firstIndex(where: { $0.id == group.monitorID }), + let workspaceIndex = monitors[monitorIndex].workspaces.firstIndex(where: { + $0.id == group.workspaceID + }) + else { continue } + let desiredWindowIDs = placement.column.windows.filter { + state.windows[$0] != nil + } + guard !desiredWindowIDs.isEmpty else { continue } + if let existingColumnIndex = monitors[monitorIndex].workspaces[workspaceIndex] + .columns.firstIndex(where: { column in + column.windows.contains(where: desiredWindowIDs.contains) + }) + { + monitors[monitorIndex].workspaces[workspaceIndex] + .columns[existingColumnIndex].windows = desiredWindowIDs + monitors[monitorIndex].workspaces[workspaceIndex] + .columns[existingColumnIndex].width = placement.column.width + monitors[monitorIndex].workspaces[workspaceIndex] + .columns[existingColumnIndex].preMaximizedWidth = + placement.column.preMaximizedWidth + } else { + let insertionIndex = min( + max(group.columnIndex, 0), + monitors[monitorIndex].workspaces[workspaceIndex].columns.count + ) + monitors[monitorIndex].workspaces[workspaceIndex].columns.insert( + Column( + windows: desiredWindowIDs, + focusedWindow: min( + placement.windowIndex, + desiredWindowIDs.count - 1 + ), + width: placement.column.width, + preMaximizedWidth: placement.column.preMaximizedWidth + ), + at: insertionIndex + ) + } + } + for originalMonitor in state.monitors { + guard let monitorIndex = monitors.firstIndex(where: { + $0.id == originalMonitor.id + }) else { continue } + for originalWorkspace in originalMonitor.workspaces { + guard let workspaceIndex = monitors[monitorIndex].workspaces.firstIndex(where: { + $0.id == originalWorkspace.id + }) else { continue } + let selectedWindowID = overviewSelectedWindowID(in: originalWorkspace) + monitors[monitorIndex].workspaces[workspaceIndex].scrollOffset = + originalWorkspace.scrollOffset + monitors[monitorIndex].workspaces[workspaceIndex].targetScrollOffset = + originalWorkspace.targetScrollOffset + if let selectedWindowID { + selectOverviewWindow( + selectedWindowID, + in: &monitors[monitorIndex].workspaces[workspaceIndex] + ) + } + } + } + return monitors + } + + private func focusFromOverview( + windowID: WindowID, + appID: String, + monitorID: MonitorID, + workspaceID: WorkspaceID + ) { + let previousSelectedWindowID = activeMonitorID.flatMap { + state.selectedWindowID(on: $0) + } + let inputTimestamp = ProcessInfo.processInfo.systemUptime + do { + let focusedMonitorID = try focusOverviewWindow( + OverviewWindowIntent( + windowID: windowID, + expectedAppID: appID, + sourceMonitorID: monitorID, + sourceWorkspaceID: workspaceID + ), + state: &state + ) + activeMonitorID = focusedMonitorID + commandGeneration &+= 1 + latestCommandInputTimestamp = inputTimestamp + platform.userInputTracker.record(timestamp: inputTimestamp) + pendingWindowRemovalFocusGuard = nil + displacedPointerFocusRecovery = nil + invalidatePointerFocusIntent(recoveringTo: previousSelectedWindowID) + invalidateSubmittedWorkspaceFocus() + pendingWorkspaceFocus = nil + synchronizeScrollOffsets(state: &state, viewports: viewportsByMonitor) + startScrollAnimationsIfNeeded() + let animated = dispatchScrollAnimationIfNeeded( + monitorIDs: [focusedMonitorID] + ) + if !animated { + applyCurrentLayout( + monitorIDs: [focusedMonitorID], + asynchronousPositions: true, + updateVisibility: true, + positionTimeoutSeconds: 0.05, + stagesVisibleBeforeParking: true, + source: "overview-focus" + ) + } + if focusIsReady(on: focusedMonitorID, targetWindowID: windowID) { + commitCommandFocus( + windowID, + previousSelectedWindowID: previousSelectedWindowID, + monitorID: focusedMonitorID, + sourceWorkspaceID: workspaceID, + commandGeneration: commandGeneration, + focusInputTimestamp: inputTimestamp, + cursorWarpInputTimestamp: config.input.mouseFollowsFocus + ? inputTimestamp + : nil + ) + } else { + pendingAnimatedFocus = PendingAnimatedFocus( + windowID: windowID, + previousSelectedWindowID: previousSelectedWindowID, + monitorID: focusedMonitorID, + sourceWorkspaceID: workspaceID, + commandGeneration: commandGeneration, + focusInputTimestamp: inputTimestamp, + cursorWarpInputTimestamp: config.input.mouseFollowsFocus + ? inputTimestamp + : nil + ) + } + persistPlacements() + updateMenuBar() + updateOverviewIfOpen() + } catch { + platform.recordPerformanceTrace("overview-focus-rejected error=\(error)") + updateOverviewIfOpen() + } + } + + private func focusWorkspaceFromOverview( + monitorID: MonitorID, + workspaceID: WorkspaceID + ) { + let inputTimestamp = ProcessInfo.processInfo.systemUptime + do { + let selectedWindowID = try focusOverviewWorkspace( + monitorID: monitorID, + workspaceID: workspaceID, + state: &state + ) + activeMonitorID = monitorID + commandGeneration &+= 1 + latestCommandInputTimestamp = inputTimestamp + platform.userInputTracker.record(timestamp: inputTimestamp) + synchronizeScrollOffsets(state: &state, viewports: viewportsByMonitor) + snapScrollOffsetsToTargets() + applyCurrentLayout( + monitorIDs: [monitorID], + asynchronousPositions: true, + updateVisibility: true, + positionTimeoutSeconds: 0.05, + stagesVisibleBeforeParking: true, + focusWindowIDAfterCommit: selectedWindowID, + focusInputTimestampAfterCommit: selectedWindowID == nil + ? nil + : inputTimestamp, + source: "overview-workspace" + ) + persistPlacements() + updateMenuBar() + updateOverviewIfOpen() + } catch { + platform.recordPerformanceTrace("overview-workspace-rejected error=\(error)") + } + } + + private func dropFromOverview( + windowID: WindowID, + appID: String, + sourceMonitorID: MonitorID, + sourceWorkspaceID: WorkspaceID, + target: OverviewDropTarget + ) { + let inputTimestamp = ProcessInfo.processInfo.systemUptime + do { + let result = try applyOverviewDrop( + OverviewWindowIntent( + windowID: windowID, + expectedAppID: appID, + sourceMonitorID: sourceMonitorID, + sourceWorkspaceID: sourceWorkspaceID + ), + target: target, + viewports: viewportsByMonitor, + floatingFrames: floatingWindowFrames, + state: &state + ) + commandGeneration &+= 1 + latestCommandInputTimestamp = inputTimestamp + activeMonitorID = result.monitorID + for (windowID, frame) in result.floatingFrameUpdates { + floatingWindowFrames[windowID] = frame + } + pendingAnimatedFocus = nil + invalidateSubmittedCommandFocus() + invalidateSubmittedWorkspaceFocus() + pendingWorkspaceFocus = nil + preemptMouseGesture() + synchronizeScrollOffsets(state: &state, viewports: viewportsByMonitor) + snapScrollOffsetsToTargets() + let affectedMonitorIDs: Set = [sourceMonitorID, result.monitorID] + applyCurrentLayout( + monitorIDs: affectedMonitorIDs, + asynchronousPositions: true, + updateVisibility: true, + positionTimeoutSeconds: 0.05, + stagesVisibleBeforeParking: true, + focusWindowIDAfterCommit: result.focusedWindowID, + focusInputTimestampAfterCommit: inputTimestamp, + forcingFloatingFrameWritesFor: Set(result.floatingFrameUpdates.keys), + source: "overview-drop" + ) + persistPlacements() + updateMenuBar() + updateOverviewIfOpen() + needsDesktopSync = true + } catch { + platform.recordPerformanceTrace("overview-drop-rejected error=\(error)") + updateOverviewIfOpen() + } + } +} + +private struct OverviewPlacementGroup: Hashable { + let monitorID: MonitorID + let workspaceID: WorkspaceID + let columnIndex: Int +} + +private func overviewSelectedWindowID(in workspace: Workspace) -> WindowID? { + if workspace.focusedLayer == .floating, + workspace.floatingWindows.indices.contains(workspace.focusedFloatingWindow) + { + return workspace.floatingWindows[workspace.focusedFloatingWindow] + } + guard workspace.columns.indices.contains(workspace.focusedColumn) else { return nil } + let column = workspace.columns[workspace.focusedColumn] + guard column.windows.indices.contains(column.focusedWindow) else { return nil } + return column.windows[column.focusedWindow] +} + +private func selectOverviewWindow(_ windowID: WindowID, in workspace: inout Workspace) { + if let index = workspace.floatingWindows.firstIndex(of: windowID) { + workspace.focusedLayer = .floating + workspace.focusedFloatingWindow = index + return + } + guard let columnIndex = workspace.columns.firstIndex(where: { + $0.windows.contains(windowID) + }), + let windowIndex = workspace.columns[columnIndex].windows.firstIndex(of: windowID) + else { return } + workspace.focusedLayer = .tiled + workspace.focusedColumn = columnIndex + workspace.columns[columnIndex].focusedWindow = windowIndex +} diff --git a/Sources/DefiDaemon/DaemonStatusLifecycle.swift b/Sources/DefiDaemon/DaemonStatusLifecycle.swift index f6bf276..56461fd 100644 --- a/Sources/DefiDaemon/DaemonStatusLifecycle.swift +++ b/Sources/DefiDaemon/DaemonStatusLifecycle.swift @@ -26,6 +26,17 @@ extension Daemon { let capturedHotKeyCount = hotKeys?.capturedKeyCount ?? 0 let tapReenableCount = hotKeys?.tapReenableCount ?? 0 let pointerTransitionCount = hotKeys?.pointerTransitionCount ?? 0 + let overviewOpen = overviewController?.isOpen == true + let overviewPanels = overviewController?.panelCount ?? 0 + let overviewPermission = overviewController?.previewPermissionState.rawValue + ?? (config.overview.windowPreviews ? "not-determined" : "disabled") + let overviewCaptures = overviewController?.inFlightPreviewCount ?? 0 + let overviewPreviews = overviewController?.previewCacheCount ?? 0 + let overviewPreviewCacheMiB = String( + format: "%.2f", + Double(overviewController?.rememberedPreviewMemoryBytes ?? 0) / 1_048_576 + ) + let overviewPreviewFailures = overviewController?.previewFailureCount ?? 0 let cursorWarps = platform.cursorWarpPerformance let resize = activelyResizedWindowID.map { String($0.rawValue) } @@ -157,7 +168,7 @@ extension Daemon { "\($0.id.rawValue):\(Int($0.frame.width))x\(Int($0.frame.height))" }.joined(separator: ",") return - "running monitors=\(state.monitors.count)[\(displaySizes)] windows=\(managedCount) floating=\(floatingCount) workspace=\(workspace) focused=\(focused) fullscreenSuspended=\(state.nativeFullscreenWindowIDs.count)[\(fullscreenWindowIDs)] columnWidth=\(focusedColumnState) menuBar=\(menuBar == nil ? "missing" : "installed") hotkeys=\(hotKeyState) bindings=\(bindingCount) captured=\(capturedHotKeyCount) processed=\(processedHotKeyCount) queued=\(pendingHotKeyCommands.count) tapReenables=\(tapReenableCount) events=\(observedPlatformEventCount) focusDedup=\(ignoredRedundantNativeFocusCount) closeFocusPreserved=\(preservedWindowRemovalFocusCount) displayEvents=\(displayConfigurationEventCount) displayRetries=\(pendingDisplaySyncDeadlines.count) drift=\(targetMismatchCount)[\(driftDetails)] resize=\(resize) visibility=\(visibility) hidden=\(platform.hiddenWindowCount) borders=\(borders.visible) borderNodes=\(borders.allocated) borderDormant=\(borders.dormant) borderOpacity=\(borderOpacity) borderSurfaceMiB=\(borderSurfaceMiB) borderCapture=\(borders.captureEnabled) borderPlans=\(borders.appliedPlans) borderSkips=\(borders.skippedPlans) borderGeometry=\(borders.geometryUpdates) snapshots=\(snapshotPerformance.full)/\(snapshotPerformance.incremental)/\(snapshotPerformance.cached) appInventories=\(snapshotPerformance.applicationInventories) snapshotMs=\(snapshotMS) snapshotMaxMs=\(snapshotMaxMS) snapshotCG=\(snapshotPerformance.cgCopies)/\(snapshotCGMS)/\(snapshotCGMaxMS) axReads=\(attributeReads.batched)/\(attributeReads.fallback) parkingChecks=\(parking.checks) parkingRepairs=\(parking.repairs) initialChecks=\(initialSettlement.checks) initialRepairs=\(initialSettlement.repairs) settling=\(frameCommit.settling) deferredCommits=\(frameCommit.deferred) observedCommits=\(frameCommit.observed) observedCommitMaxMs=\(observedCommitMaxMS) slowApps=\(platform.latencySensitiveProcessCount) slowAppDetails=[\(platform.latencySensitiveProcessDescription)] axAppDetails=[\(platform.processLatencyDescription)] posWrites=\(platform.successfulPositionWriteCount) stalePos=\(platform.skippedStalePositionWriteCount) droppedFrames=\(platform.droppedPositionFrameCount) displayedRebases=\(displayedFrameRebaseCount) displayedDelta=\(displayedRebaseDelta) sizeWrites=\(platform.successfulSizeWriteCount) displayHz=\(displayHz) timerHz=\(timerHz) axPending=\(platform.hasPendingAnimatedFrameWrites) axFrameMs=\(axFrameMS) axFrameMaxMs=\(axFrameMaxMS) axSlowFrames=\(axFramePerformance.slowFrames) focusPending=\(platform.hasPendingFocusWrite) focusFast=\(focusPerformance.fastPaths) focusCancelled=\(focusPerformance.cancelled) focusRetries=\(focusPerformance.retries) focusMainMs=\(focusMainMS) focusRaiseMs=\(focusRaiseMS) focusActivateMs=\(focusActivateMS) animating=\(platform.hasPendingAnimatedFrameWrites) animationFrames=\(axFramePerformance.animationFrames) animationMs=\(coordinatorAnimationMS) commandMs=\(commandMS) frameMs=\(frameMS) focusMs=\(focusMS)" + "running monitors=\(state.monitors.count)[\(displaySizes)] windows=\(managedCount) floating=\(floatingCount) workspace=\(workspace) focused=\(focused) fullscreenSuspended=\(state.nativeFullscreenWindowIDs.count)[\(fullscreenWindowIDs)] columnWidth=\(focusedColumnState) menuBar=\(menuBar == nil ? "missing" : "installed") hotkeys=\(hotKeyState) bindings=\(bindingCount) captured=\(capturedHotKeyCount) processed=\(processedHotKeyCount) queued=\(pendingHotKeyCommands.count) tapReenables=\(tapReenableCount) overview=\(overviewOpen ? "open" : "closed") overviewPanels=\(overviewPanels) overviewPreviewConfig=\(config.overview.windowPreviews) overviewPermission=\(overviewPermission) overviewCaptures=\(overviewCaptures) overviewPreviews=\(overviewPreviews) overviewPreviewCacheMiB=\(overviewPreviewCacheMiB) overviewPreviewFailures=\(overviewPreviewFailures) events=\(observedPlatformEventCount) focusDedup=\(ignoredRedundantNativeFocusCount) closeFocusPreserved=\(preservedWindowRemovalFocusCount) displayEvents=\(displayConfigurationEventCount) displayRetries=\(pendingDisplaySyncDeadlines.count) drift=\(targetMismatchCount)[\(driftDetails)] resize=\(resize) visibility=\(visibility) hidden=\(platform.hiddenWindowCount) borders=\(borders.visible) borderNodes=\(borders.allocated) borderDormant=\(borders.dormant) borderOpacity=\(borderOpacity) borderSurfaceMiB=\(borderSurfaceMiB) borderCapture=\(borders.captureEnabled) borderPlans=\(borders.appliedPlans) borderSkips=\(borders.skippedPlans) borderGeometry=\(borders.geometryUpdates) snapshots=\(snapshotPerformance.full)/\(snapshotPerformance.incremental)/\(snapshotPerformance.cached) appInventories=\(snapshotPerformance.applicationInventories) snapshotMs=\(snapshotMS) snapshotMaxMs=\(snapshotMaxMS) snapshotCG=\(snapshotPerformance.cgCopies)/\(snapshotCGMS)/\(snapshotCGMaxMS) axReads=\(attributeReads.batched)/\(attributeReads.fallback) parkingChecks=\(parking.checks) parkingRepairs=\(parking.repairs) initialChecks=\(initialSettlement.checks) initialRepairs=\(initialSettlement.repairs) settling=\(frameCommit.settling) deferredCommits=\(frameCommit.deferred) observedCommits=\(frameCommit.observed) observedCommitMaxMs=\(observedCommitMaxMS) slowApps=\(platform.latencySensitiveProcessCount) slowAppDetails=[\(platform.latencySensitiveProcessDescription)] axAppDetails=[\(platform.processLatencyDescription)] posWrites=\(platform.successfulPositionWriteCount) stalePos=\(platform.skippedStalePositionWriteCount) droppedFrames=\(platform.droppedPositionFrameCount) displayedRebases=\(displayedFrameRebaseCount) displayedDelta=\(displayedRebaseDelta) sizeWrites=\(platform.successfulSizeWriteCount) displayHz=\(displayHz) timerHz=\(timerHz) axPending=\(platform.hasPendingAnimatedFrameWrites) axFrameMs=\(axFrameMS) axFrameMaxMs=\(axFrameMaxMS) axSlowFrames=\(axFramePerformance.slowFrames) focusPending=\(platform.hasPendingFocusWrite) focusFast=\(focusPerformance.fastPaths) focusCancelled=\(focusPerformance.cancelled) focusRetries=\(focusPerformance.retries) focusMainMs=\(focusMainMS) focusRaiseMs=\(focusRaiseMS) focusActivateMs=\(focusActivateMS) animating=\(platform.hasPendingAnimatedFrameWrites) animationFrames=\(axFramePerformance.animationFrames) animationMs=\(coordinatorAnimationMS) commandMs=\(commandMS) frameMs=\(frameMS) focusMs=\(focusMS)" + " topologyObservers=\(platform.hasReliableWindowTopologyObservation) appWindowLists=\(snapshotPerformance.applicationWindowListReads)" + " appLifecycleObservers=\(platform.hasReliableApplicationLifecycleObservation) appInventoryInterval=\(Int(platform.recommendedApplicationInventoryRefreshInterval))" + " desktopObservers=\(platform.hasReliableDesktopObservation)" diff --git a/Sources/DefiDaemon/DefiDaemon.swift b/Sources/DefiDaemon/DefiDaemon.swift index 69ea206..84384ce 100644 --- a/Sources/DefiDaemon/DefiDaemon.swift +++ b/Sources/DefiDaemon/DefiDaemon.swift @@ -166,6 +166,7 @@ final class Daemon: NSObject { ) var placementSaveWorkItem: DispatchWorkItem? var hotKeys: HotKeyManager? + var overviewController: OverviewController? var menuBar: MenuBarController? var lastPublishedWorkspaceState: WorkspaceStateSnapshot? var lastWorkspacePublishState: RuntimeState? @@ -310,6 +311,9 @@ final class Daemon: NSObject { }, tapReenabledHandler: { [weak self] timestamp in self?.handleEventTapReenabled(at: timestamp) + }, + overviewHandler: { [weak self] action in + self?.overviewController?.handleKey(action) } ) { [weak self] invocation in self?.enqueueHotKey(invocation) diff --git a/Sources/DefiMacOS/HotKeyModels.swift b/Sources/DefiMacOS/HotKeyModels.swift index cf4d386..a4f8149 100644 --- a/Sources/DefiMacOS/HotKeyModels.swift +++ b/Sources/DefiMacOS/HotKeyModels.swift @@ -25,6 +25,32 @@ public struct HotKeyInvocation: Equatable, Sendable { } } +public enum OverviewKeyAction: Equatable, Sendable { + case left + case right + case up + case down + case select + case cancel +} + +func overviewKeyAction( + keyCode: CGKeyCode, + modifierBits: UInt64, + isConfiguredBinding: Bool = false +) -> OverviewKeyAction? { + return switch keyCode { + case 123 where modifierBits == 0 || isConfiguredBinding: .left + case 124 where modifierBits == 0 || isConfiguredBinding: .right + case 125 where modifierBits == 0 || isConfiguredBinding: .down + case 126 where modifierBits == 0 || isConfiguredBinding: .up + case 36 where modifierBits == 0: .select + case 76 where modifierBits == 0: .select + case 53 where modifierBits == 0: .cancel + default: nil + } +} + public struct PointerMotionInvocation: Equatable, Sendable { public let windowID: WindowID? public let location: CGPoint @@ -139,4 +165,3 @@ struct Key: Hashable, Sendable { "left": 123, "right": 124, "down": 125, "up": 126, ] } - diff --git a/Sources/DefiMacOS/HotKeys.swift b/Sources/DefiMacOS/HotKeys.swift index a16501a..c497c5c 100644 --- a/Sources/DefiMacOS/HotKeys.swift +++ b/Sources/DefiMacOS/HotKeys.swift @@ -10,6 +10,8 @@ public final class HotKeyManager { @MainActor @Sendable (PointerMotionInvocation) -> Void public typealias TapReenabledHandler = @MainActor @Sendable (TimeInterval) -> Void + public typealias OverviewHandler = + @MainActor @Sendable (OverviewKeyAction) -> Void private let bindings: [Key: String] private let handler: Handler @@ -17,6 +19,7 @@ public final class HotKeyManager { public let bindingError: HotKeyError? private let pointerMotionHandler: PointerMotionHandler? private let tapReenabledHandler: TapReenabledHandler + private let overviewHandler: OverviewHandler private let userInputTracker: UserInputTracker private let pointerMotionTracker: PointerMotionTracker private var context: HotKeyTapContext? @@ -50,6 +53,7 @@ public final class HotKeyManager { pointerMotionTracker: PointerMotionTracker = PointerMotionTracker(), pointerMotionHandler: PointerMotionHandler? = nil, tapReenabledHandler: @escaping TapReenabledHandler = { _ in }, + overviewHandler: @escaping OverviewHandler = { _ in }, handler: @escaping Handler ) { self.handler = handler @@ -59,6 +63,7 @@ public final class HotKeyManager { ? pointerMotionHandler : nil self.tapReenabledHandler = tapReenabledHandler + self.overviewHandler = overviewHandler self.userInputTracker = userInputTracker self.pointerMotionTracker = pointerMotionTracker var bindings: [Key: String] = [:] @@ -111,6 +116,7 @@ public final class HotKeyManager { let handler = self.handler let pointerMotionHandler = self.pointerMotionHandler let tapReenabledHandler = self.tapReenabledHandler + let overviewHandler = self.overviewHandler let context = HotKeyTapContext( bindings: bindings, userInputTracker: userInputTracker, @@ -122,6 +128,12 @@ public final class HotKeyManager { handler(invocation) } } + } deliverOverview: { action in + DispatchQueue.main.async { + MainActor.assumeIsolated { + overviewHandler(action) + } + } } deliverPointerMotion: { invocation in MainActor.assumeIsolated { pointerMotionHandler?(invocation) @@ -169,6 +181,10 @@ public final class HotKeyManager { context?.resetPointerWindowTransition() } + public func setOverviewModeEnabled(_ enabled: Bool) { + context?.setOverviewModeEnabled(enabled) + } + isolated deinit { context?.stop() } @@ -183,6 +199,7 @@ private final class HotKeyTapContext: @unchecked Sendable { private let pointerMotionTracker: PointerMotionTracker private let tracksPointerWindowTransitions: Bool private let deliver: @Sendable (HotKeyInvocation) -> Void + private let deliverOverview: @Sendable (OverviewKeyAction) -> Void private let deliverPointerMotion: @Sendable (PointerMotionInvocation) -> Void private let tapReenabled: @Sendable (TimeInterval) -> Void private let lock = NSLock() @@ -199,6 +216,7 @@ private final class HotKeyTapContext: @unchecked Sendable { private var pointerDeliveryGeneration: UInt64 = 0 private var lastPointerDeliveryTimestamp: TimeInterval? private var capturedModifierReleaseState = CapturedHotKeyModifierReleaseState() + private var overviewModeEnabled = false init( bindings: [Key: String], @@ -206,6 +224,7 @@ private final class HotKeyTapContext: @unchecked Sendable { pointerMotionTracker: PointerMotionTracker, tracksPointerWindowTransitions: Bool, deliver: @escaping @Sendable (HotKeyInvocation) -> Void, + deliverOverview: @escaping @Sendable (OverviewKeyAction) -> Void, deliverPointerMotion: @escaping @Sendable (PointerMotionInvocation) -> Void, tapReenabled: @escaping @Sendable (TimeInterval) -> Void ) { @@ -214,6 +233,7 @@ private final class HotKeyTapContext: @unchecked Sendable { self.pointerMotionTracker = pointerMotionTracker self.tracksPointerWindowTransitions = tracksPointerWindowTransitions self.deliver = deliver + self.deliverOverview = deliverOverview self.deliverPointerMotion = deliverPointerMotion self.tapReenabled = tapReenabled } @@ -340,6 +360,30 @@ private final class HotKeyTapContext: @unchecked Sendable { } let code = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) let key = Key(code: code, flags: event.flags.rawValue) + lock.lock() + let overviewModeEnabled = overviewModeEnabled + lock.unlock() + if overviewModeEnabled, + event.flags.contains(.maskCommand), + code == Self.commandTabKeyCode + { + deliverOverview(.cancel) + return Unmanaged.passUnretained(event) + } + if overviewModeEnabled, + let action = overviewKeyAction( + keyCode: code, + modifierBits: key.modifierBits, + isConfiguredBinding: bindings[key] != nil + ) + { + lock.lock() + captured += 1 + lock.unlock() + capturedModifierReleaseState.capture(modifierBits: key.modifierBits) + deliverOverview(action) + return nil + } guard let command = bindings[key] else { return Unmanaged.passUnretained(event) } @@ -385,6 +429,12 @@ private final class HotKeyTapContext: @unchecked Sendable { lock.unlock() } + func setOverviewModeEnabled(_ enabled: Bool) { + lock.lock() + overviewModeEnabled = enabled + lock.unlock() + } + private func enqueuePointerMotionIfNeeded( _ invocation: PointerMotionInvocation, rawWindowID: Int64 diff --git a/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift b/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift index 0970618..9b33d84 100644 --- a/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift +++ b/Sources/DefiMacOS/MacOSPlatform+WindowBorderObservation.swift @@ -262,17 +262,7 @@ extension MacOSPlatform { } borderHiddenWindowIDs = lastHiddenWindowIDs borderLiveWindowID = liveWindowID - borderStyle = WindowBorderStyle( - enabled: config.enabled, - width: config.width, - activeColor: parseBorderColor(config.color) ?? 0xffc0_99ff, - inactiveEnabled: config.inactiveEnabled, - inactiveColor: parseBorderColor(config.inactiveColor) ?? 0x66c0_99ff, - captureEnabled: config.captureEnabled, - placement: WindowBorderPlacement( - configValue: config.placement - ) - ) + borderStyle = WindowBorderStyle(config: config) refreshWindowBorders() if let ownedWindowID = borderManager.ownedSurfaceWindowID { @@ -490,6 +480,19 @@ extension MacOSPlatform { borderManager.hide() } + public func setWindowBordersSuppressed(_ suppressed: Bool) { + guard borderManager.isSuppressed != suppressed else { return } + borderManager.setSuppressed(suppressed) + if suppressed { + borderStackingRefreshTask?.cancel() + return + } + stageWindowBorderSelection(borderSelectedWindowID) + refreshWindowBorders() + scheduleWindowBorderStackingRefresh() + revealWindowBordersIfReady() + } + public func updateNativeFullscreenPlaceholders( _ placeholders: [NativeFullscreenPlaceholder], selectedWindowID: WindowID?, diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift new file mode 100644 index 0000000..629c00a --- /dev/null +++ b/Sources/DefiMacOS/OverviewController.swift @@ -0,0 +1,2003 @@ +import AppKit +import DefiConfig +import DefiCore +import DefiModel +import QuartzCore + +private struct OverviewViewportAnimation { + let from: OverviewViewport + let to: OverviewViewport + let startedAt: TimeInterval + let duration: TimeInterval +} + +private struct RememberedOverviewPreview { + let image: NSImage + let appID: String + let processID: pid_t +} + +enum OverviewScrollAxis: Equatable { + case horizontal + case vertical +} + +func overviewScrollAxis(for delta: NSPoint) -> OverviewScrollAxis? { + guard delta.x != 0 || delta.y != 0 else { return nil } + return abs(delta.y) >= abs(delta.x) ? .vertical : .horizontal +} + +func overviewViewportAfterScroll( + _ viewport: OverviewViewport, + delta: NSPoint, + hasPreciseScrollingDeltas: Bool, + viewSize: NSSize, + zoom: Double = 0.5, + activeWorkspaceIndex: Int, + workspaceCount: Int, + horizontalWorkspaceID: WorkspaceID?, + maximumHorizontalOffset: Double? +) -> OverviewViewport { + var viewport = viewport + let deltaScale = hasPreciseScrollingDeltas ? 1.0 : 16.0 + switch overviewScrollAxis(for: delta) { + case .vertical: + let stride = overviewWorkspaceStride(boundsHeight: viewSize.height, zoom: zoom) + viewport.workspaceOffset = min( + max( + viewport.workspaceOffset - delta.y * deltaScale / max(stride, 1), + Double(-activeWorkspaceIndex) + ), + Double(workspaceCount - activeWorkspaceIndex - 1) + ) + case .horizontal: + guard let horizontalWorkspaceID, let maximumHorizontalOffset else { + return viewport + } + let workspaceHeight = overviewWorkspaceStride( + boundsHeight: viewSize.height, + zoom: zoom + ) - 28 + let contentScale = workspaceHeight / max(viewSize.height, 1) + let projectedPointsPerScrollUnit = viewSize.width * contentScale + let offset = viewport.horizontalOffsets[horizontalWorkspaceID, default: 0] + - delta.x * deltaScale / max(projectedPointsPerScrollUnit, 1) + viewport.horizontalOffsets[horizontalWorkspaceID] = min( + max(offset, 0), + maximumHorizontalOffset + ) + case nil: + break + } + return viewport +} + +@MainActor +public final class OverviewController: NSObject { + private static let rememberedPreviewByteLimit = 16 * 1_024 * 1_024 + + public typealias WindowHandler = @MainActor @Sendable ( + WindowID, String, MonitorID, WorkspaceID + ) -> Void + public typealias WorkspaceHandler = @MainActor @Sendable ( + MonitorID, WorkspaceID + ) -> Void + public typealias DropHandler = @MainActor @Sendable ( + WindowID, String, MonitorID, WorkspaceID, OverviewDropTarget + ) -> Void + public typealias MonitorHandler = @MainActor @Sendable (MonitorID) -> Void + public typealias OpenStateHandler = @MainActor @Sendable (Bool) -> Void + + private let focusWindowHandler: WindowHandler + private let focusWorkspaceHandler: WorkspaceHandler + private let dropHandler: DropHandler + private let activateMonitorHandler: MonitorHandler + private let openStateHandler: OpenStateHandler + private var panels: [MonitorID: OverviewPanel] = [:] + private var snapshot: OverviewSnapshot? + private var layout = LayoutSettings() + private var borderStyle = WindowBorderStyle(config: BordersConfig()) + private var viewports: [MonitorID: OverviewViewport] = [:] + private var projections: [MonitorID: OverviewProjection] = [:] + private var activeWorkspaceByMonitor: [MonitorID: WorkspaceID] = [:] + private var selection: OverviewSelection? + private var drag: OverviewDrag? + private var edgeScrollTimer: Timer? + private var edgeScrollDirection: Double? + private var sessionGeneration: UInt64 = 0 + private var expectedActivationProcessID: pid_t? + private var expectedActivationGeneration: UInt64 = 0 + private var windowPreviewsEnabled = false + private var previewTask: Task? + private var previewCache: [WindowID: NSImage] = [:] + private var rememberedPreviews: [WindowID: RememberedOverviewPreview] = [:] + private var rememberedPreviewByteCosts: [WindowID: Int] = [:] + private var previewRevealStartedAt: [WindowID: TimeInterval] = [:] + private var previewFadeTimer: Timer? + private var attemptedPreviewWindowIDs = Set() + private var hasAttemptedDesktopCapture = false + private var hasRequestedPreviewPermission = false + private var previewPendingCount = 0 + private var alignSelectionOnNextUpdate = false + private var animationsEnabled = true + private var overviewZoom = 0.5 + private var viewportAnimations: [MonitorID: OverviewViewportAnimation] = [:] + private var viewportDisplayLinks: [MonitorID: CADisplayLink] = [:] + private var displayLinkMonitorIDs: [ObjectIdentifier: MonitorID] = [:] + + public private(set) var isOpen = false + public var panelCount: Int { panels.count } + public private(set) var previewPermissionState: OverviewPreviewPermissionState = .disabled + public private(set) var previewFailureCount = 0 + public var previewCacheCount: Int { previewCache.count } + public var rememberedPreviewMemoryBytes: Int { + rememberedPreviewByteCosts.values.reduce(0, +) + } + public var inFlightPreviewCount: Int { + previewTask == nil ? 0 : min(previewPendingCount, 2) + } + + public init( + focusWindow: @escaping WindowHandler, + focusWorkspace: @escaping WorkspaceHandler, + drop: @escaping DropHandler, + activateMonitor: @escaping MonitorHandler, + openStateChanged: @escaping OpenStateHandler + ) { + focusWindowHandler = focusWindow + focusWorkspaceHandler = focusWorkspace + dropHandler = drop + activateMonitorHandler = activateMonitor + openStateHandler = openStateChanged + super.init() + let center = NSWorkspace.shared.notificationCenter + center.addObserver( + self, + selector: #selector(applicationActivated(_:)), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + for name in [ + NSWorkspace.screensDidSleepNotification, + NSWorkspace.sessionDidResignActiveNotification, + NSWorkspace.willSleepNotification, + ] { + center.addObserver( + self, + selector: #selector(closeForSystemTransition(_:)), + name: name, + object: nil + ) + } + } + + isolated deinit { + previewFadeTimer?.invalidate() + for link in viewportDisplayLinks.values { link.invalidate() } + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + public func toggle( + snapshot: OverviewSnapshot, + layout: LayoutSettings, + borders: BordersConfig = BordersConfig(), + animation: AnimationConfig = AnimationConfig(), + zoom: Double = 0.5, + windowPreviewsEnabled: Bool = false + ) { + if isOpen { + close() + } else { + open( + snapshot: snapshot, + layout: layout, + borders: borders, + animation: animation, + zoom: zoom, + windowPreviewsEnabled: windowPreviewsEnabled + ) + } + } + + public func open( + snapshot: OverviewSnapshot, + layout: LayoutSettings, + borders: BordersConfig = BordersConfig(), + animation: AnimationConfig = AnimationConfig(), + zoom: Double = 0.5, + windowPreviewsEnabled: Bool = false + ) { + closePanelsImmediately() + sessionGeneration &+= 1 + self.snapshot = snapshot + self.layout = layout + borderStyle = WindowBorderStyle(config: borders) + animationsEnabled = animation.enabled + overviewZoom = zoom + self.windowPreviewsEnabled = windowPreviewsEnabled + previewTask?.cancel() + previewTask = nil + previewCache.removeAll(keepingCapacity: true) + restoreRememberedPreviews(for: snapshot) + resetPreviewFadeAnimation() + attemptedPreviewWindowIDs.removeAll(keepingCapacity: true) + hasAttemptedDesktopCapture = false + previewPendingCount = 0 + previewPermissionState = windowPreviewsEnabled ? .notDetermined : .disabled + selection = initialSelection(in: snapshot) + activeWorkspaceByMonitor = Dictionary( + uniqueKeysWithValues: snapshot.monitors.map { ($0.id, $0.activeWorkspace) } + ) + viewports = Dictionary(uniqueKeysWithValues: snapshot.monitors.map { monitor in + ( + monitor.id, + OverviewViewport( + horizontalOffsets: Dictionary( + uniqueKeysWithValues: monitor.workspaces.map { + ($0.id, $0.scrollOffset) + } + ) + ) + ) + }) + for monitor in snapshot.monitors { + guard let screen = screen(for: monitor.id) else { continue } + panels[monitor.id] = OverviewPanel( + monitorID: monitor.id, + screen: screen, + delegate: self + ) + } + isOpen = true + updatePanels() + openStateHandler(true) + let animated = animationsEnabled + && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + for panel in panels.values { + panel.show(animated: animated) + } + } + + public func update( + snapshot: OverviewSnapshot, + layout: LayoutSettings, + borders: BordersConfig = BordersConfig(), + animation: AnimationConfig = AnimationConfig(), + zoom: Double = 0.5, + windowPreviewsEnabled: Bool? = nil + ) { + guard isOpen else { return } + if let windowPreviewsEnabled, + self.windowPreviewsEnabled != windowPreviewsEnabled + { + self.windowPreviewsEnabled = windowPreviewsEnabled + previewTask?.cancel() + previewTask = nil + previewCache.removeAll(keepingCapacity: true) + if windowPreviewsEnabled { + restoreRememberedPreviews(for: snapshot) + } else { + removeAllRememberedPreviews() + } + resetPreviewFadeAnimation() + attemptedPreviewWindowIDs.removeAll(keepingCapacity: true) + hasAttemptedDesktopCapture = false + previewPendingCount = 0 + previewPermissionState = windowPreviewsEnabled ? .notDetermined : .disabled + } + self.snapshot = snapshot + pruneRememberedPreviews(for: snapshot) + self.layout = layout + borderStyle = WindowBorderStyle(config: borders) + animationsEnabled = animation.enabled + overviewZoom = zoom + if drag == nil { + let focusedSelection = initialSelection(in: snapshot) + if focusedSelection != selection { + selection = focusedSelection + alignSelectionOnNextUpdate = true + } + } else if let drag, + snapshot.windows[drag.windowID]?.appID != drag.appID + || snapshot.location(of: drag.windowID) + != OverviewLocation( + monitorID: drag.sourceMonitorID, + workspaceID: drag.sourceWorkspaceID + ) + { + self.drag = nil + edgeScrollTimer?.invalidate() + edgeScrollTimer = nil + edgeScrollDirection = nil + } + var viewportTargets: [MonitorID: OverviewViewport] = [:] + for monitor in snapshot.monitors { + if viewports[monitor.id] == nil { + viewports[monitor.id] = OverviewViewport() + } + for workspace in monitor.workspaces + where viewports[monitor.id]?.horizontalOffsets[workspace.id] == nil { + viewports[monitor.id]?.horizontalOffsets[workspace.id] = workspace.scrollOffset + } + if let previousWorkspaceID = activeWorkspaceByMonitor[monitor.id], + previousWorkspaceID != monitor.activeWorkspace, + let previousIndex = monitor.workspaces.firstIndex(where: { + $0.id == previousWorkspaceID + }), + let activeIndex = monitor.workspaces.firstIndex(where: { + $0.id == monitor.activeWorkspace + }), + var viewport = viewports[monitor.id] + { + cancelViewportAnimation(on: monitor.id) + viewport.workspaceOffset += Double(previousIndex - activeIndex) + viewports[monitor.id] = viewport + viewport.workspaceOffset = 0 + viewportTargets[monitor.id] = viewport + } + activeWorkspaceByMonitor[monitor.id] = monitor.activeWorkspace + } + if alignSelectionOnNextUpdate, + let location = selection?.location, + let workspace = snapshot.monitors.first(where: { + $0.id == location.monitorID + })?.workspaces.first(where: { $0.id == location.workspaceID }) + { + var viewport = viewportTargets[location.monitorID] + ?? viewportAnimations[location.monitorID]?.to + ?? viewports[location.monitorID] + ?? OverviewViewport() + viewport.horizontalOffsets[location.workspaceID] = workspace.scrollOffset + viewportTargets[location.monitorID] = viewport + alignSelectionOnNextUpdate = false + } + let monitorIDs = Set(snapshot.monitors.map(\.id)) + guard Set(panels.keys) == monitorIDs, + snapshot.monitors.allSatisfy({ screen(for: $0.id) != nil }) + else { + close() + return + } + if let selection, !selection.isValid(in: snapshot) { + self.selection = initialSelection(in: snapshot) + drag = nil + } + for (monitorID, viewport) in viewportTargets { + animateViewport(on: monitorID, to: viewport) + } + updatePanels() + } + + public func close() { + guard isOpen else { return } + isOpen = false + sessionGeneration &+= 1 + edgeScrollTimer?.invalidate() + edgeScrollTimer = nil + edgeScrollDirection = nil + drag = nil + alignSelectionOnNextUpdate = false + expectedActivationProcessID = nil + previewTask?.cancel() + previewTask = nil + previewCache.removeAll(keepingCapacity: true) + resetPreviewFadeAnimation() + attemptedPreviewWindowIDs.removeAll(keepingCapacity: true) + hasAttemptedDesktopCapture = false + previewPendingCount = 0 + stopViewportAnimations() + openStateHandler(false) + let closingPanels = Array(panels.values) + panels.removeAll(keepingCapacity: true) + let animated = animationsEnabled + && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + for panel in closingPanels { + panel.hide(animated: animated) + } + } + + public func handleKey(_ action: OverviewKeyAction) { + guard isOpen else { return } + switch action { + case .cancel: + close() + case .select: + chooseSelection() + case .left, .right, .up, .down: + navigate(action) + } + } + + @objc private func applicationActivated(_ notification: Notification) { + guard isOpen else { return } + let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication + if application?.processIdentifier == expectedActivationProcessID { + expectedActivationProcessID = nil + return + } + if let processID = application?.processIdentifier, + let snapshot, + let selectedWindowID = selection?.windowID, + snapshot.windows[selectedWindowID]?.processID == processID + { + return + } + close() + } + + @objc private func closeForSystemTransition(_ notification: Notification) { + close() + } + + private func chooseSelection() { + guard let snapshot, let selection else { return } + switch selection { + case .window(let windowID, let monitorID, let workspaceID): + guard let window = snapshot.windows[windowID] else { return } + close() + focusWindowHandler(windowID, window.appID, monitorID, workspaceID) + case .workspace(let monitorID, let workspaceID): + close() + focusWorkspaceHandler(monitorID, workspaceID) + } + } + + private func navigate(_ action: OverviewKeyAction) { + guard let snapshot, + let target = navigationTarget( + from: selection ?? initialSelection(in: snapshot), + action: action, + snapshot: snapshot + ) + else { return } + selection = target + alignSelectionOnNextUpdate = true + switch target { + case .window(let windowID, let monitorID, let workspaceID): + guard let window = snapshot.windows[windowID] else { return } + expectActivation(of: window.processID) + focusWindowHandler(windowID, window.appID, monitorID, workspaceID) + case .workspace(let monitorID, let workspaceID): + focusWorkspaceHandler(monitorID, workspaceID) + } + updatePanels() + } + + private func expectActivation(of processID: Int32?) { + guard let processID else { return } + expectedActivationGeneration &+= 1 + let generation = expectedActivationGeneration + expectedActivationProcessID = pid_t(processID) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self, self.expectedActivationGeneration == generation else { return } + self.expectedActivationProcessID = nil + } + } + + private func navigationTarget( + from selection: OverviewSelection?, + action: OverviewKeyAction, + snapshot: OverviewSnapshot + ) -> OverviewSelection? { + guard let selection, + let location = selection.location, + let monitor = snapshot.monitors.first(where: { $0.id == location.monitorID }), + let workspaceIndex = monitor.workspaces.firstIndex(where: { + $0.id == location.workspaceID + }) + else { return initialSelection(in: snapshot) } + let workspace = monitor.workspaces[workspaceIndex] + + switch action { + case .left, .right: + let delta = action == .left ? -1 : 1 + if case .window(let windowID, _, _) = selection, + let columnIndex = workspace.columns.firstIndex(where: { + $0.windows.contains(windowID) + }) + { + let targetColumnIndex = columnIndex + delta + guard workspace.columns.indices.contains(targetColumnIndex) else { return selection } + let targetColumn = workspace.columns[targetColumnIndex] + guard !targetColumn.windows.isEmpty else { return selection } + let sourceWindowIndex = workspace.columns[columnIndex].windows.firstIndex( + of: windowID + ) ?? 0 + let targetWindowID = targetColumn.windows[ + min(sourceWindowIndex, targetColumn.windows.count - 1) + ] + return .window( + windowID: targetWindowID, + monitorID: monitor.id, + workspaceID: workspace.id + ) + } + return firstSelection(in: workspace, monitorID: monitor.id) ?? selection + case .up, .down: + let delta = action == .up ? -1 : 1 + if case .window(let windowID, _, _) = selection, + let columnIndex = workspace.columns.firstIndex(where: { + $0.windows.contains(windowID) + }), + let windowIndex = workspace.columns[columnIndex].windows.firstIndex(of: windowID) + { + let targetWindowIndex = windowIndex + delta + if workspace.columns[columnIndex].windows.indices.contains(targetWindowIndex) { + return .window( + windowID: workspace.columns[columnIndex].windows[targetWindowIndex], + monitorID: monitor.id, + workspaceID: workspace.id + ) + } + } + let adjacentIndex = workspaceIndex + delta + guard monitor.workspaces.indices.contains(adjacentIndex) else { return selection } + return firstSelection( + in: monitor.workspaces[adjacentIndex], + monitorID: monitor.id + ) ?? .workspace( + monitorID: monitor.id, + workspaceID: monitor.workspaces[adjacentIndex].id + ) + case .select, .cancel: + return selection + } + } + + private func firstSelection( + in workspace: Workspace, + monitorID: MonitorID + ) -> OverviewSelection? { + if workspace.focusedLayer == .floating, + workspace.floatingWindows.indices.contains(workspace.focusedFloatingWindow) + { + return .window( + windowID: workspace.floatingWindows[workspace.focusedFloatingWindow], + monitorID: monitorID, + workspaceID: workspace.id + ) + } + if workspace.columns.indices.contains(workspace.focusedColumn) { + let column = workspace.columns[workspace.focusedColumn] + if column.windows.indices.contains(column.focusedWindow) { + return .window( + windowID: column.windows[column.focusedWindow], + monitorID: monitorID, + workspaceID: workspace.id + ) + } + } + if let windowID = workspace.columns.first?.windows.first + ?? workspace.floatingWindows.first + { + return .window( + windowID: windowID, + monitorID: monitorID, + workspaceID: workspace.id + ) + } + return nil + } + + private func initialSelection(in snapshot: OverviewSnapshot) -> OverviewSelection? { + guard let monitor = snapshot.activeMonitorID.flatMap({ activeID in + snapshot.monitors.first(where: { $0.id == activeID }) + }) ?? snapshot.monitors.first, + let workspace = monitor.workspaces.first(where: { + $0.id == monitor.activeWorkspace + }) + else { return nil } + return firstSelection(in: workspace, monitorID: monitor.id) + ?? .workspace(monitorID: monitor.id, workspaceID: workspace.id) + } + + private func updatePanels() { + guard let snapshot else { return } + var projections: [MonitorID: OverviewProjection] = [:] + let now = CACurrentMediaTime() + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + let previewOpacities = Dictionary( + uniqueKeysWithValues: previewCache.keys.map { windowID in + ( + windowID, + overviewPreviewOpacity( + startedAt: previewRevealStartedAt[windowID], + now: now, + reduceMotion: reduceMotion + ) + ) + } + ) + for (monitorID, panel) in panels { + let bounds = Rect( + x: 0, + y: 0, + width: panel.view.bounds.width, + height: panel.view.bounds.height + ) + let projection = projectOverview( + snapshot: snapshot, + monitorID: monitorID, + bounds: bounds, + viewport: viewports[monitorID] ?? OverviewViewport(), + layout: layout, + zoom: overviewZoom + ) + projections[monitorID] = projection + panel.view.update( + snapshot: snapshot, + projection: projection, + selection: selection, + drag: drag?.presentation(on: monitorID, panel: panel), + borderStyle: borderStyle, + previews: previewCache, + previewOpacities: previewOpacities + ) + } + self.projections = projections + schedulePreviewsIfNeeded() + } + + private func schedulePreviewsIfNeeded() { + guard windowPreviewsEnabled, isOpen, previewTask == nil, + previewPermissionState != .denied + else { return } + let requests = visiblePreviewRequests().filter { + !attemptedPreviewWindowIDs.contains($0.windowID) + } + let captureDesktop = !hasAttemptedDesktopCapture + guard captureDesktop || !requests.isEmpty else { return } + hasAttemptedDesktopCapture = true + attemptedPreviewWindowIDs.formUnion(requests.map(\.windowID)) + previewPendingCount = requests.count + let generation = sessionGeneration + previewTask = Task { @MainActor [weak self] in + await Task.yield() + await self?.capturePreviewBatch( + requests, + captureDesktop: captureDesktop, + generation: generation + ) + } + } + + private func capturePreviewBatch( + _ requests: [OverviewPreviewRequest], + captureDesktop: Bool, + generation: UInt64 + ) async { + guard windowPreviewsEnabled, isOpen, generation == sessionGeneration, + !Task.isCancelled + else { + finishPreviewBatch(generation: generation) + return + } + let permissionGranted: Bool + if CGPreflightScreenCaptureAccess() { + permissionGranted = true + } else if hasRequestedPreviewPermission { + permissionGranted = false + } else { + hasRequestedPreviewPermission = true + permissionGranted = await Task.detached(priority: .userInitiated) { + CGRequestScreenCaptureAccess() + }.value + } + guard windowPreviewsEnabled, isOpen, generation == sessionGeneration, + !Task.isCancelled + else { + finishPreviewBatch(generation: generation) + return + } + previewPermissionState = permissionGranted ? .granted : .denied + guard permissionGranted, !Task.isCancelled else { + finishPreviewBatch(generation: generation) + return + } + + let desktopRequests: [OverviewDesktopCaptureRequest] = captureDesktop + ? panels.compactMap { monitorID, panel in + guard let displayID = CGDirectDisplayID(exactly: monitorID.rawValue) else { + return nil + } + let scale = panel.window.backingScaleFactor + return OverviewDesktopCaptureRequest( + monitorID: monitorID, + displayID: displayID, + width: max(Int((panel.view.bounds.width * scale).rounded(.up)), 1), + height: max(Int((panel.view.bounds.height * scale).rounded(.up)), 1) + ) + } : [] + let results = await captureOverviewImages( + previews: requests, + desktops: desktopRequests + ) + guard windowPreviewsEnabled, isOpen, generation == sessionGeneration, + !Task.isCancelled + else { + finishPreviewBatch(generation: generation) + return + } + let currentRequests = Dictionary( + uniqueKeysWithValues: visiblePreviewRequests().map { ($0.windowID, $0) } + ) + let revealStartedAt = CACurrentMediaTime() + var didAddPreview = false + for (monitorID, image) in results.desktops { + panels[monitorID]?.setDesktopImage( + NSImage(cgImage: image, size: panels[monitorID]?.window.frame.size ?? .zero) + ) + } + for result in results.previews { + let isCurrent = overviewPreviewRequestIsCurrent( + result.request, + generation: generation, + currentGeneration: sessionGeneration, + currentRequest: currentRequests[result.request.windowID], + currentAppID: snapshot?.windows[result.request.windowID]?.appID + ) + guard isCurrent else { + previewFailureCount += 1 + continue + } + guard let image = result.image, image.width > 1, image.height > 1 else { + previewCache[result.request.windowID] = nil + forgetRememberedPreview(for: result.request.windowID) + previewFailureCount += 1 + continue + } + let preview = NSImage( + cgImage: image, + size: NSSize(width: result.request.width, height: result.request.height) + ) + let hadPreview = previewCache[result.request.windowID] != nil + previewCache[result.request.windowID] = preview + if let window = snapshot?.windows[result.request.windowID] { + rememberPreview( + preview, + cgImage: image, + for: window + ) + } + if !hadPreview { + previewRevealStartedAt[result.request.windowID] = revealStartedAt + didAddPreview = true + } + } + finishPreviewBatch(generation: generation) + if didAddPreview { startPreviewFadeAnimation() } + updatePanels() + } + + private func finishPreviewBatch(generation: UInt64) { + guard generation == sessionGeneration else { return } + previewTask = nil + previewPendingCount = 0 + } + + private func startPreviewFadeAnimation() { + previewFadeTimer?.invalidate() + guard animationsEnabled, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + else { + previewFadeTimer = nil + previewRevealStartedAt.removeAll(keepingCapacity: true) + return + } + let timer = Timer(timeInterval: 1 / 60, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + guard self.isOpen else { return self.resetPreviewFadeAnimation() } + let now = CACurrentMediaTime() + let isAnimating = self.previewRevealStartedAt.values.contains { + overviewPreviewOpacity(startedAt: $0, now: now, reduceMotion: false) < 1 + } + if !isAnimating { + self.resetPreviewFadeAnimation() + } + self.updatePanels() + } + } + previewFadeTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func resetPreviewFadeAnimation() { + previewFadeTimer?.invalidate() + previewFadeTimer = nil + previewRevealStartedAt.removeAll(keepingCapacity: true) + } + + private func restoreRememberedPreviews(for snapshot: OverviewSnapshot) { + guard windowPreviewsEnabled, CGPreflightScreenCaptureAccess() else { + removeAllRememberedPreviews() + return + } + pruneRememberedPreviews(for: snapshot) + for (windowID, remembered) in rememberedPreviews { + previewCache[windowID] = remembered.image + } + } + + private func pruneRememberedPreviews(for snapshot: OverviewSnapshot) { + for (windowID, remembered) in rememberedPreviews { + guard let window = snapshot.windows[windowID], + window.appID == remembered.appID, + window.processID == remembered.processID + else { + previewCache[windowID] = nil + forgetRememberedPreview(for: windowID) + continue + } + } + } + + private func rememberPreview( + _ image: NSImage, + cgImage: CGImage, + for window: Window + ) { + guard let processID = window.processID else { + forgetRememberedPreview(for: window.id) + return + } + let byteCost = cgImage.bytesPerRow * cgImage.height + let canStore = overviewPreviewCacheCanStore( + windowID: window.id, + byteCost: byteCost, + currentByteCosts: rememberedPreviewByteCosts, + maximumBytes: Self.rememberedPreviewByteLimit + ) + forgetRememberedPreview(for: window.id) + guard canStore else { return } + rememberedPreviews[window.id] = RememberedOverviewPreview( + image: image, + appID: window.appID, + processID: processID + ) + rememberedPreviewByteCosts[window.id] = byteCost + } + + private func forgetRememberedPreview(for windowID: WindowID) { + rememberedPreviews[windowID] = nil + rememberedPreviewByteCosts[windowID] = nil + } + + private func removeAllRememberedPreviews() { + rememberedPreviews.removeAll(keepingCapacity: true) + rememberedPreviewByteCosts.removeAll(keepingCapacity: true) + } + + private func visiblePreviewRequests() -> [OverviewPreviewRequest] { + guard let snapshot else { return [] } + var requests: [OverviewPreviewRequest] = [] + for (monitorID, projection) in projections { + let scale = panels[monitorID]?.window.backingScaleFactor ?? 1 + for card in projection.workspaces.flatMap(\.windows) { + guard let window = snapshot.windows[card.windowID] else { continue } + requests.append( + OverviewPreviewRequest( + windowID: card.windowID, + expectedAppID: window.appID, + width: min(max(Int((card.frame.width * scale).rounded(.up)), 32), 1_600), + height: min(max(Int((card.frame.height * scale).rounded(.up)), 24), 1_200) + ) + ) + } + } + if let selectedWindowID = selection?.windowID, + let index = requests.firstIndex(where: { $0.windowID == selectedWindowID }) + { + requests.insert(requests.remove(at: index), at: 0) + } + var seen = Set() + return requests.filter { seen.insert($0.windowID).inserted } + } + + private func screen(for monitorID: MonitorID) -> NSScreen? { + NSScreen.screens.first { screen in + (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? NSNumber)?.uint64Value == monitorID.rawValue + } + } + + private func animateViewport( + on monitorID: MonitorID, + to target: OverviewViewport + ) { + let current = viewports[monitorID] ?? target + guard current != target else { return } + guard animationsEnabled, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + let screen = screen(for: monitorID) + else { + cancelViewportAnimation(on: monitorID) + viewports[monitorID] = target + updatePanels() + return + } + viewportAnimations[monitorID] = OverviewViewportAnimation( + from: current, + to: target, + startedAt: CACurrentMediaTime(), + duration: 0.16 + ) + let link: CADisplayLink + if let existing = viewportDisplayLinks[monitorID] { + link = existing + } else { + link = screen.displayLink( + target: self, + selector: #selector(viewportDisplayLinkDidFire(_:)) + ) + link.add(to: .main, forMode: .common) + viewportDisplayLinks[monitorID] = link + displayLinkMonitorIDs[ObjectIdentifier(link)] = monitorID + } + link.isPaused = false + } + + @objc private func viewportDisplayLinkDidFire(_ link: CADisplayLink) { + guard let monitorID = displayLinkMonitorIDs[ObjectIdentifier(link)], + let animation = viewportAnimations[monitorID] + else { + link.isPaused = true + return + } + if !animationsEnabled || NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + viewports[monitorID] = animation.to + viewportAnimations[monitorID] = nil + link.isPaused = true + updatePanels() + return + } + let elapsed = CACurrentMediaTime() - animation.startedAt + if elapsed >= animation.duration { + viewports[monitorID] = animation.to + viewportAnimations[monitorID] = nil + link.isPaused = true + } else { + let progress = animatedScalar( + from: 0, + to: 1, + elapsed: elapsed, + duration: animation.duration + ) + viewports[monitorID] = interpolateOverviewViewport( + from: animation.from, + to: animation.to, + progress: progress + ) + } + updatePanels() + } + + private func cancelViewportAnimation(on monitorID: MonitorID) { + viewportAnimations[monitorID] = nil + viewportDisplayLinks[monitorID]?.isPaused = true + } + + private func stopViewportAnimations() { + viewportAnimations.removeAll(keepingCapacity: true) + for link in viewportDisplayLinks.values { link.invalidate() } + viewportDisplayLinks.removeAll(keepingCapacity: true) + displayLinkMonitorIDs.removeAll(keepingCapacity: true) + } + + private func closePanelsImmediately() { + stopViewportAnimations() + for panel in panels.values { panel.close() } + panels.removeAll(keepingCapacity: true) + isOpen = false + } + + private func panelAndPoint(at screenPoint: NSPoint) -> (OverviewPanel, OverviewPoint)? { + for panel in panels.values where panel.window.frame.contains(screenPoint) { + let point = panel.localPoint(fromScreen: screenPoint) + return (panel, OverviewPoint(x: point.x, y: point.y)) + } + return nil + } + + private func maximumHorizontalOffset( + for workspaceID: WorkspaceID, + on monitor: Monitor + ) -> Double? { + guard let snapshot, + let monitorFrame = snapshot.monitorFrames[monitor.id], + var workspace = monitor.workspaces.first(where: { $0.id == workspaceID }) + else { return nil } + workspace.focusedColumn = max(workspace.columns.count - 1, 0) + return focusedColumnLeftScrollOffset( + workspace: workspace, + viewport: Rect(x: 0, y: 0, width: monitorFrame.width, height: monitorFrame.height), + windows: Array(snapshot.windows.values), + settings: layout + ) + } +} + +@MainActor +extension OverviewController: OverviewViewDelegate { + fileprivate func overviewView( + _ view: OverviewView, + clickedAt point: NSPoint + ) { + guard let projection = projections[view.monitorID], + let hit = projection.hitTest(OverviewPoint(x: point.x, y: point.y)), + let snapshot + else { return } + activateMonitorHandler(view.monitorID) + switch hit { + case .window(let windowID, let monitorID, let workspaceID): + guard let window = snapshot.windows[windowID] else { return } + close() + focusWindowHandler(windowID, window.appID, monitorID, workspaceID) + case .workspace(let monitorID, let workspaceID): + close() + focusWorkspaceHandler(monitorID, workspaceID) + } + } + + fileprivate func overviewView( + _ view: OverviewView, + beganDragging windowID: WindowID, + at screenPoint: NSPoint + ) { + guard let snapshot, + let window = snapshot.windows[windowID], + let location = snapshot.location(of: windowID), + let sourceProjection = projections[view.monitorID], + let sourceCard = sourceProjection.workspaces.flatMap(\.windows).first( + where: { $0.windowID == windowID && $0.canDrag } + ) + else { return } + cancelViewportAnimation(on: view.monitorID) + drag = OverviewDrag( + windowID: windowID, + appID: window.appID, + sourceMonitorID: location.monitorID, + sourceWorkspaceID: location.workspaceID, + screenPoint: screenPoint, + cardSize: NSSize(width: sourceCard.frame.width, height: sourceCard.frame.height), + target: nil, + targetMonitorID: nil + ) + updateDrag(at: screenPoint) + } + + fileprivate func overviewView( + _ view: OverviewView, + draggedTo screenPoint: NSPoint + ) { + updateDrag(at: screenPoint) + } + + fileprivate func overviewView( + _ view: OverviewView, + endedDraggingAt screenPoint: NSPoint + ) { + guard let drag else { return } + edgeScrollTimer?.invalidate() + edgeScrollTimer = nil + self.drag = nil + updatePanels() + guard let target = drag.target else { return } + let location = target.location + selection = .window( + windowID: drag.windowID, + monitorID: location.monitorID, + workspaceID: location.workspaceID + ) + alignSelectionOnNextUpdate = true + expectActivation(of: snapshot?.windows[drag.windowID]?.processID) + activateMonitorHandler(location.monitorID) + dropHandler( + drag.windowID, + drag.appID, + drag.sourceMonitorID, + drag.sourceWorkspaceID, + target + ) + } + + fileprivate func overviewView( + _ view: OverviewView, + scrolled delta: NSPoint, + hasPreciseScrollingDeltas: Bool, + at point: NSPoint + ) { + guard let snapshot, + let monitor = snapshot.monitors.first(where: { $0.id == view.monitorID }) + else { return } + activateMonitorHandler(view.monitorID) + let scrollAxis = overviewScrollAxis(for: delta) + let workspaceID = scrollAxis == .horizontal + ? projections[view.monitorID]?.hitTest( + OverviewPoint(x: point.x, y: point.y) + )?.workspaceID + : nil + let activeIndex = monitor.workspaces.firstIndex(where: { + $0.id == monitor.activeWorkspace + }) ?? 0 + let viewport: OverviewViewport + if hasPreciseScrollingDeltas { + cancelViewportAnimation(on: view.monitorID) + viewport = viewports[view.monitorID] ?? OverviewViewport() + } else { + viewport = viewportAnimations[view.monitorID]?.to + ?? viewports[view.monitorID] + ?? OverviewViewport() + } + let target = overviewViewportAfterScroll( + viewport, + delta: delta, + hasPreciseScrollingDeltas: hasPreciseScrollingDeltas, + viewSize: view.bounds.size, + zoom: overviewZoom, + activeWorkspaceIndex: activeIndex, + workspaceCount: monitor.workspaces.count, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: workspaceID.flatMap { + maximumHorizontalOffset(for: $0, on: monitor) + } + ) + if hasPreciseScrollingDeltas { + guard target != viewport else { return } + viewports[view.monitorID] = target + updatePanels() + } else { + animateViewport(on: view.monitorID, to: target) + } + } + + fileprivate func overviewView( + _ view: OverviewView, + rightDraggedBy deltaX: Double, + at point: NSPoint + ) { + guard let snapshot, + let monitor = snapshot.monitors.first(where: { $0.id == view.monitorID }), + let viewport = viewports[view.monitorID], + let workspaceID = projections[view.monitorID]?.hitTest( + OverviewPoint(x: point.x, y: point.y) + )?.workspaceID, + let maximumOffset = maximumHorizontalOffset(for: workspaceID, on: monitor) + else { return } + activateMonitorHandler(view.monitorID) + cancelViewportAnimation(on: view.monitorID) + let activeIndex = monitor.workspaces.firstIndex(where: { + $0.id == monitor.activeWorkspace + }) ?? 0 + viewports[view.monitorID] = overviewViewportAfterScroll( + viewport, + delta: NSPoint(x: deltaX, y: 0), + hasPreciseScrollingDeltas: true, + viewSize: view.bounds.size, + zoom: overviewZoom, + activeWorkspaceIndex: activeIndex, + workspaceCount: monitor.workspaces.count, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: maximumOffset + ) + updatePanels() + } + + fileprivate func overviewView( + _ view: OverviewView, + pageWorkspace workspaceID: WorkspaceID, + direction: Int + ) { + guard let snapshot, + let monitor = snapshot.monitors.first(where: { $0.id == view.monitorID }), + let maximumOffset = maximumHorizontalOffset(for: workspaceID, on: monitor) + else { return } + var viewport = viewportAnimations[view.monitorID]?.to + ?? viewports[view.monitorID] + ?? OverviewViewport() + activateMonitorHandler(view.monitorID) + let currentOffset = viewport.horizontalOffsets[workspaceID] ?? 0 + viewport.horizontalOffsets[workspaceID] = min( + max(currentOffset + Double(direction), 0), + maximumOffset + ) + animateViewport(on: view.monitorID, to: viewport) + } + + private func updateDrag(at screenPoint: NSPoint) { + guard var drag, let snapshot else { return } + drag.screenPoint = screenPoint + if let (panel, point) = panelAndPoint(at: screenPoint), + let projection = projections[panel.monitorID] + { + drag.target = overviewDropTarget( + at: point, + sourceWindowID: drag.windowID, + projection: projection, + snapshot: snapshot + ) + drag.targetMonitorID = panel.monitorID + activateEdgeScroll(for: panel, localY: point.y) + } else { + drag.target = nil + drag.targetMonitorID = nil + edgeScrollTimer?.invalidate() + edgeScrollTimer = nil + edgeScrollDirection = nil + } + self.drag = drag + updatePanels() + } + + private func activateEdgeScroll(for panel: OverviewPanel, localY: Double) { + let margin = 56.0 + let direction: Double + if localY < margin { + direction = -1 + } else if localY > panel.view.bounds.height - margin { + direction = 1 + } else { + edgeScrollTimer?.invalidate() + edgeScrollTimer = nil + edgeScrollDirection = nil + return + } + if edgeScrollDirection == direction { return } + edgeScrollTimer?.invalidate() + edgeScrollDirection = direction + edgeScrollTimer = Timer.scheduledTimer( + withTimeInterval: 0.15, + repeats: true + ) { [weak self, weak panel] _ in + MainActor.assumeIsolated { + guard let self, let panel, + var viewport = self.viewports[panel.monitorID], + let monitor = self.snapshot?.monitors.first(where: { + $0.id == panel.monitorID + }) + else { return } + self.cancelViewportAnimation(on: panel.monitorID) + let activeIndex = monitor.workspaces.firstIndex(where: { + $0.id == monitor.activeWorkspace + }) ?? 0 + viewport.workspaceOffset = min( + max(viewport.workspaceOffset + direction * 0.2, Double(-activeIndex)), + Double(monitor.workspaces.count - activeIndex - 1) + ) + self.viewports[panel.monitorID] = viewport + self.updatePanels() + } + } + } +} + +private enum OverviewSelection: Equatable { + case window(windowID: WindowID, monitorID: MonitorID, workspaceID: WorkspaceID) + case workspace(monitorID: MonitorID, workspaceID: WorkspaceID) + + var location: (monitorID: MonitorID, workspaceID: WorkspaceID)? { + switch self { + case .window(_, let monitorID, let workspaceID), + .workspace(let monitorID, let workspaceID): + (monitorID, workspaceID) + } + } + + var windowID: WindowID? { + if case .window(let windowID, _, _) = self { return windowID } + return nil + } + + func isValid(in snapshot: OverviewSnapshot) -> Bool { + guard let location, + let workspace = snapshot.monitors.first(where: { + $0.id == location.monitorID + })?.workspaces.first(where: { $0.id == location.workspaceID }) + else { return false } + switch self { + case .window(let windowID, _, _): + return snapshot.windows[windowID] != nil + && (workspace.columns.contains(where: { $0.windows.contains(windowID) }) + || workspace.floatingWindows.contains(windowID)) + case .workspace: + return true + } + } +} + +private struct OverviewLocation: Equatable { + let monitorID: MonitorID + let workspaceID: WorkspaceID +} + +private extension OverviewSnapshot { + func location(of windowID: WindowID) -> OverviewLocation? { + for monitor in monitors { + for workspace in monitor.workspaces + where workspace.columns.contains(where: { $0.windows.contains(windowID) }) + || workspace.floatingWindows.contains(windowID) + { + return OverviewLocation(monitorID: monitor.id, workspaceID: workspace.id) + } + } + return nil + } +} + +private struct OverviewDrag { + let windowID: WindowID + let appID: String + let sourceMonitorID: MonitorID + let sourceWorkspaceID: WorkspaceID + var screenPoint: NSPoint + let cardSize: NSSize + var target: OverviewDropTarget? + var targetMonitorID: MonitorID? + + @MainActor + func presentation( + on monitorID: MonitorID, + panel: OverviewPanel + ) -> OverviewDragPresentation { + let localPoint = targetMonitorID == monitorID + ? panel.localPoint(fromScreen: screenPoint) + : nil + return OverviewDragPresentation( + windowID: windowID, + localPoint: localPoint, + cardSize: cardSize, + target: targetMonitorID == monitorID ? target : nil + ) + } +} + +private struct OverviewDragPresentation { + let windowID: WindowID + let localPoint: NSPoint? + let cardSize: NSSize + let target: OverviewDropTarget? +} + +private extension OverviewHit { + var workspaceID: WorkspaceID { + switch self { + case .window(_, _, let workspaceID), .workspace(_, let workspaceID): + workspaceID + } + } +} + +private extension OverviewDropTarget { + var location: (monitorID: MonitorID, workspaceID: WorkspaceID) { + switch self { + case .newColumn(let monitorID, let workspaceID, _), + .stack(let monitorID, let workspaceID, _, _), + .floating(let monitorID, let workspaceID, _): + (monitorID, workspaceID) + } + } +} + +@MainActor +private protocol OverviewViewDelegate: AnyObject { + func overviewView(_ view: OverviewView, clickedAt point: NSPoint) + func overviewView(_ view: OverviewView, beganDragging windowID: WindowID, at: NSPoint) + func overviewView(_ view: OverviewView, draggedTo: NSPoint) + func overviewView(_ view: OverviewView, endedDraggingAt: NSPoint) + func overviewView( + _ view: OverviewView, + scrolled delta: NSPoint, + hasPreciseScrollingDeltas: Bool, + at: NSPoint + ) + func overviewView(_ view: OverviewView, rightDraggedBy deltaX: Double, at: NSPoint) + func overviewView(_ view: OverviewView, pageWorkspace: WorkspaceID, direction: Int) +} + +@MainActor +private final class OverviewPanel { + let monitorID: MonitorID + let window: NSPanel + let view: OverviewView + private let desktopView: NSView + + init(monitorID: MonitorID, screen: NSScreen, delegate: OverviewViewDelegate) { + self.monitorID = monitorID + view = OverviewView(monitorID: monitorID, delegate: delegate) + desktopView = NSView(frame: NSRect(origin: .zero, size: screen.frame.size)) + window = NSPanel( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false, + screen: screen + ) + window.setFrame(screen.frame, display: false) + window.title = "Defi Overview" + window.setAccessibilityLabel("Defi Overview") + window.isOpaque = true + window.backgroundColor = .black + window.hasShadow = false + window.hidesOnDeactivate = false + window.isReleasedWhenClosed = false + window.isExcludedFromWindowsMenu = true + window.animationBehavior = .none + window.level = .floating + window.collectionBehavior = [ + .canJoinAllSpaces, + .fullScreenAuxiliary, + .stationary, + .ignoresCycle, + ] + window.sharingType = .readOnly + let rootView = NSView(frame: NSRect(origin: .zero, size: screen.frame.size)) + rootView.wantsLayer = true + rootView.layer?.backgroundColor = NSColor.black.cgColor + rootView.autoresizingMask = [.width, .height] + desktopView.wantsLayer = true + desktopView.layer?.backgroundColor = NSColor.black.cgColor + desktopView.layer?.contentsGravity = .resizeAspectFill + desktopView.layer?.contentsScale = screen.backingScaleFactor + desktopView.layer?.masksToBounds = true + desktopView.autoresizingMask = [.width, .height] + if let url = NSWorkspace.shared.desktopImageURL(for: screen), + let image = NSImage(contentsOf: url) + { + desktopView.layer?.contents = image + } + let glassView = NSGlassEffectView( + frame: NSRect(origin: .zero, size: screen.frame.size) + ) + glassView.style = .regular + glassView.appearance = NSAppearance(named: .darkAqua) + glassView.tintColor = NSColor.black.withAlphaComponent(0.58) + glassView.autoresizingMask = [.width, .height] + view.frame = glassView.bounds + view.autoresizingMask = [.width, .height] + glassView.contentView = view + rootView.addSubview(desktopView) + rootView.addSubview(glassView) + window.contentView = rootView + } + + func setDesktopImage(_ image: NSImage) { + desktopView.layer?.contents = image + } + + func show(animated: Bool) { + window.alphaValue = animated ? 0 : 1 + view.wantsLayer = true + view.layer?.setAffineTransform(animated ? CGAffineTransform(scaleX: 0.97, y: 0.97) : .identity) + window.orderFrontRegardless() + guard animated else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().alphaValue = 1 + view.layer?.setAffineTransform(.identity) + } + } + + func hide(animated: Bool) { + guard animated else { + close() + return + } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + window.animator().alphaValue = 0 + view.layer?.setAffineTransform(CGAffineTransform(scaleX: 0.98, y: 0.98)) + } completionHandler: { [weak self] in + MainActor.assumeIsolated { + self?.close() + } + } + } + + func localPoint(fromScreen point: NSPoint) -> NSPoint { + let windowPoint = window.convertPoint(fromScreen: point) + return view.convert(windowPoint, from: nil) + } + + func close() { + window.orderOut(nil) + window.close() + } +} + +@MainActor +private final class OverviewView: NSView { + private static let windowCornerRadius = 9.0 + + let monitorID: MonitorID + private weak var delegate: OverviewViewDelegate? + private var snapshot: OverviewSnapshot? + private var projection: OverviewProjection? + private var selection: OverviewSelection? + private var drag: OverviewDragPresentation? + private var borderStyle = WindowBorderStyle(config: BordersConfig()) + private var previews: [WindowID: NSImage] = [:] + private var previewOpacities: [WindowID: Double] = [:] + private var mouseDownPoint: NSPoint? + private var mouseDownWindowID: WindowID? + private var mouseDownOverflow: (workspaceID: WorkspaceID, direction: Int)? + private var leftDragStarted = false + private var rightDragPoint: NSPoint? + private var iconCache: [String: NSImage] = [:] + + override var isFlipped: Bool { true } + + init(monitorID: MonitorID, delegate: OverviewViewDelegate) { + self.monitorID = monitorID + self.delegate = delegate + super.init(frame: .zero) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityLabel("Defi Overview") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + func update( + snapshot: OverviewSnapshot, + projection: OverviewProjection, + selection: OverviewSelection?, + drag: OverviewDragPresentation?, + borderStyle: WindowBorderStyle, + previews: [WindowID: NSImage], + previewOpacities: [WindowID: Double] + ) { + self.snapshot = snapshot + self.projection = projection + self.selection = selection + self.drag = drag + self.borderStyle = borderStyle + self.previews = previews + self.previewOpacities = previewOpacities + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + guard let projection, let snapshot else { return } + for workspace in projection.workspaces { + drawWorkspace(workspace, snapshot: snapshot) + } + drawDraggedCard(snapshot: snapshot) + drawDropTarget() + } + + override func accessibilityChildren() -> [Any]? { + guard let projection, let snapshot, let window else { return [] } + return projection.workspaces.flatMap { workspace -> [NSAccessibilityElement] in + let workspaceElement = NSAccessibilityElement() + workspaceElement.setAccessibilityParent(self) + workspaceElement.setAccessibilityRole(.group) + workspaceElement.setAccessibilityEnabled(true) + workspaceElement.setAccessibilityLabel("Workspace \(workspace.workspaceID.rawValue)") + workspaceElement.setAccessibilityFrame( + window.convertToScreen(convert(nsRect(workspace.frame), to: nil)) + ) + let windowElements = workspace.windows.compactMap { card -> NSAccessibilityElement? in + guard let managedWindow = snapshot.windows[card.windowID] else { return nil } + let visibleFrame = nsRect(card.frame).intersection(nsRect(workspace.frame)) + guard !visibleFrame.isEmpty else { return nil } + let element = OverviewAccessibilityElement { [weak self] in + guard let self else { return } + self.delegate?.overviewView( + self, + clickedAt: NSPoint(x: visibleFrame.midX, y: visibleFrame.midY) + ) + } + element.setAccessibilityParent(self) + element.setAccessibilityRole(.button) + element.setAccessibilityEnabled(true) + element.setAccessibilityLabel( + managedWindow.title.isEmpty ? managedWindow.appID : managedWindow.title + ) + element.setAccessibilityHelp("Workspace \(workspace.workspaceID.rawValue)") + element.setAccessibilitySelected(selection?.windowID == card.windowID) + element.setAccessibilityFrame( + window.convertToScreen(convert(visibleFrame, to: nil)) + ) + return element + } + return [workspaceElement] + windowElements + } + } + + private func drawWorkspace( + _ workspace: OverviewWorkspaceProjection, + snapshot: OverviewSnapshot + ) { + let frame = nsRect(workspace.frame) + NSGraphicsContext.saveGraphicsState() + frame.clip() + for window in workspace.windows where drag?.windowID != window.windowID { + drawWindow(window, snapshot: snapshot) + } + NSGraphicsContext.restoreGraphicsState() + + NSGraphicsContext.saveGraphicsState() + frame.insetBy(dx: -borderStyle.width, dy: -borderStyle.width).clip() + for window in workspace.windows where drag?.windowID != window.windowID { + drawWindowBorder(window) + } + NSGraphicsContext.restoreGraphicsState() + drawHorizontalOverflowIndicators(for: workspace) + } + + private func drawWindow( + _ card: OverviewWindowProjection, + snapshot: OverviewSnapshot + ) { + guard let window = snapshot.windows[card.windowID] else { return } + let frame = nsRect(card.frame) + let path = NSBezierPath( + roundedRect: frame, + xRadius: Self.windowCornerRadius, + yRadius: Self.windowCornerRadius + ) + NSColor(calibratedWhite: card.isNativeFullscreen ? 0.19 : 0.15, alpha: 1).setFill() + path.fill() + if let preview = previews[card.windowID] { + let opacity = previewOpacities[card.windowID] ?? 1 + NSGraphicsContext.saveGraphicsState() + path.addClip() + preview.draw( + in: frame, + from: .zero, + operation: .sourceOver, + fraction: opacity, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) + let scrimHeight = min(max(frame.height * 0.28, 48), 72) + NSGradient( + starting: NSColor.black.withAlphaComponent(0.48 * opacity), + ending: NSColor.black.withAlphaComponent(0) + )?.draw( + from: NSPoint(x: frame.midX, y: frame.minY), + to: NSPoint(x: frame.midX, y: frame.minY + scrimHeight), + options: [] + ) + NSGraphicsContext.restoreGraphicsState() + } + let iconSize = min(24, max(frame.height * 0.16, 14)) + let icon = icon(for: window) + icon.draw( + in: NSRect( + x: frame.minX + 10, + y: frame.minY + 9, + width: iconSize, + height: iconSize + ) + ) + let titleRect = NSRect( + x: frame.minX + iconSize + 18, + y: frame.minY + 10, + width: max(frame.width - iconSize - 28, 1), + height: 22 + ) + ((window.title.isEmpty ? window.appID : window.title) as NSString).draw( + in: titleRect, + withAttributes: [ + .font: NSFont.systemFont(ofSize: min(13, max(frame.height * 0.09, 10)), weight: .medium), + .foregroundColor: NSColor.white.withAlphaComponent(0.9), + ] + ) + if card.isNativeFullscreen { + let label = "Full Screen" as NSString + label.draw( + at: NSPoint(x: frame.minX + 10, y: frame.maxY - 26), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 10, weight: .semibold), + .foregroundColor: NSColor.white.withAlphaComponent(0.58), + ] + ) + } + } + + private func drawWindowBorder(_ card: OverviewWindowProjection) { + let selected = selection == .window( + windowID: card.windowID, + monitorID: monitorID, + workspaceID: selection?.location?.workspaceID ?? WorkspaceID(rawValue: "") + ) + if let border = overviewWindowBorderAppearance( + isSelected: selected, + style: borderStyle + ) { + overviewBorderColor(border.color).setStroke() + let geometry = overviewWindowBorderGeometry( + cardFrame: card.frame, + cardRadius: Self.windowCornerRadius, + width: border.width, + placement: borderStyle.placement + ) + let borderPath = NSBezierPath( + roundedRect: nsRect(geometry.frame), + xRadius: geometry.radius, + yRadius: geometry.radius + ) + borderPath.lineWidth = border.width + borderPath.stroke() + } + } + + private func drawHorizontalOverflowIndicators( + for workspace: OverviewWorkspaceProjection + ) { + if workspace.hiddenTiledWindowCountBefore > 0 { + drawHorizontalOverflowIndicator( + "\u{2190} \(workspace.hiddenTiledWindowCountBefore)", + leading: true, + in: nsRect(workspace.frame) + ) + } + if workspace.hiddenTiledWindowCountAfter > 0 { + drawHorizontalOverflowIndicator( + "\(workspace.hiddenTiledWindowCountAfter) \u{2192}", + leading: false, + in: nsRect(workspace.frame) + ) + } + } + + private func drawHorizontalOverflowIndicator( + _ label: String, + leading: Bool, + in frame: NSRect + ) { + let indicatorFrame = horizontalOverflowIndicatorFrame(leading: leading, in: frame) + let path = NSBezierPath(roundedRect: indicatorFrame, xRadius: 15, yRadius: 15) + NSColor.black.withAlphaComponent(0.72).setFill() + path.fill() + (label as NSString).draw( + in: indicatorFrame.insetBy(dx: 8, dy: 6), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 14, weight: .semibold), + .foregroundColor: NSColor.white.withAlphaComponent(0.92), + .paragraphStyle: centeredParagraphStyle, + ] + ) + } + + private var centeredParagraphStyle: NSParagraphStyle { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + } + + private func icon(for window: Window) -> NSImage { + if let cached = iconCache[window.appID] { return cached } + let icon = window.processID.flatMap { + NSRunningApplication(processIdentifier: pid_t($0))?.icon + } ?? NSImage(systemSymbolName: "app", accessibilityDescription: window.appID) + ?? NSImage(size: NSSize(width: 24, height: 24)) + iconCache[window.appID] = icon + return icon + } + + private func overviewBorderColor(_ value: UInt32) -> NSColor { + NSColor( + srgbRed: CGFloat((value >> 16) & 0xff) / 255, + green: CGFloat((value >> 8) & 0xff) / 255, + blue: CGFloat(value & 0xff) / 255, + alpha: CGFloat(windowBorderAlpha(of: value)) / 255 + ) + } + + private func drawDropTarget() { + guard let target = drag?.target, let projection else { return } + NSColor.controlAccentColor.setStroke() + switch target { + case .newColumn(_, let workspaceID, let columnIndex): + guard let workspace = projection.workspaces.first(where: { + $0.workspaceID == workspaceID + }) else { return } + let columns = workspace.windows.compactMap { window -> (Int, Rect)? in + guard case .tiled(let index, _) = window.layer else { return nil } + return (index, window.frame) + } + let x = columns.filter { $0.0 == columnIndex }.map(\.1.x).min() + ?? columns.map { $0.1.x + $0.1.width }.max() + ?? workspace.frame.x + 8 + let band = NSBezierPath( + roundedRect: NSRect( + x: x - 10, + y: workspace.frame.y + 8, + width: 20, + height: workspace.frame.height - 16 + ), + xRadius: 10, + yRadius: 10 + ) + NSColor.controlAccentColor.withAlphaComponent(0.18).setFill() + band.fill() + NSColor.controlAccentColor.setStroke() + let path = NSBezierPath() + path.move(to: NSPoint(x: x, y: workspace.frame.y + 12)) + path.line(to: NSPoint(x: x, y: workspace.frame.y + workspace.frame.height - 12)) + path.lineWidth = 3 + path.stroke() + case .stack(_, let workspaceID, let columnIndex, let windowIndex): + let cards = projection.workspaces.first(where: { + $0.workspaceID == workspaceID + })?.windows.filter { + if case .tiled(let index, _) = $0.layer { return index == columnIndex } + return false + }.sorted { $0.frame.y < $1.frame.y } ?? [] + guard let first = cards.first, let last = cards.last else { return } + let y = windowIndex < cards.count + ? cards[windowIndex].frame.y + : last.frame.y + last.frame.height + let path = NSBezierPath() + path.move(to: NSPoint(x: first.frame.x + 8, y: y)) + path.line(to: NSPoint(x: first.frame.x + first.frame.width - 8, y: y)) + path.lineWidth = 4 + path.stroke() + case .floating: + break + } + } + + private func drawDraggedCard(snapshot: OverviewSnapshot) { + guard let drag, let point = drag.localPoint, + let window = snapshot.windows[drag.windowID] + else { return } + let frame = NSRect( + x: point.x - drag.cardSize.width / 2, + y: point.y - drag.cardSize.height / 2, + width: drag.cardSize.width, + height: drag.cardSize.height + ) + let path = NSBezierPath(roundedRect: frame, xRadius: 9, yRadius: 9) + NSColor(calibratedWhite: 0.18, alpha: 0.94).setFill() + path.fill() + NSColor.controlAccentColor.setStroke() + path.lineWidth = 3 + path.stroke() + ((window.title.isEmpty ? window.appID : window.title) as NSString).draw( + in: frame.insetBy(dx: 12, dy: 10), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 13, weight: .semibold), + .foregroundColor: NSColor.white, + ] + ) + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + mouseDownPoint = point + mouseDownOverflow = horizontalOverflowAction(at: point) + if mouseDownOverflow == nil { + mouseDownWindowID = projection?.hitTest( + OverviewPoint(x: point.x, y: point.y) + ).flatMap { hit in + if case .window(let windowID, _, _) = hit { return windowID } + return nil + } + } else { + mouseDownWindowID = nil + } + leftDragStarted = false + } + + override func mouseDragged(with event: NSEvent) { + guard let start = mouseDownPoint, let windowID = mouseDownWindowID else { return } + let point = convert(event.locationInWindow, from: nil) + let screenPoint = window?.convertPoint(toScreen: event.locationInWindow) ?? .zero + if !leftDragStarted, hypot(point.x - start.x, point.y - start.y) >= 5 { + leftDragStarted = true + delegate?.overviewView(self, beganDragging: windowID, at: screenPoint) + } + if leftDragStarted { + delegate?.overviewView(self, draggedTo: screenPoint) + } + } + + override func mouseUp(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + if leftDragStarted { + let screenPoint = window?.convertPoint(toScreen: event.locationInWindow) ?? .zero + delegate?.overviewView(self, endedDraggingAt: screenPoint) + } else if let pressed = mouseDownOverflow, + let released = horizontalOverflowAction(at: point), + pressed.workspaceID == released.workspaceID, + pressed.direction == released.direction + { + delegate?.overviewView( + self, + pageWorkspace: released.workspaceID, + direction: released.direction + ) + } else { + delegate?.overviewView(self, clickedAt: point) + } + mouseDownPoint = nil + mouseDownWindowID = nil + mouseDownOverflow = nil + leftDragStarted = false + } + + override func rightMouseDown(with event: NSEvent) { + rightDragPoint = convert(event.locationInWindow, from: nil) + } + + override func rightMouseDragged(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + guard let previous = rightDragPoint else { return } + delegate?.overviewView(self, rightDraggedBy: point.x - previous.x, at: point) + rightDragPoint = point + } + + override func rightMouseUp(with event: NSEvent) { + rightDragPoint = nil + } + + override func scrollWheel(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + delegate?.overviewView( + self, + scrolled: NSPoint(x: event.scrollingDeltaX, y: event.scrollingDeltaY), + hasPreciseScrollingDeltas: event.hasPreciseScrollingDeltas, + at: point + ) + } + + private func nsRect(_ rect: Rect) -> NSRect { + NSRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + } + + private func horizontalOverflowAction( + at point: NSPoint + ) -> (workspaceID: WorkspaceID, direction: Int)? { + guard let projection else { return nil } + for workspace in projection.workspaces.reversed() { + let frame = nsRect(workspace.frame) + if workspace.hiddenTiledWindowCountBefore > 0, + horizontalOverflowIndicatorFrame(leading: true, in: frame).contains(point) + { + return (workspace.workspaceID, -1) + } + if workspace.hiddenTiledWindowCountAfter > 0, + horizontalOverflowIndicatorFrame(leading: false, in: frame).contains(point) + { + return (workspace.workspaceID, 1) + } + } + return nil + } + + private func horizontalOverflowIndicatorFrame( + leading: Bool, + in frame: NSRect + ) -> NSRect { + let size = NSSize(width: 46, height: 30) + return NSRect( + x: leading ? frame.minX + 12 : frame.maxX - size.width - 12, + y: frame.midY - size.height / 2, + width: size.width, + height: size.height + ) + } +} + +@MainActor +private final class OverviewAccessibilityElement: NSAccessibilityElement { + private nonisolated let press: @MainActor @Sendable () -> Void + + init(press: @escaping @MainActor @Sendable () -> Void) { + self.press = press + super.init() + } + + nonisolated override func accessibilityPerformPress() -> Bool { + let press = press + Task { @MainActor in press() } + return true + } +} diff --git a/Sources/DefiMacOS/OverviewPreviewCapture.swift b/Sources/DefiMacOS/OverviewPreviewCapture.swift new file mode 100644 index 0000000..e1782f6 --- /dev/null +++ b/Sources/DefiMacOS/OverviewPreviewCapture.swift @@ -0,0 +1,241 @@ +import CoreGraphics +import CoreImage +import DefiModel +import Foundation +import ScreenCaptureKit + +private let overviewPreviewCIContext = CIContext(options: [.cacheIntermediates: false]) + +func overviewPreviewBlurFadeHeight(imageHeight: CGFloat) -> CGFloat { + min(max(imageHeight * 0.24, 72), 144) +} + +func progressivelyBlurredOverviewPreview(_ image: CGImage) -> CGImage? { + let source = CIImage(cgImage: image) + let extent = source.extent + guard extent.width > 1, extent.height > 1, + let gradient = CIFilter(name: "CISmoothLinearGradient"), + let blur = CIFilter(name: "CIMaskedVariableBlur") + else { return nil } + + let fadeHeight = overviewPreviewBlurFadeHeight(imageHeight: extent.height) + gradient.setValue( + CIVector(x: extent.midX, y: extent.maxY), + forKey: "inputPoint0" + ) + gradient.setValue( + CIVector(x: extent.midX, y: extent.maxY - fadeHeight), + forKey: "inputPoint1" + ) + gradient.setValue(CIColor.white, forKey: "inputColor0") + gradient.setValue(CIColor.black, forKey: "inputColor1") + guard let mask = gradient.outputImage?.cropped(to: extent) else { return nil } + + blur.setValue(source.clampedToExtent(), forKey: kCIInputImageKey) + blur.setValue(mask, forKey: "inputMask") + blur.setValue(min(max(extent.height * 0.04, 10), 24), forKey: kCIInputRadiusKey) + guard let output = blur.outputImage?.cropped(to: extent) else { return nil } + return overviewPreviewCIContext.createCGImage(output, from: extent) +} + +func overviewPreviewOpacity( + startedAt: TimeInterval?, + now: TimeInterval, + reduceMotion: Bool, + duration: TimeInterval = 0.32 +) -> Double { + guard !reduceMotion, let startedAt, duration > 0 else { return 1 } + let progress = min(max((now - startedAt) / duration, 0), 1) + return progress * progress * (3 - 2 * progress) +} + +public enum OverviewPreviewPermissionState: String, Sendable { + case disabled + case notDetermined = "not-determined" + case granted + case denied +} + +struct OverviewPreviewRequest: Equatable, Sendable { + let windowID: WindowID + let expectedAppID: String + let width: Int + let height: Int +} + +struct OverviewPreviewCaptureResult: Sendable { + let request: OverviewPreviewRequest + let image: CGImage? +} + +struct OverviewDesktopCaptureRequest: Sendable { + let monitorID: MonitorID + let displayID: CGDirectDisplayID + let width: Int + let height: Int +} + +struct OverviewCaptureResults: Sendable { + let previews: [OverviewPreviewCaptureResult] + let desktops: [MonitorID: CGImage] +} + +func overviewPreviewCacheCanStore( + windowID: WindowID, + byteCost: Int, + currentByteCosts: [WindowID: Int], + maximumBytes: Int +) -> Bool { + guard byteCost > 0, byteCost <= maximumBytes else { return false } + let bytesWithoutExisting = currentByteCosts.values.reduce(0, +) + - (currentByteCosts[windowID] ?? 0) + return bytesWithoutExisting <= maximumBytes - byteCost +} + +func runOverviewPreviewCaptures( + _ requests: [OverviewPreviewRequest], + maximumConcurrent: Int = 2, + capture: @escaping @Sendable (OverviewPreviewRequest) async + -> OverviewPreviewCaptureResult +) async -> [OverviewPreviewCaptureResult] { + guard !requests.isEmpty else { return [] } + let limit = max(min(maximumConcurrent, requests.count), 1) + return await withTaskGroup(of: (Int, OverviewPreviewCaptureResult).self) { group in + var nextIndex = 0 + for _ in 0.. OverviewCaptureResults { + do { + let content = try await SCShareableContent.excludingDesktopWindows( + true, + onScreenWindowsOnly: false + ) + var desktops: [MonitorID: CGImage] = [:] + for request in desktopRequests where !Task.isCancelled { + guard let display = content.displays.first(where: { + $0.displayID == request.displayID + }) else { continue } + let filter = SCContentFilter( + display: display, + excludingWindows: content.windows + ) + filter.includeMenuBar = false + let configuration = SCStreamConfiguration() + configuration.width = request.width + configuration.height = request.height + configuration.showsCursor = false + configuration.capturesAudio = false + if let image = try? await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: configuration + ) { + desktops[request.monitorID] = image + } + } + let windows = Dictionary( + uniqueKeysWithValues: content.windows.map { ($0.windowID, $0) } + ) + let batch = OverviewScreenCaptureBatch(windows: windows) + let previews = await runOverviewPreviewCaptures(requests) { request in + await batch.capture(request) + } + return OverviewCaptureResults(previews: previews, desktops: desktops) + } catch { + return OverviewCaptureResults( + previews: requests.map { + OverviewPreviewCaptureResult(request: $0, image: nil) + }, + desktops: [:] + ) + } +} + +@MainActor +private final class OverviewScreenCaptureBatch { + private let windows: [CGWindowID: SCWindow] + + init(windows: [CGWindowID: SCWindow]) { + self.windows = windows + } + + func capture( + _ request: OverviewPreviewRequest + ) async -> OverviewPreviewCaptureResult { + guard !Task.isCancelled, + let windowID = CGWindowID(exactly: request.windowID.rawValue), + let window = windows[windowID] + else { + return OverviewPreviewCaptureResult(request: request, image: nil) + } + let configuration = SCStreamConfiguration() + configuration.width = request.width + configuration.height = request.height + configuration.showsCursor = false + configuration.capturesAudio = false + do { + let image = try await SCScreenshotManager.captureImage( + contentFilter: SCContentFilter(desktopIndependentWindow: window), + configuration: configuration + ) + let styledImage = await Task.detached(priority: .userInitiated) { + progressivelyBlurredOverviewPreview(image) ?? image + }.value + return OverviewPreviewCaptureResult(request: request, image: styledImage) + } catch { + return OverviewPreviewCaptureResult(request: request, image: nil) + } + } +} + +func overviewPreviewResultIsCurrent( + _ result: OverviewPreviewCaptureResult, + generation: UInt64, + currentGeneration: UInt64, + currentRequest: OverviewPreviewRequest?, + currentAppID: String? +) -> Bool { + overviewPreviewRequestIsCurrent( + result.request, + generation: generation, + currentGeneration: currentGeneration, + currentRequest: currentRequest, + currentAppID: currentAppID + ) + && (result.image.map { $0.width > 1 && $0.height > 1 } ?? false) +} + +func overviewPreviewRequestIsCurrent( + _ request: OverviewPreviewRequest, + generation: UInt64, + currentGeneration: UInt64, + currentRequest: OverviewPreviewRequest?, + currentAppID: String? +) -> Bool { + generation == currentGeneration + && currentRequest == request + && currentAppID == request.expectedAppID +} diff --git a/Sources/DefiMacOS/Platform.swift b/Sources/DefiMacOS/Platform.swift index e79d6db..810e2b8 100644 --- a/Sources/DefiMacOS/Platform.swift +++ b/Sources/DefiMacOS/Platform.swift @@ -547,6 +547,33 @@ func bestCGWindow( return closest } +func cgWindowDiscoveryNeedsExactID( + processID: pid_t, + title: String, + frame: Rect, + records: [CGWindowRecord], + excluding usedWindowIDs: Set, + maximumDistance: Double = 80 +) -> Bool { + guard let closest = bestCGWindow( + processID: processID, + title: title, + frame: frame, + records: records, + excluding: usedWindowIDs, + maximumDistance: maximumDistance + ) else { return false } + let closestDistance = frameDistance(closest.frame, frame) + let closestTitleRank = windowTitleMatchRank(closest.title, title) + return records.contains { candidate in + candidate.id != closest.id + && candidate.processID == processID + && !usedWindowIDs.contains(candidate.id) + && abs(frameDistance(candidate.frame, frame) - closestDistance) <= 0.5 + && windowTitleMatchRank(candidate.title, title) == closestTitleRank + } +} + func closestFocusRecoveryWindowIndex( target: (frame: Rect, title: String), candidates: [(frame: Rect, title: String)], diff --git a/Sources/DefiMacOS/SnapshotEngine.swift b/Sources/DefiMacOS/SnapshotEngine.swift index 2d258b3..138fdba 100644 --- a/Sources/DefiMacOS/SnapshotEngine.swift +++ b/Sources/DefiMacOS/SnapshotEngine.swift @@ -663,10 +663,18 @@ extension SnapshotEngine { records: eligibleCGWindows, excluding: usedCGWindowIDs ) - let record: CGWindowRecord? - if let publicRecord { - record = publicRecord - } else { + var record = publicRecord + let hasEligiblePublicCandidate = eligibleCGWindows.contains { + $0.processID == processID && !usedCGWindowIDs.contains($0.id) + } + let supportsExactWindowID = role == kAXWindowRole || role == kAXSheetRole + if supportsExactWindowID && ((publicRecord == nil && hasEligiblePublicCandidate) || cgWindowDiscoveryNeedsExactID( + processID: processID, + title: title, + frame: frame, + records: eligibleCGWindows, + excluding: usedCGWindowIDs + )) { let axWindowID = { let assumedElement = AssumedThreadSafe(element) return onMain { $0.windowIDProvider.windowID(for: assumedElement.value) } @@ -676,15 +684,17 @@ extension SnapshotEngine { } else { privateWindowIDLookupCount += 1 } - record = cgWindowRecordForDiscovery( - axWindowID: axWindowID, - preferredWindowID: preferredWindowID, - processID: processID, - title: title, - frame: frame, - records: eligibleCGWindows, - excluding: usedCGWindowIDs - ) + if let axWindowID { + record = cgWindowRecordForDiscovery( + axWindowID: axWindowID, + preferredWindowID: nil, + processID: processID, + title: title, + frame: frame, + records: eligibleCGWindows, + excluding: usedCGWindowIDs + ) ?? record + } } guard let resolvedWindowID = record?.id else { return .unmatched diff --git a/Sources/DefiMacOS/WindowBorderManager.swift b/Sources/DefiMacOS/WindowBorderManager.swift index f8fc166..4f1e4f4 100644 --- a/Sources/DefiMacOS/WindowBorderManager.swift +++ b/Sources/DefiMacOS/WindowBorderManager.swift @@ -12,6 +12,7 @@ final class WindowBorderManager { private var lastPlan: WindowBorderRenderPlan? private var lastDisplayedFrames: [WindowID: Rect] = [:] private var compositorCommitPending = false + private(set) var isSuppressed = false private var appliedPlans = 0 private var skippedPlans = 0 private var geometryUpdateCount = 0 @@ -56,6 +57,7 @@ final class WindowBorderManager { } func revealPendingBorders() { + guard !isSuppressed else { return } compositorCommitPending = true commitVisibleOverlays() revealPendingOpacities(in: overlays.values.filter(\.isVisible)) @@ -79,6 +81,7 @@ final class WindowBorderManager { displayedFrame: Rect?, stacking: WindowBorderStacking? = nil ) { + guard !isSuppressed else { return } let activeStacking = stacking ?? .inactive(for: windowID) if activeWindowID == windowID { if let windowID { @@ -135,6 +138,7 @@ final class WindowBorderManager { frames: [WindowID: Rect], style: WindowBorderStyle ) -> Bool { + guard !isSuppressed else { return false } var geometryChanged = false for (windowID, frame) in frames { guard let overlay = overlays[windowID], overlay.isVisible else { continue } @@ -161,6 +165,7 @@ final class WindowBorderManager { displayedFrames: [WindowID: Rect], stacking: WindowBorderStacking ) { + guard !isSuppressed else { return } let trackedWindowIDs = Set(plan.tracked.map(\.windowID)) if let activeWindowID = plan.active?.windowID { overlays[activeWindowID]?.setStacking( @@ -246,6 +251,14 @@ final class WindowBorderManager { compositorCommitPending = false } + func setSuppressed(_ suppressed: Bool) { + guard isSuppressed != suppressed else { return } + isSuppressed = suppressed + if suppressed { + hide() + } + } + private func commitVisibleOverlays() { let visibleOverlays = overlays.values.filter(\.isVisible) guard !visibleOverlays.isEmpty else { diff --git a/Sources/DefiMacOS/WindowBorderPlanning.swift b/Sources/DefiMacOS/WindowBorderPlanning.swift index 28d526c..78f1bdc 100644 --- a/Sources/DefiMacOS/WindowBorderPlanning.swift +++ b/Sources/DefiMacOS/WindowBorderPlanning.swift @@ -1,6 +1,7 @@ import AppKit import CoreFoundation import Darwin +import DefiConfig import DefiCore import DefiModel @@ -35,6 +36,18 @@ struct WindowBorderStyle: Equatable, Sendable { self.captureEnabled = captureEnabled self.placement = placement } + + init(config: BordersConfig) { + self.init( + enabled: config.enabled, + width: config.width, + activeColor: parseBorderColor(config.color) ?? 0xffc0_99ff, + inactiveEnabled: config.inactiveEnabled, + inactiveColor: parseBorderColor(config.inactiveColor) ?? 0x66c0_99ff, + captureEnabled: config.captureEnabled, + placement: WindowBorderPlacement(configValue: config.placement) + ) + } } struct WindowBorderRenderPlan: Equatable, Sendable { @@ -128,6 +141,38 @@ func planWindowBorders( ) } +func overviewWindowBorderAppearance( + isSelected: Bool, + style: WindowBorderStyle +) -> (color: UInt32, width: Double)? { + guard style.enabled, style.width > 0 else { return nil } + if isSelected, windowBorderAlpha(of: style.activeColor) > 0 { + return (style.activeColor, style.width) + } + guard style.inactiveEnabled, windowBorderAlpha(of: style.inactiveColor) > 0 else { + return nil + } + return (style.inactiveColor, style.width) +} + +func overviewWindowBorderGeometry( + cardFrame: Rect, + cardRadius: Double, + width: Double, + placement: WindowBorderPlacement +) -> (frame: Rect, radius: Double) { + let inset = placement == .inside ? width / 2 : -width / 2 + return ( + Rect( + x: cardFrame.x + inset, + y: cardFrame.y + inset, + width: cardFrame.width - inset * 2, + height: cardFrame.height - inset * 2 + ), + max(cardRadius - inset, 0) + ) +} + func borderCornerRadius(windowRadius: Double) -> Double { max(windowRadius, 0) } diff --git a/Sources/DefiMacOS/WindowDiscoverySupport.swift b/Sources/DefiMacOS/WindowDiscoverySupport.swift index b07735a..b7e159d 100644 --- a/Sources/DefiMacOS/WindowDiscoverySupport.swift +++ b/Sources/DefiMacOS/WindowDiscoverySupport.swift @@ -79,6 +79,9 @@ final class AXWindowIDProvider { private var disabled = false private var probeCompleted = false private var probeSucceeded = false + private var probeAttempts = 0 + private var probePanel: NSPanel? + private var probeScheduled = false init() { let handle = dlopen( @@ -122,39 +125,51 @@ final class AXWindowIDProvider { private func ensureProbe() { guard !probeCompleted else { return } - probeCompleted = true - guard let getWindow, NSApp != nil else { return } - - let title = "Defi AX probe \(UUID().uuidString)" - let panel = NSPanel( - contentRect: NSRect(x: -10_000, y: -10_000, width: 1, height: 1), - styleMask: [.nonactivatingPanel], - backing: .buffered, - defer: false - ) - panel.title = title - panel.titleVisibility = .visible - panel.isOpaque = false - panel.backgroundColor = .clear - panel.alphaValue = 0 - panel.ignoresMouseEvents = true - panel.hasShadow = false - panel.sharingType = .none - panel.isExcludedFromWindowsMenu = true - panel.orderFrontRegardless() - defer { - panel.orderOut(nil) - panel.close() + guard getWindow != nil else { + probeCompleted = true + return + } + guard NSApp != nil else { return } + if probePanel == nil { + let panel = NSPanel( + contentRect: NSRect(x: -10_000, y: -10_000, width: 1, height: 1), + styleMask: [.nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.title = "Defi AX probe \(UUID().uuidString)" + panel.titleVisibility = .visible + panel.isOpaque = false + panel.backgroundColor = .clear + panel.alphaValue = 0 + panel.ignoresMouseEvents = true + panel.hasShadow = false + panel.sharingType = .none + panel.isExcludedFromWindowsMenu = true + panel.orderFrontRegardless() + probePanel = panel } + guard !probeScheduled else { return } + probeScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.runProbe() + } + } + + private func runProbe() { + probeScheduled = false + guard !probeCompleted, let getWindow, let panel = probePanel else { return } + probeAttempts += 1 var value: CFTypeRef? guard AXUIElementCopyAttributeValue( AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier), kAXWindowsAttribute as CFString, &value - ) == .success, + ) == .success, let windows = value as? [AXUIElement] else { + finishInconclusiveProbe(panel) return } for window in windows { @@ -164,16 +179,32 @@ final class AXWindowIDProvider { kAXTitleAttribute as CFString, &windowTitle ) == .success, - windowTitle as? String == title + windowTitle as? String == panel.title else { continue } + probeCompleted = true var windowID: CGWindowID = 0 if getWindow(window, &windowID) == .success, windowID != 0 { probeSucceeded = true + } else { + disabled = true } + panel.close() + probePanel = nil return } + finishInconclusiveProbe(panel) + } + + private func finishInconclusiveProbe(_ panel: NSPanel) { + if probeAttempts == 3 { + probeCompleted = true + panel.close() + probePanel = nil + } else { + ensureProbe() + } } } diff --git a/Sources/DefiModel/Command.swift b/Sources/DefiModel/Command.swift index 5d4a858..a6d0a4d 100644 --- a/Sources/DefiModel/Command.swift +++ b/Sources/DefiModel/Command.swift @@ -28,6 +28,7 @@ public enum Command: Equatable, Codable, Sendable { case activateFloating case joinWindow(Direction) case unjoinWindows + case toggleOverview case runStartupCommands public var resizesManagedLayout: Bool { diff --git a/Sources/DefiModel/CommandParser.swift b/Sources/DefiModel/CommandParser.swift index 36636af..88dc3fd 100644 --- a/Sources/DefiModel/CommandParser.swift +++ b/Sources/DefiModel/CommandParser.swift @@ -65,6 +65,8 @@ public func parseCommand(_ input: String) throws -> Command { return .joinWindow(try parseDirection(argument("direction"))) case "unjoin-windows": return .unjoinWindows + case "toggle-overview": + return .toggleOverview case "run-startup-commands": return .runStartupCommands default: diff --git a/Sources/DefiRuntime/CommandReducer.swift b/Sources/DefiRuntime/CommandReducer.swift index a06c086..47fc5d1 100644 --- a/Sources/DefiRuntime/CommandReducer.swift +++ b/Sources/DefiRuntime/CommandReducer.swift @@ -230,6 +230,8 @@ public func reduce( if !state.monitors[monitorIndex].workspaces[index].floatingWindows.isEmpty { state.monitors[monitorIndex].workspaces[index].focusedLayer = .floating } + case .toggleOverview: + break case .runStartupCommands: break } @@ -269,7 +271,7 @@ private func commandMutatesNativeFullscreenSelection(_ command: Command) -> Bool .maximizeColumn, .toggleFloating, .joinWindow, .unjoinWindows: true case .focusColumn, .focusFloating, .focusWindow, .switchWorkspace, - .activateFloating, .runStartupCommands: + .activateFloating, .toggleOverview, .runStartupCommands: false } } @@ -503,7 +505,7 @@ private func moveFocusedSelectionToMonitor( } } -private func transientRootWindowID( +func transientRootWindowID( _ windowID: WindowID, windows: [WindowID: Window] ) -> WindowID { @@ -518,7 +520,7 @@ private func transientRootWindowID( return root } -private func transientDescendants( +func transientDescendants( of ownerWindowIDs: Set, windows: [WindowID: Window] ) -> Set { @@ -538,7 +540,7 @@ private func transientDescendants( } } -private func groupedTiledColumns( +func groupedTiledColumns( moving movedWindowIDs: Set, excluding excludedWindowIDs: Set = [], from workspace: Workspace @@ -565,7 +567,7 @@ private func groupedTiledColumns( return (columns, tiledWindowIDs) } -private func removeWindowFromEveryWorkspace( +func removeWindowFromEveryWorkspace( _ windowID: WindowID, state: inout RuntimeState ) { diff --git a/Sources/DefiRuntime/OverviewRuntime.swift b/Sources/DefiRuntime/OverviewRuntime.swift new file mode 100644 index 0000000..e01814b --- /dev/null +++ b/Sources/DefiRuntime/OverviewRuntime.swift @@ -0,0 +1,345 @@ +import DefiCore +import DefiModel + +public struct OverviewWindowIntent: Equatable, Sendable { + public let windowID: WindowID + public let expectedAppID: String + public let sourceMonitorID: MonitorID + public let sourceWorkspaceID: WorkspaceID + + public init( + windowID: WindowID, + expectedAppID: String, + sourceMonitorID: MonitorID, + sourceWorkspaceID: WorkspaceID + ) { + self.windowID = windowID + self.expectedAppID = expectedAppID + self.sourceMonitorID = sourceMonitorID + self.sourceWorkspaceID = sourceWorkspaceID + } +} + +public struct OverviewDropResult: Equatable, Sendable { + public let monitorID: MonitorID + public let workspaceID: WorkspaceID + public let focusedWindowID: WindowID + public let floatingFrameUpdates: [WindowID: Rect] + + public init( + monitorID: MonitorID, + workspaceID: WorkspaceID, + focusedWindowID: WindowID, + floatingFrameUpdates: [WindowID: Rect] = [:] + ) { + self.monitorID = monitorID + self.workspaceID = workspaceID + self.focusedWindowID = focusedWindowID + self.floatingFrameUpdates = floatingFrameUpdates + } +} + +public enum OverviewRuntimeError: Error, Equatable, Sendable { + case staleWindow(WindowID) + case staleSource(WindowID) + case transientWindow(WindowID) + case nativeFullscreen(WindowID) + case unknownMonitor(MonitorID) + case unknownWorkspace(WorkspaceID) + case invalidDropTarget + case classificationChanged(WindowID) +} + +@discardableResult +public func focusOverviewWindow( + _ intent: OverviewWindowIntent, + state: inout RuntimeState +) throws -> MonitorID { + let location = try validateOverviewWindow(intent, state: state) + _ = focusWindow(intent.windowID, state: &state) + return location.monitorID +} + +@discardableResult +public func focusOverviewWorkspace( + monitorID: MonitorID, + workspaceID: WorkspaceID, + state: inout RuntimeState +) throws -> WindowID? { + guard let monitorIndex = state.monitors.firstIndex(where: { $0.id == monitorID }) + else { throw OverviewRuntimeError.unknownMonitor(monitorID) } + guard state.monitors[monitorIndex].workspaces.contains(where: { + $0.id == workspaceID + }) else { throw OverviewRuntimeError.unknownWorkspace(workspaceID) } + state.monitors[monitorIndex].activeWorkspace = workspaceID + return state.selectedWindowID(on: monitorID) +} + +public func applyOverviewDrop( + _ intent: OverviewWindowIntent, + target: OverviewDropTarget, + viewports: [MonitorID: Rect], + floatingFrames: [WindowID: Rect] = [:], + state: inout RuntimeState +) throws -> OverviewDropResult { + let sourceLocation = try validateOverviewWindow(intent, state: state) + guard state.windows[intent.windowID]?.transientOwnerID == nil else { + throw OverviewRuntimeError.transientWindow(intent.windowID) + } + let rootWindowID = transientRootWindowID(intent.windowID, windows: state.windows) + guard rootWindowID == intent.windowID else { + throw OverviewRuntimeError.transientWindow(intent.windowID) + } + let movedWindowIDs = transientDescendants( + of: [rootWindowID], + windows: state.windows + ).union([rootWindowID]) + if let fullscreenID = movedWindowIDs.first(where: { + state.nativeFullscreenWindowIDs.contains($0) + }) { + throw OverviewRuntimeError.nativeFullscreen(fullscreenID) + } + + let destination = try validatedDestination(target, state: state) + let sourceFloatingWindowIDs = Set( + state.monitors.flatMap(\.workspaces).flatMap(\.floatingWindows) + ) + let sourceIsFloating = sourceFloatingWindowIDs.contains(intent.windowID) + if sourceIsFloating { + guard case .floating = target else { + throw OverviewRuntimeError.classificationChanged(intent.windowID) + } + } else if case .floating = target { + throw OverviewRuntimeError.classificationChanged(intent.windowID) + } + + var next = state + guard let sourceMonitorIndex = next.monitors.firstIndex(where: { + $0.id == sourceLocation.monitorID + }), + let sourceWorkspaceIndex = next.monitors[sourceMonitorIndex].workspaces.firstIndex( + where: { $0.id == sourceLocation.workspaceID } + ) + else { throw OverviewRuntimeError.staleSource(intent.windowID) } + let sourceWorkspace = next.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex] + let sourceColumnIndex = sourceWorkspace.columns.firstIndex { + $0.windows.contains(intent.windowID) + } + let sourceWindowIndex = sourceColumnIndex.flatMap { + sourceWorkspace.columns[$0].windows.firstIndex(of: intent.windowID) + } + let sourceColumn = sourceColumnIndex.map { sourceWorkspace.columns[$0] } + let orderedMovedWindowIDs = next.monitors.flatMap(\.workspaces).flatMap { + $0.columns.flatMap(\.windows) + $0.floatingWindows + }.filter(movedWindowIDs.contains) + let movedTiledColumns = groupedTiledColumns( + moving: movedWindowIDs, + excluding: [intent.windowID], + from: sourceWorkspace + ) + + for windowID in orderedMovedWindowIDs { + removeWindowFromEveryWorkspace(windowID, state: &next) + } + + guard let targetMonitorIndex = next.monitors.firstIndex(where: { + $0.id == destination.monitorID + }), + let targetWorkspaceIndex = next.monitors[targetMonitorIndex].workspaces.firstIndex( + where: { $0.id == destination.workspaceID } + ) + else { throw OverviewRuntimeError.invalidDropTarget } + + let sameWorkspace = sourceLocation.monitorID == destination.monitorID + && sourceLocation.workspaceID == destination.workspaceID + let sourceColumnWasRemoved = sourceColumn?.windows.count == 1 + let sourceViewport = viewports[sourceLocation.monitorID] + let targetViewport = viewports[destination.monitorID] + let widthScale = sourceViewport.flatMap { source in + targetViewport.map { $0.width / max(source.width, 1) } + } ?? 1 + var floatingFrameUpdates: [WindowID: Rect] = [:] + + switch target { + case .newColumn(_, _, let requestedIndex): + var insertionIndex = requestedIndex + if sameWorkspace, sourceColumnWasRemoved, + let sourceColumnIndex, sourceColumnIndex < insertionIndex + { + insertionIndex -= 1 + } + guard (0...next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns.count).contains(insertionIndex) + else { throw OverviewRuntimeError.invalidDropTarget } + var column = Column( + window: intent.windowID, + width: sourceColumn?.width ?? .fraction(next.layout.defaultColumnWidth), + preMaximizedWidth: sourceColumn?.preMaximizedWidth + ) + scalePixelWidths(in: &column, by: widthScale) + next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns.insert(column, at: insertionIndex) + case .stack(_, _, let requestedColumnIndex, let requestedWindowIndex): + if sameWorkspace, sourceColumnWasRemoved, + sourceColumnIndex == requestedColumnIndex, let sourceColumn + { + guard (0...next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns.count).contains(requestedColumnIndex) + else { throw OverviewRuntimeError.invalidDropTarget } + next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns.insert(sourceColumn, at: requestedColumnIndex) + break + } + var columnIndex = requestedColumnIndex + if sameWorkspace, sourceColumnWasRemoved, + let sourceColumnIndex, sourceColumnIndex < columnIndex + { + columnIndex -= 1 + } + guard next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns.indices.contains(columnIndex) + else { throw OverviewRuntimeError.invalidDropTarget } + var windowIndex = requestedWindowIndex + if sameWorkspace, sourceColumnWasRemoved == false, + sourceColumnIndex == requestedColumnIndex, + let sourceWindowIndex, sourceWindowIndex < windowIndex + { + windowIndex -= 1 + } + guard (0...next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns[columnIndex].windows.count).contains(windowIndex) + else { throw OverviewRuntimeError.invalidDropTarget } + next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns[columnIndex].windows.insert(intent.windowID, at: windowIndex) + case .floating(_, _, let relativeFrame): + guard let viewport = targetViewport, + relativeFrame.x.isFinite, + relativeFrame.y.isFinite, + relativeFrame.width.isFinite, + relativeFrame.height.isFinite, + relativeFrame.width > 0, + relativeFrame.height > 0 + else { throw OverviewRuntimeError.invalidDropTarget } + let width = min(relativeFrame.width * viewport.width, viewport.width) + let height = min(relativeFrame.height * viewport.height, viewport.height) + let frame = Rect( + x: min( + max(viewport.x + relativeFrame.x * viewport.width, viewport.x), + viewport.x + viewport.width - width + ), + y: min( + max(viewport.y + relativeFrame.y * viewport.height, viewport.y), + viewport.y + viewport.height - height + ), + width: width, + height: height + ) + next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .floatingWindows.append(intent.windowID) + floatingFrameUpdates[intent.windowID] = frame + next.windows[intent.windowID]?.frame = frame + } + + var auxiliaryColumnInsertionIndex = next.monitors[targetMonitorIndex] + .workspaces[targetWorkspaceIndex].columns.count + for windowID in orderedMovedWindowIDs where windowID != intent.windowID { + if sourceFloatingWindowIDs.contains(windowID) { + next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .floatingWindows.append(windowID) + if let previousFrame = floatingFrames[windowID] ?? state.windows[windowID]?.frame, + let sourceViewport, + let targetViewport + { + let frame = rebasedFloatingFrame( + previousFrame, + from: sourceViewport, + to: targetViewport + ) + next.windows[windowID]?.frame = frame + floatingFrameUpdates[windowID] = frame + } + } else if var column = movedTiledColumns.byFirstWindowID[windowID] { + scalePixelWidths(in: &column, by: widthScale) + next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns.insert(column, at: auxiliaryColumnInsertionIndex) + auxiliaryColumnInsertionIndex += 1 + } else if !movedTiledColumns.windowIDs.contains(windowID) { + next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] + .columns.insert( + Column(window: windowID, width: .fraction(next.layout.defaultColumnWidth)), + at: auxiliaryColumnInsertionIndex + ) + auxiliaryColumnInsertionIndex += 1 + } + } + + for windowID in orderedMovedWindowIDs { + next.windows[windowID]?.monitorID = destination.monitorID + if let placement = next.suspendedTiledPlacements[windowID] { + if next.windows[windowID]?.floatingOrigin == .automatic { + var column = placement.column + scalePixelWidths(in: &column, by: widthScale) + next.suspendedTiledPlacements[windowID] = SuspendedTiledPlacement( + monitorID: destination.monitorID, + workspaceID: destination.workspaceID, + columnIndex: placement.columnIndex, + windowIndex: placement.windowIndex, + column: column + ) + } else { + next.suspendedTiledPlacements[windowID] = nil + } + } + } + repairWorkspaceScroll( + &next.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex], + settings: next.layout + ) + repairWorkspaceScroll( + &next.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex], + settings: next.layout + ) + _ = focusWindow(intent.windowID, state: &next) + next.monitors[targetMonitorIndex].activeWorkspace = destination.workspaceID + state = next + return OverviewDropResult( + monitorID: destination.monitorID, + workspaceID: destination.workspaceID, + focusedWindowID: intent.windowID, + floatingFrameUpdates: floatingFrameUpdates + ) +} + +private func validateOverviewWindow( + _ intent: OverviewWindowIntent, + state: RuntimeState +) throws -> (monitorID: MonitorID, workspaceID: WorkspaceID) { + guard let window = state.windows[intent.windowID], + window.appID == intent.expectedAppID + else { throw OverviewRuntimeError.staleWindow(intent.windowID) } + guard let location = state.location(containing: intent.windowID), + location.monitorID == intent.sourceMonitorID, + location.workspaceID == intent.sourceWorkspaceID + else { throw OverviewRuntimeError.staleSource(intent.windowID) } + return location +} + +private func validatedDestination( + _ target: OverviewDropTarget, + state: RuntimeState +) throws -> (monitorID: MonitorID, workspaceID: WorkspaceID) { + let monitorID: MonitorID + let workspaceID: WorkspaceID + switch target { + case .newColumn(let monitor, let workspace, _), + .stack(let monitor, let workspace, _, _), + .floating(let monitor, let workspace, _): + monitorID = monitor + workspaceID = workspace + } + guard let monitor = state.monitors.first(where: { $0.id == monitorID }) + else { throw OverviewRuntimeError.unknownMonitor(monitorID) } + guard monitor.workspaces.contains(where: { $0.id == workspaceID }) + else { throw OverviewRuntimeError.unknownWorkspace(workspaceID) } + return (monitorID, workspaceID) +} diff --git a/Support/Defi-Info.plist b/Support/Defi-Info.plist index 593c864..ff2dee4 100644 --- a/Support/Defi-Info.plist +++ b/Support/Defi-Info.plist @@ -22,5 +22,7 @@ NSPrincipalClass NSApplication + NSScreenCaptureUsageDescription + Defi can show optional window previews in Overview. diff --git a/Tests/DefiConfigTests/ConfigTests.swift b/Tests/DefiConfigTests/ConfigTests.swift index ace8f61..36dafbd 100644 --- a/Tests/DefiConfigTests/ConfigTests.swift +++ b/Tests/DefiConfigTests/ConfigTests.swift @@ -12,6 +12,8 @@ struct ConfigTests { #expect(config.layout.defaultColumnWidth == 0.8) #expect(config.animation.enabled) #expect(config.animation.durationMS == 35) + #expect(config.overview.zoom == 0.5) + #expect(config.overview.windowPreviews == false) #expect(config.decorations.borders.enabled) #expect(config.decorations.borders.width == 4) #expect(config.decorations.borders.color == "#FFC099FF") @@ -27,6 +29,37 @@ struct ConfigTests { #expect(config.keys["alt-shift-j"] == "move-column-to-monitor down") #expect(config.keys["alt-shift-k"] == "move-column-to-monitor up") #expect(config.keys["alt-shift-l"] == "move-column-to-monitor right") + #expect(config.keys["alt-o"] == "toggle-overview") + } + + @Test + func `Decodes overview configuration`() throws { + let config = try Config.decode( + Data( + """ + [overview] + zoom = 0.25 + window_previews = true + """.utf8 + ) + ) + + #expect(config.overview.zoom == 0.25) + #expect(config.overview.windowPreviews) + } + + @Test + func `Rejects invalid overview zoom`() { + let data = Data( + """ + [overview] + zoom = 0.76 + """.utf8 + ) + + #expect(throws: ConfigError.invalidValue("overview.zoom")) { + try Config.decode(data) + } } @Test diff --git a/Tests/DefiCoreTests/OverviewTests.swift b/Tests/DefiCoreTests/OverviewTests.swift new file mode 100644 index 0000000..0850c09 --- /dev/null +++ b/Tests/DefiCoreTests/OverviewTests.swift @@ -0,0 +1,407 @@ +import DefiCore +import DefiModel +import Testing + +struct OverviewTests { + let monitorID = MonitorID(rawValue: 1) + let monitorFrame = Rect(x: 0, y: 0, width: 1_000, height: 800) + + @Test + func `Projection culls workspaces without mutating real scroll`() { + let workspaceIDs = (1...9).map { WorkspaceID(rawValue: String($0)) } + var active = Workspace( + id: workspaceIDs[4], + columns: [Column(window: WindowID(rawValue: 1), width: .fraction(0.8))], + scrollOffset: 0.25, + targetScrollOffset: 0.25 + ) + active.focusedColumn = 0 + let monitor = Monitor( + id: monitorID, + workspaces: workspaceIDs.map { $0 == active.id ? active : Workspace(id: $0) }, + activeWorkspace: active.id + ) + let snapshot = OverviewSnapshot( + monitors: [monitor], + monitorFrames: [monitorID: monitorFrame], + windows: [ + WindowID(rawValue: 1): Window( + id: WindowID(rawValue: 1), + appID: "app", + title: "one", + frame: monitorFrame + ) + ] + ) + + let projection = projectOverview( + snapshot: snapshot, + monitorID: monitorID, + bounds: monitorFrame, + viewport: OverviewViewport(horizontalOffsets: [active.id: 1]), + layout: LayoutSettings() + ) + + #expect(projection.workspaces.count < monitor.workspaces.count) + #expect(projection.workspaces.contains(where: { $0.workspaceID == active.id })) + #expect(snapshot.monitors[0].workspaces[4].scrollOffset == 0.25) + } + + @Test + func `Workspace projects as a centered half-height ribbon`() { + let windowID = WindowID(rawValue: 1) + let floatingID = WindowID(rawValue: 2) + let workspace = Workspace( + id: WorkspaceID(rawValue: "dev"), + columns: [Column(window: windowID, width: .fraction(1))], + floatingWindows: [floatingID] + ) + let bounds = Rect(x: 40, y: 20, width: 1_200, height: 600) + let projection = projectOverview( + snapshot: OverviewSnapshot( + monitors: [ + Monitor( + id: monitorID, + workspaces: [workspace], + activeWorkspace: workspace.id + ) + ], + monitorFrames: [monitorID: monitorFrame], + windows: [ + windowID: Window( + id: windowID, + appID: "app", + title: "window", + frame: monitorFrame + ), + floatingID: Window( + id: floatingID, + appID: "floating", + title: "floating", + frame: Rect(x: 200, y: 100, width: 400, height: 200), + floating: true + ), + ], + floatingFrames: [ + floatingID: Rect(x: 200, y: 100, width: 400, height: 200) + ] + ), + monitorID: monitorID, + bounds: bounds, + viewport: OverviewViewport(), + layout: LayoutSettings() + ) + let ribbon = projection.workspaces[0].frame + let tiled = projection.workspaces[0].windows.first { + if case .tiled = $0.layer { return true } + return false + }! + let floating = projection.workspaces[0].windows.first { + $0.layer == .floating + }! + + #expect(ribbon.x == bounds.x) + #expect(ribbon.width == bounds.width) + #expect(ribbon.height == (bounds.height - 56) / 2) + #expect(ribbon.y == bounds.y + (bounds.height - ribbon.height) / 2) + #expect(tiled.frame.x > ribbon.x + 28) + #expect(tiled.frame.x < ribbon.x + ribbon.width / 4) + #expect(floating.frame.width / floating.frame.height == 2) + } + + @Test + func `Adjacent workspaces remain half visible`() { + let workspaces = (0..<3).map { Workspace(id: WorkspaceID(rawValue: "\($0)")) } + let bounds = Rect(x: 0, y: 20, width: 1_200, height: 600) + let projection = projectOverview( + snapshot: OverviewSnapshot( + monitors: [ + Monitor( + id: monitorID, + workspaces: workspaces, + activeWorkspace: workspaces[1].id + ) + ], + monitorFrames: [monitorID: monitorFrame], + windows: [:] + ), + monitorID: monitorID, + bounds: bounds, + viewport: OverviewViewport(), + layout: LayoutSettings() + ) + + let previous = projection.workspaces[0].frame + let active = projection.workspaces[1].frame + let next = projection.workspaces[2].frame + #expect(active.y + active.height / 2 == bounds.y + bounds.height / 2) + #expect(previous.y + previous.height - bounds.y == active.height / 2) + #expect(bounds.y + bounds.height - next.y == active.height / 2) + } + + @Test + func `Zoom scales workspace ribbons`() { + let workspace = Workspace(id: WorkspaceID(rawValue: "dev")) + let bounds = Rect(x: 0, y: 0, width: 1_200, height: 600) + let projection = projectOverview( + snapshot: OverviewSnapshot( + monitors: [ + Monitor( + id: monitorID, + workspaces: [workspace], + activeWorkspace: workspace.id + ) + ], + monitorFrames: [monitorID: monitorFrame], + windows: [:] + ), + monitorID: monitorID, + bounds: bounds, + viewport: OverviewViewport(), + layout: LayoutSettings(), + zoom: 0.25 + ) + + #expect(projection.workspaces[0].frame.height == (bounds.height - 56) * 0.25) + } + + @Test + func `Hit test prefers floating windows`() { + let tiledID = WindowID(rawValue: 1) + let floatingID = WindowID(rawValue: 2) + let workspace = Workspace( + id: WorkspaceID(rawValue: "1"), + columns: [Column(window: tiledID, width: .fraction(1))], + floatingWindows: [floatingID] + ) + let snapshot = OverviewSnapshot( + monitors: [Monitor(id: monitorID, workspaces: [workspace], activeWorkspace: workspace.id)], + monitorFrames: [monitorID: monitorFrame], + windows: [ + tiledID: Window(id: tiledID, appID: "app", title: "tiled", frame: monitorFrame), + floatingID: Window( + id: floatingID, + appID: "app", + title: "floating", + frame: Rect(x: 200, y: 200, width: 400, height: 300), + floating: true + ), + ] + ) + let projection = projectOverview( + snapshot: snapshot, + monitorID: monitorID, + bounds: monitorFrame, + viewport: OverviewViewport(), + layout: LayoutSettings() + ) + let floating = projection.workspaces[0].windows.last! + let hit = projection.hitTest( + OverviewPoint(x: floating.frame.x + 1, y: floating.frame.y + 1) + ) + + #expect( + hit == .window( + windowID: floatingID, + monitorID: monitorID, + workspaceID: workspace.id + ) + ) + } + + @Test + func `Four scrolling columns remain reachable through overview scroll`() { + let windowIDs = (1...4).map { WindowID(rawValue: UInt64($0)) } + let workspace = Workspace( + id: WorkspaceID(rawValue: "terminals"), + columns: windowIDs.map { Column(window: $0, width: .fraction(0.8)) } + ) + let snapshot = OverviewSnapshot( + monitors: [ + Monitor( + id: monitorID, + workspaces: [workspace], + activeWorkspace: workspace.id + ) + ], + monitorFrames: [monitorID: monitorFrame], + windows: Dictionary(uniqueKeysWithValues: windowIDs.map { + ($0, Window(id: $0, appID: "terminal", title: "terminal", frame: monitorFrame)) + }) + ) + + let visibleWindowIDs = Set([0.0, 0.8, 1.6, 2.4].flatMap { offset in + projectOverview( + snapshot: snapshot, + monitorID: monitorID, + bounds: monitorFrame, + viewport: OverviewViewport(horizontalOffsets: [workspace.id: offset]), + layout: LayoutSettings() + ).workspaces[0].windows.map(\.windowID) + }) + + #expect(visibleWindowIDs == Set(windowIDs)) + } + + @Test + func `Projection reports windows hidden on each side of the strip`() { + let windowIDs = (1...8).map { WindowID(rawValue: UInt64($0)) } + let workspace = Workspace( + id: WorkspaceID(rawValue: "terminals"), + columns: windowIDs.map { Column(window: $0, width: .fraction(0.8)) } + ) + let snapshot = OverviewSnapshot( + monitors: [ + Monitor( + id: monitorID, + workspaces: [workspace], + activeWorkspace: workspace.id + ) + ], + monitorFrames: [monitorID: monitorFrame], + windows: Dictionary(uniqueKeysWithValues: windowIDs.map { + ($0, Window(id: $0, appID: "terminal", title: "terminal", frame: monitorFrame)) + }) + ) + + let start = projectOverview( + snapshot: snapshot, + monitorID: monitorID, + bounds: monitorFrame, + viewport: OverviewViewport(), + layout: LayoutSettings() + ).workspaces[0] + let middle = projectOverview( + snapshot: snapshot, + monitorID: monitorID, + bounds: monitorFrame, + viewport: OverviewViewport(horizontalOffsets: [workspace.id: 1]), + layout: LayoutSettings() + ).workspaces[0] + + #expect(start.hiddenTiledWindowCountBefore == 0) + #expect(start.hiddenTiledWindowCountAfter > 0) + #expect(middle.hiddenTiledWindowCountBefore > 0) + #expect(middle.hiddenTiledWindowCountAfter < start.hiddenTiledWindowCountAfter) + } + + @Test + func `Viewport interpolation moves both axes without losing offsets`() { + let first = WorkspaceID(rawValue: "first") + let second = WorkspaceID(rawValue: "second") + let third = WorkspaceID(rawValue: "third") + + let viewport = interpolateOverviewViewport( + from: OverviewViewport( + workspaceOffset: 0, + horizontalOffsets: [first: 0, second: 2] + ), + to: OverviewViewport( + workspaceOffset: 2, + horizontalOffsets: [first: 4, third: 3] + ), + progress: 0.25 + ) + + #expect(viewport.workspaceOffset == 0.5) + #expect(viewport.horizontalOffsets[first] == 1) + #expect(viewport.horizontalOffsets[second] == 2) + #expect(viewport.horizontalOffsets[third] == 3) + } + + @Test + func `Drop target distinguishes stacks and new columns`() { + let first = WindowID(rawValue: 1) + let second = WindowID(rawValue: 2) + let workspace = Workspace( + id: WorkspaceID(rawValue: "1"), + columns: [ + Column(windows: [first, second], focusedWindow: 0, width: .fraction(0.5)) + ] + ) + var windows = Dictionary(uniqueKeysWithValues: [first, second].map { id in + (id, Window(id: id, appID: "app", title: "window", frame: monitorFrame)) + }) + windows[first] = Window( + id: first, + appID: "app", + title: "window", + frame: monitorFrame, + floating: true, + forceTiling: true + ) + let snapshot = OverviewSnapshot( + monitors: [Monitor(id: monitorID, workspaces: [workspace], activeWorkspace: workspace.id)], + monitorFrames: [monitorID: monitorFrame], + windows: windows + ) + let projection = projectOverview( + snapshot: snapshot, + monitorID: monitorID, + bounds: monitorFrame, + viewport: OverviewViewport(), + layout: LayoutSettings() + ) + let firstCard = projection.workspaces[0].windows[0].frame + let card = projection.workspaces[0].frame + + #expect( + overviewDropTarget( + at: OverviewPoint( + x: firstCard.x + firstCard.width / 2, + y: firstCard.y + firstCard.height - 1 + ), + sourceWindowID: first, + projection: projection, + snapshot: snapshot + ) == .stack( + monitorID: monitorID, + workspaceID: workspace.id, + columnIndex: 0, + windowIndex: 1 + ) + ) + #expect( + overviewDropTarget( + at: OverviewPoint( + x: firstCard.x + firstCard.width * 0.2, + y: firstCard.y + firstCard.height / 2 + ), + sourceWindowID: first, + projection: projection, + snapshot: snapshot + ) == .newColumn( + monitorID: monitorID, + workspaceID: workspace.id, + columnIndex: 0 + ) + ) + #expect( + overviewDropTarget( + at: OverviewPoint( + x: firstCard.x + firstCard.width * 0.8, + y: firstCard.y + firstCard.height / 2 + ), + sourceWindowID: first, + projection: projection, + snapshot: snapshot + ) == .newColumn( + monitorID: monitorID, + workspaceID: workspace.id, + columnIndex: 1 + ) + ) + #expect( + overviewDropTarget( + at: OverviewPoint(x: card.x + card.width - 1, y: card.y + 1), + sourceWindowID: first, + projection: projection, + snapshot: snapshot + ) == .newColumn( + monitorID: monitorID, + workspaceID: workspace.id, + columnIndex: 1 + ) + ) + } +} diff --git a/Tests/DefiMacOSTests/DesktopE2ETests.swift b/Tests/DefiMacOSTests/DesktopE2ETests.swift index 03e3f95..b653840 100644 --- a/Tests/DefiMacOSTests/DesktopE2ETests.swift +++ b/Tests/DefiMacOSTests/DesktopE2ETests.swift @@ -626,6 +626,42 @@ final class DesktopE2ETests: XCTestCase { XCTAssertEqual(manager.tapReenableCount, 0) } + func testConfiguredHyperArrowNavigatesOverview() throws { + _ = try makePlatform() + let config = Config( + modifierCombinations: ["hyper": "Alt + Cmd + Ctrl"], + keys: ["hyper-left": "focus-column left"] + ) + var commands: [HotKeyInvocation] = [] + var overviewActions: [OverviewKeyAction] = [] + let manager = HotKeyManager( + config: config, + overviewHandler: { overviewActions.append($0) } + ) { commands.append($0) } + try manager.start() + manager.setOverviewModeEnabled(true) + + DispatchQueue.global(qos: .userInteractive).async { + Thread.sleep(forTimeInterval: 0.05) + guard + let source = CGEventSource(stateID: .hidSystemState), + let event = CGEvent( + keyboardEventSource: source, + virtualKey: 123, + keyDown: true + ) + else { return } + event.flags = [.maskAlternate, .maskCommand, .maskControl] + event.post(tap: .cghidEventTap) + } + + XCTAssertTrue( + pumpRunLoop(until: { overviewActions == [.left] }, timeout: 1) + ) + XCTAssertEqual(commands, []) + XCTAssertEqual(manager.capturedKeyCount, 1) + } + func testScrollWheelAdvancesUserInputTracker() throws { _ = try makePlatform() let tracker = UserInputTracker() diff --git a/Tests/DefiMacOSTests/HotKeyTests.swift b/Tests/DefiMacOSTests/HotKeyTests.swift index 49b4dad..80cfc90 100644 --- a/Tests/DefiMacOSTests/HotKeyTests.swift +++ b/Tests/DefiMacOSTests/HotKeyTests.swift @@ -1,9 +1,40 @@ import ApplicationServices import DefiConfig import Testing - @testable import DefiMacOS +@Suite +struct OverviewHotKeyTests { + @Test + func `Overview captures unmodified and Hyper arrows`() { + let hyper = hotKeyModifierBits([ + .maskAlternate, + .maskCommand, + .maskControl, + ]) + + #expect(overviewKeyAction(keyCode: 123, modifierBits: 0) == .left) + #expect( + overviewKeyAction( + keyCode: 123, + modifierBits: hyper, + isConfiguredBinding: true + ) == .left + ) + #expect(overviewKeyAction(keyCode: 123, modifierBits: hyper) == nil) + #expect(overviewKeyAction(keyCode: 36, modifierBits: 0) == .select) + #expect( + overviewKeyAction( + keyCode: 36, + modifierBits: hyper, + isConfiguredBinding: true + ) == nil + ) + #expect(overviewKeyAction(keyCode: 53, modifierBits: 0) == .cancel) + #expect(overviewKeyAction(keyCode: 0, modifierBits: 0) == nil) + } +} + struct HotKeyTests { private let aliases = [ "hyper": "Alt + Cmd + Ctrl" diff --git a/Tests/DefiMacOSTests/OverviewPreviewTests.swift b/Tests/DefiMacOSTests/OverviewPreviewTests.swift new file mode 100644 index 0000000..6efbc83 --- /dev/null +++ b/Tests/DefiMacOSTests/OverviewPreviewTests.swift @@ -0,0 +1,142 @@ +import CoreGraphics +import DefiModel +import Testing +@testable import DefiMacOS + +struct OverviewPreviewTests { + @Test + func `Capture scheduler never exceeds its bound and preserves order`() async { + let probe = CaptureProbe() + let requests = (1...7).map { + OverviewPreviewRequest( + windowID: WindowID(rawValue: UInt64($0)), + expectedAppID: "app", + width: 100, + height: 80 + ) + } + + let results = await runOverviewPreviewCaptures(requests) { request in + await probe.begin() + await Task.yield() + await probe.end() + return OverviewPreviewCaptureResult(request: request, image: nil) + } + + let maximum = await probe.maximum + #expect(maximum > 0 && maximum <= 2) + #expect(results.map(\.request) == requests) + } + + @Test + func `Stale or empty preview results are rejected`() { + let request = OverviewPreviewRequest( + windowID: WindowID(rawValue: 1), + expectedAppID: "app", + width: 100, + height: 80 + ) + let result = OverviewPreviewCaptureResult(request: request, image: nil) + + #expect( + !overviewPreviewResultIsCurrent( + result, + generation: 1, + currentGeneration: 2, + currentRequest: request, + currentAppID: "app" + ) + ) + #expect( + !overviewPreviewResultIsCurrent( + result, + generation: 1, + currentGeneration: 1, + currentRequest: request, + currentAppID: "app" + ) + ) + } + + @Test + func `Remembered previews stay inside their memory budget`() { + let first = WindowID(rawValue: 1) + let second = WindowID(rawValue: 2) + var costs = [first: 6] + + #expect( + overviewPreviewCacheCanStore( + windowID: second, + byteCost: 5, + currentByteCosts: costs, + maximumBytes: 10 + ) == false + ) + #expect( + overviewPreviewCacheCanStore( + windowID: first, + byteCost: 8, + currentByteCosts: costs, + maximumBytes: 10 + ) + ) + costs[first] = 8 + #expect(costs.values.reduce(0, +) <= 10) + } + + @Test + func `Preview reveal fades in and respects reduced motion`() { + #expect(overviewPreviewOpacity(startedAt: 10, now: 10, reduceMotion: false) == 0) + #expect(overviewPreviewOpacity(startedAt: 10, now: 10.08, reduceMotion: false) < 0.25) + let midpoint = overviewPreviewOpacity(startedAt: 10, now: 10.16, reduceMotion: false) + #expect(midpoint > 0.45 && midpoint < 0.55) + #expect(overviewPreviewOpacity(startedAt: 10, now: 10.32, reduceMotion: false) == 1) + #expect(overviewPreviewOpacity(startedAt: 10, now: 10, reduceMotion: true) == 1) + } + + @Test + func `Progressive blur stays close to the title band`() { + let fadeHeight = overviewPreviewBlurFadeHeight(imageHeight: 512) + #expect(fadeHeight >= 120 && fadeHeight <= 145) + } + + @Test + func `Progressive blur preserves the captured image dimensions`() throws { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + let context = try #require( + CGContext( + data: nil, + width: 64, + height: 64, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo + ) + ) + context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 32, height: 64)) + context.setFillColor(CGColor(red: 0, green: 0, blue: 1, alpha: 1)) + context.fill(CGRect(x: 32, y: 0, width: 32, height: 64)) + let image = try #require(context.makeImage()) + let blurred = try #require(progressivelyBlurredOverviewPreview(image)) + + #expect(blurred.width == image.width) + #expect(blurred.height == image.height) + } +} + +private actor CaptureProbe { + private var current = 0 + private(set) var maximum = 0 + + func begin() { + current += 1 + maximum = max(maximum, current) + } + + func end() { + current -= 1 + } +} diff --git a/Tests/DefiMacOSTests/OverviewScrollTests.swift b/Tests/DefiMacOSTests/OverviewScrollTests.swift new file mode 100644 index 0000000..09d73d7 --- /dev/null +++ b/Tests/DefiMacOSTests/OverviewScrollTests.swift @@ -0,0 +1,120 @@ +import AppKit +import DefiCore +import DefiModel +import Testing + +@testable import DefiMacOS + +@Suite +struct OverviewScrollTests { + private let workspaceID = WorkspaceID(rawValue: "web") + + @Test + func `Precise deltas remain continuous on either axis`() { + var viewport = OverviewViewport() + let zoom = 0.25 + let stride = overviewWorkspaceStride(boundsHeight: 900, zoom: zoom) + let workspaceHeight = stride - 28 + + for _ in 0..<100 { + viewport = overviewViewportAfterScroll( + viewport, + delta: NSPoint(x: 0, y: -1), + hasPreciseScrollingDeltas: true, + viewSize: NSSize(width: 1_512, height: 900), + zoom: zoom, + activeWorkspaceIndex: 1, + workspaceCount: 9, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: 2 + ) + viewport = overviewViewportAfterScroll( + viewport, + delta: NSPoint(x: -1, y: 0), + hasPreciseScrollingDeltas: true, + viewSize: NSSize(width: 1_512, height: 900), + zoom: zoom, + activeWorkspaceIndex: 1, + workspaceCount: 9, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: 2 + ) + } + + #expect(abs(viewport.workspaceOffset - 100 / stride) < 0.000_001) + #expect( + abs( + viewport.horizontalOffsets[workspaceID, default: 0] + - 100.0 / (1_512 * workspaceHeight / 900) + ) < 0.000_001 + ) + } + + @Test + func `Scroll uses the same horizontal bounds as keyboard paging`() { + let maximumOffset = 2.5 + let end = overviewViewportAfterScroll( + OverviewViewport(), + delta: NSPoint(x: -100_000, y: 0), + hasPreciseScrollingDeltas: true, + viewSize: NSSize(width: 1_512, height: 900), + activeWorkspaceIndex: 1, + workspaceCount: 9, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: maximumOffset + ) + let start = overviewViewportAfterScroll( + end, + delta: NSPoint(x: 100_000, y: 0), + hasPreciseScrollingDeltas: true, + viewSize: NSSize(width: 1_512, height: 900), + activeWorkspaceIndex: 1, + workspaceCount: 9, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: maximumOffset + ) + + #expect(end.horizontalOffsets[workspaceID] == maximumOffset) + #expect(start.horizontalOffsets[workspaceID] == 0) + } + + @Test + func `Vertical trackpad movement does not leak into the horizontal ribbon`() { + let viewport = overviewViewportAfterScroll( + OverviewViewport(), + delta: NSPoint(x: -3, y: -20), + hasPreciseScrollingDeltas: true, + viewSize: NSSize(width: 1_512, height: 900), + activeWorkspaceIndex: 1, + workspaceCount: 9, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: 2 + ) + + #expect(viewport.workspaceOffset > 0) + #expect(viewport.horizontalOffsets[workspaceID, default: 0] == 0) + } + + @Test + func `Horizontal trackpad movement follows projected ribbon pixels`() { + let viewport = overviewViewportAfterScroll( + OverviewViewport(), + delta: NSPoint(x: -100, y: 0), + hasPreciseScrollingDeltas: true, + viewSize: NSSize(width: 1_512, height: 900), + activeWorkspaceIndex: 1, + workspaceCount: 9, + horizontalWorkspaceID: workspaceID, + maximumHorizontalOffset: 2 + ) + let workspaceHeight = overviewWorkspaceStride(boundsHeight: 900) - 28 + let projectedPointsPerScrollUnit = 1_512.0 * workspaceHeight / 900 + + #expect( + abs( + viewport.horizontalOffsets[workspaceID, default: 0] + - 100.0 / projectedPointsPerScrollUnit + ) < 0.000_001 + ) + } +} diff --git a/Tests/DefiMacOSTests/WindowBorderTests.swift b/Tests/DefiMacOSTests/WindowBorderTests.swift index 866dcbc..4ab7280 100644 --- a/Tests/DefiMacOSTests/WindowBorderTests.swift +++ b/Tests/DefiMacOSTests/WindowBorderTests.swift @@ -156,6 +156,42 @@ struct WindowBorderTests { #expect(plan.tracked.count == 1) } + @Test + func overviewReusesConfiguredWindowBorderPolicy() { + let active = overviewWindowBorderAppearance(isSelected: true, style: style) + #expect(active?.color == style.activeColor) + #expect(active?.width == style.width) + #expect(overviewWindowBorderAppearance(isSelected: false, style: style) == nil) + + let inactiveStyle = WindowBorderStyle( + enabled: true, + width: 2, + activeColor: 0xff11_2233, + inactiveEnabled: true, + inactiveColor: 0x8044_5566, + captureEnabled: false + ) + let inactive = overviewWindowBorderAppearance( + isSelected: false, + style: inactiveStyle + ) + #expect(inactive?.color == inactiveStyle.inactiveColor) + #expect(inactive?.width == inactiveStyle.width) + } + + @Test + func overviewOutsideBorderKeepsTheCardRadius() { + let geometry = overviewWindowBorderGeometry( + cardFrame: Rect(x: 100, y: 200, width: 800, height: 600), + cardRadius: 9, + width: 4, + placement: .outside + ) + + #expect(geometry.frame == Rect(x: 98, y: 198, width: 804, height: 604)) + #expect(geometry.radius == 11) + } + @Test func borderAppearanceValuesAreNormalized() { #expect(borderCornerRadius(windowRadius: 9) == 9) @@ -328,6 +364,47 @@ struct WindowBorderTests { #expect(manager.activeWindowID == third) } + @Test @MainActor + func suppressedBordersStayHiddenUntilRestored() { + let manager = WindowBorderManager() + let windowID = WindowID(rawValue: 1) + let frame = Rect(x: 100, y: 100, width: 800, height: 600) + let plan = planWindowBorders( + frames: [FrameAssignment(windowID: windowID, frame: frame)], + selectedWindowID: windowID, + hiddenWindowIDs: [], + monitorFrames: [monitor], + style: style + ) + + manager.prepareForSelection(windowID, displayedFrame: frame) + manager.sync( + plan, + displayedFrames: [windowID: frame], + stacking: frontmostStacking(for: windowID) + ) + #expect(manager.performance.visible == 1) + + manager.setSuppressed(true) + manager.prepareForSelection(windowID, displayedFrame: frame) + manager.sync( + plan, + displayedFrames: [windowID: frame], + stacking: frontmostStacking(for: windowID) + ) + manager.revealPendingBorders() + #expect(manager.performance.visible == 0) + + manager.setSuppressed(false) + manager.prepareForSelection(windowID, displayedFrame: frame) + manager.sync( + plan, + displayedFrames: [windowID: frame], + stacking: frontmostStacking(for: windowID) + ) + #expect(manager.performance.visible == 1) + } + @Test @MainActor func activeSelectionReusesDormantBorderPanels() { let manager = WindowBorderManager() diff --git a/Tests/DefiMacOSTests/WindowDiscoveryTests.swift b/Tests/DefiMacOSTests/WindowDiscoveryTests.swift index 2405028..512aa80 100644 --- a/Tests/DefiMacOSTests/WindowDiscoveryTests.swift +++ b/Tests/DefiMacOSTests/WindowDiscoveryTests.swift @@ -189,6 +189,38 @@ struct WindowDiscoveryTests { #expect(match?.id == exactFrame.id) } + @Test + func `Identical public windows require an exact identifier`() { + let records = [1, 2].map { + CGWindowRecord( + id: CGWindowID($0), + processID: processID, + layer: 0, + title: "Defi", + frame: frame + ) + } + + #expect( + cgWindowDiscoveryNeedsExactID( + processID: processID, + title: "Defi", + frame: frame, + records: records, + excluding: [] + ) + ) + #expect( + !cgWindowDiscoveryNeedsExactID( + processID: processID, + title: "Defi", + frame: frame, + records: [records[0]], + excluding: [] + ) + ) + } + @Test func `Window matching rejects unrelated surface`() { let unrelated = CGWindowRecord( diff --git a/Tests/DefiModelTests/ModelTests.swift b/Tests/DefiModelTests/ModelTests.swift index 1477fd3..af64749 100644 --- a/Tests/DefiModelTests/ModelTests.swift +++ b/Tests/DefiModelTests/ModelTests.swift @@ -52,6 +52,7 @@ struct ModelTests { ("maximize-column", Command.maximizeColumn), ("toggle-floating", Command.toggleFloating), ("activate-floating", Command.activateFloating), + ("toggle-overview", Command.toggleOverview), ]) func `Parses shared commands`( testCase: (input: String, expected: Command) @@ -87,6 +88,7 @@ struct ModelTests { (Command.moveWindowToMonitor(.right), true, true), (Command.focusColumn(.right), false, false), (Command.toggleFloating, false, false), + (Command.toggleOverview, false, false), ]) func `Managed layout command behavior is explicit`( testCase: (command: Command, resizesLayout: Bool, crossesMonitors: Bool) diff --git a/Tests/DefiRuntimeTests/OverviewRuntimeTests.swift b/Tests/DefiRuntimeTests/OverviewRuntimeTests.swift new file mode 100644 index 0000000..241adfa --- /dev/null +++ b/Tests/DefiRuntimeTests/OverviewRuntimeTests.swift @@ -0,0 +1,295 @@ +import DefiConfig +import DefiCore +import DefiModel +import DefiRuntime +import Testing + +struct OverviewRuntimeTests { + let firstMonitor = MonitorID(rawValue: 1) + let secondMonitor = MonitorID(rawValue: 2) + let firstWorkspace = WorkspaceID(rawValue: "1") + let secondWorkspace = WorkspaceID(rawValue: "2") + + @Test + func `Moves a tiled window to an exact stack atomically`() throws { + let moving = WindowID(rawValue: 1) + let target = WindowID(rawValue: 2) + var state = makeState() + state.monitors[0].workspaces[0].columns = [ + Column(window: moving, width: .fraction(0.8)) + ] + state.monitors[0].workspaces[1].columns = [ + Column(window: target, width: .fraction(0.5)) + ] + state.windows[moving] = window(moving, monitorID: firstMonitor) + state.windows[target] = window(target, monitorID: firstMonitor) + + let result = try applyOverviewDrop( + intent(for: moving), + target: .stack( + monitorID: firstMonitor, + workspaceID: secondWorkspace, + columnIndex: 0, + windowIndex: 1 + ), + viewports: [firstMonitor: Rect(x: 0, y: 0, width: 1_000, height: 800)], + state: &state + ) + + #expect(state.monitors[0].workspaces[0].columns.isEmpty) + #expect(state.monitors[0].workspaces[1].columns[0].windows == [target, moving]) + #expect(state.monitors[0].activeWorkspace == secondWorkspace) + #expect(result.focusedWindowID == moving) + + let layout = computeLayout( + workspace: state.monitors[0].workspaces[1], + viewport: Rect(x: 0, y: 0, width: 1_000, height: 800), + windows: Array(state.windows.values), + settings: LayoutSettings( + innerHorizontalGap: 0, + innerVerticalGap: 0, + outerTopGap: 0, + outerRightGap: 0, + outerBottomGap: 0, + outerLeftGap: 0 + ) + ) + #expect(layout.frames.map(\.frame) == [ + Rect(x: 0, y: 0, width: 500, height: 400), + Rect(x: 0, y: 400, width: 500, height: 400), + ]) + } + + @Test + func `Dropping a sole window onto its own card keeps its column`() throws { + let moving = WindowID(rawValue: 1) + let neighbor = WindowID(rawValue: 2) + var state = makeState() + state.monitors[0].workspaces[0].columns = [ + Column(window: moving, width: .fraction(0.8)), + Column(window: neighbor, width: .fraction(0.5)), + ] + state.windows[moving] = window(moving, monitorID: firstMonitor) + state.windows[neighbor] = window(neighbor, monitorID: firstMonitor) + + _ = try applyOverviewDrop( + intent(for: moving), + target: .stack( + monitorID: firstMonitor, + workspaceID: firstWorkspace, + columnIndex: 0, + windowIndex: 1 + ), + viewports: [firstMonitor: Rect(x: 0, y: 0, width: 1_000, height: 800)], + state: &state + ) + + #expect(state.monitors[0].workspaces[0].columns.map(\.windows) == [ + [moving], [neighbor], + ]) + } + + @Test + func `Rejects stale and fullscreen drops without mutation`() { + let moving = WindowID(rawValue: 1) + var state = makeState() + state.monitors[0].workspaces[0].columns = [ + Column(window: moving, width: .fraction(0.8)) + ] + state.windows[moving] = window(moving, monitorID: firstMonitor) + state.nativeFullscreenWindowIDs = [moving] + let before = state + + #expect(throws: OverviewRuntimeError.nativeFullscreen(moving)) { + try applyOverviewDrop( + intent(for: moving), + target: .newColumn( + monitorID: firstMonitor, + workspaceID: secondWorkspace, + columnIndex: 0 + ), + viewports: [:], + state: &state + ) + } + #expect(state == before) + + var stale = before + stale.nativeFullscreenWindowIDs = [] + let staleBefore = stale + #expect(throws: OverviewRuntimeError.staleSource(moving)) { + try applyOverviewDrop( + OverviewWindowIntent( + windowID: moving, + expectedAppID: "app", + sourceMonitorID: firstMonitor, + sourceWorkspaceID: secondWorkspace + ), + target: .newColumn( + monitorID: firstMonitor, + workspaceID: secondWorkspace, + columnIndex: 0 + ), + viewports: [:], + state: &stale + ) + } + #expect(stale == staleBefore) + } + + @Test + func `Floating cross monitor drop retains classification and frame`() throws { + let moving = WindowID(rawValue: 1) + var state = makeState() + state.monitors[0].workspaces[0].floatingWindows = [moving] + state.windows[moving] = Window( + id: moving, + appID: "app", + title: "window", + frame: Rect(x: 100, y: 100, width: 300, height: 200), + monitorID: firstMonitor, + floating: true + ) + + let result = try applyOverviewDrop( + intent(for: moving), + target: .floating( + monitorID: secondMonitor, + workspaceID: secondWorkspace, + relativeFrame: Rect(x: 0.25, y: 0.25, width: 0.3, height: 0.25) + ), + viewports: [ + firstMonitor: Rect(x: 0, y: 0, width: 1_000, height: 800), + secondMonitor: Rect(x: 1_000, y: 0, width: 2_000, height: 1_200), + ], + state: &state + ) + + #expect(state.windows[moving]?.floating == true) + #expect(state.windows[moving]?.monitorID == secondMonitor) + #expect(state.monitors[1].workspaces[1].floatingWindows == [moving]) + #expect( + result.floatingFrameUpdates[moving] + == Rect(x: 1_500, y: 300, width: 600, height: 300) + ) + } + + @Test + func `Floating drop follows topology and clamps the logical frame`() throws { + let moving = WindowID(rawValue: 1) + var state = makeState() + state.monitors[0].workspaces[0].floatingWindows = [moving] + state.windows[moving] = Window( + id: moving, + appID: "app", + title: "window", + frame: Rect(x: -9_000, y: -9_000, width: 300, height: 200), + monitorID: firstMonitor, + floating: true, + forceTiling: true + ) + + let result = try applyOverviewDrop( + intent(for: moving), + target: .floating( + monitorID: secondMonitor, + workspaceID: secondWorkspace, + relativeFrame: Rect(x: 0.9, y: -0.2, width: 0.3, height: 0.25) + ), + viewports: [ + firstMonitor: Rect(x: 0, y: 0, width: 1_000, height: 800), + secondMonitor: Rect(x: 1_000, y: 0, width: 2_000, height: 1_200), + ], + state: &state + ) + + #expect( + result.floatingFrameUpdates[moving] + == Rect(x: 2_400, y: 0, width: 600, height: 300) + ) + } + + @Test + func `Owner move carries its transient but transient cannot move alone`() throws { + let owner = WindowID(rawValue: 1) + let transient = WindowID(rawValue: 2) + var state = makeState() + state.monitors[0].workspaces[0].columns = [ + Column(window: owner, width: .fraction(0.8)) + ] + state.monitors[0].workspaces[0].floatingWindows = [transient] + state.windows[owner] = window(owner, monitorID: firstMonitor) + state.windows[transient] = Window( + id: transient, + appID: "app", + title: "dialog", + frame: Rect(x: -9_000, y: -9_000, width: 300, height: 200), + transientOwnerID: owner, + monitorID: firstMonitor, + floating: true + ) + + #expect(throws: OverviewRuntimeError.transientWindow(transient)) { + try applyOverviewDrop( + intent(for: transient), + target: .floating( + monitorID: firstMonitor, + workspaceID: secondWorkspace, + relativeFrame: Rect(x: 0.2, y: 0.2, width: 0.3, height: 0.25) + ), + viewports: [firstMonitor: Rect(x: 0, y: 0, width: 1_000, height: 800)], + state: &state + ) + } + + let result = try applyOverviewDrop( + intent(for: owner), + target: .newColumn( + monitorID: firstMonitor, + workspaceID: secondWorkspace, + columnIndex: 0 + ), + viewports: [firstMonitor: Rect(x: 0, y: 0, width: 1_000, height: 800)], + floatingFrames: [ + transient: Rect(x: 200, y: 200, width: 300, height: 200) + ], + state: &state + ) + #expect(state.monitors[0].workspaces[1].columns[0].windows == [owner]) + #expect(state.monitors[0].workspaces[1].floatingWindows == [transient]) + #expect( + result.floatingFrameUpdates[transient] + == Rect(x: 200, y: 200, width: 300, height: 200) + ) + } + + private func makeState() -> RuntimeState { + var state = RuntimeState( + config: Config( + workspaces: WorkspacesConfig(names: ["1", "2"], defaultName: "1") + ) + ) + state.attachMonitor(firstMonitor) + state.attachMonitor(secondMonitor) + return state + } + + private func window(_ id: WindowID, monitorID: MonitorID) -> Window { + Window( + id: id, + appID: "app", + title: "window", + frame: Rect(x: 0, y: 0, width: 800, height: 800), + monitorID: monitorID + ) + } + + private func intent(for id: WindowID) -> OverviewWindowIntent { + OverviewWindowIntent( + windowID: id, + expectedAppID: "app", + sourceMonitorID: firstMonitor, + sourceWorkspaceID: firstWorkspace + ) + } +} diff --git a/docs/adr/0003-keep-overview-independent-from-capture.md b/docs/adr/0003-keep-overview-independent-from-capture.md new file mode 100644 index 0000000..41447bc --- /dev/null +++ b/docs/adr/0003-keep-overview-independent-from-capture.md @@ -0,0 +1,15 @@ +--- +status: accepted +--- + +# Keep overview behavior independent from capture + +Defi builds each monitor's Overview from logical runtime state. Window previews +are optional, enabled through configuration, refreshed once for the current +Overview session, and never required for navigation, focus, or window movement. +The last valid images may remain in a bounded memory-only cache while fresh +captures are pending. They are never persisted. +Missing, stale, protected, or denied previews fall back to identifiable cards. +The Overview may animate as one surface, but captured cards do not simulate the +motion of native windows. All committed focus and topology changes still pass +through DefiRuntime, preserving the macOS authority established by ADR 0001. diff --git a/docs/plans/overview.md b/docs/plans/overview.md new file mode 100644 index 0000000..43c0157 --- /dev/null +++ b/docs/plans/overview.md @@ -0,0 +1,305 @@ +# Overview implementation plan + +Status: implemented and validated on 2026-08-24. + +This plan implements the Overview defined in `CONTEXT.md` and the capture +boundary accepted in ADR 0003. Each phase lands as a revertable commit and +keeps the tree green. + +## Goal + +Provide a keyboard-first, interactive reduced presentation of every configured +workspace on every monitor. Each monitor keeps a vertical stack of full-width, +workspace ribbons. Each ribbon keeps a uniformly scaled view of its horizontal +scrolling strip. Optional window previews add captured pixels without changing +behavior. + +## Product contract + +- Opening the Overview creates one surface per connected monitor. +- Every configured workspace is present, including empty workspaces. +- Each workspace uses the full monitor width as a compact horizontal ribbon. +- Each strip keeps a small leading inset; overflowing strips retain their + horizontal viewport and overflow controls. +- The active workspace is centered. Only workspaces intersecting the monitor + are rendered. +- Vertical navigation moves through the workspace stack. Horizontal navigation + moves a temporary Overview viewport through one scrolling strip. +- Moving the Overview viewport never changes native frames or the workspace's + real scroll offset. +- A simple click focuses its window or workspace and closes the Overview. +- Keyboard navigation changes real focus immediately and keeps the Overview + open. +- A drag changes only the presentation until drop. Drop applies one atomic + runtime mutation, focuses the moved window, and keeps the Overview open. +- The projected ribbons animate after drop while native windows receive one + final layout pass beneath the Overview. +- Dragging near the top or bottom advances the workspace stack visually at a + bounded fixed cadence. It performs no native focus or parking work before + drop. +- A click, scroll, or drop on another monitor makes that monitor active. Hover + alone does not. +- Command-Tab, a Dock activation, screen lock, sleep, or a display topology + change closes the Overview. No stale completion may reopen it or restore an + earlier focus. + +## Window treatment + +- Tiled windows retain their column and stack order. +- Floating windows render above tiled windows at their logical relative frame. +- A floating window remains floating when moved to another workspace or + monitor. +- Transient windows render with their owner and cannot move independently. +- Native fullscreen windows render at their saved logical placement. A click + closes the Overview and activates the fullscreen window. Drag is disabled + while native fullscreen remains active. +- Minimized and unmanaged windows are absent because they do not belong to a + Defi workspace. + +## Input + +- Add `toggle-overview` to command parsing, configuration validation, IPC, and + the CLI. +- Bind `-o` by default while allowing the normal `[keys]` + override. +- While open, capture these unmodified keys through the existing event tap: + - Left and Right navigate columns. + - Up and Down navigate the stack, then adjacent workspaces. + - Return chooses the current selection and closes. + - Escape closes without another focus change. +- Existing configured Defi shortcuts continue to work. +- Pointer input supports click, left-button drag, vertical wheel navigation, + horizontal scroll, and right-button horizontal drag. +- Precise trackpad deltas move the Overview viewport directly along their + dominant axis, preserve macOS natural scrolling, and use the same bounds as + keyboard paging. This phase adds no custom inertia, hot corner, or + four-finger gesture. + +## Configuration and permission + +```toml +[overview] +window_previews = false +``` + +- `false` performs no capture preflight, permission request, or ScreenCaptureKit + content query. +- `true` requests Screen Recording on the next Overview opening when permission + is missing. Configuration loading never presents the system dialog. +- The Overview opens immediately with fallback cards while the permission + request is pending. +- If capture becomes available immediately, previews populate the current + session. Otherwise they populate a later session. +- Denial or revocation keeps the full fallback behavior and does not cause a + repeated automatic request. +- Reloading the value to `false` cancels pending capture work and discards the + in-memory cache. + +## Module boundaries + +`DefiModel` + +- Add the presentation-only `toggleOverview` command and parser support. +- Keep identifiers and serializable command syntax free of AppKit and capture + types. + +`DefiCore` + +- Define immutable Overview projection inputs and outputs. +- Compute full-width ribbon rectangles, uniformly scaled column and stack + geometry, culling, hit tests, and drop targets. +- Keep ribbon height fixed relative to the Overview height for this version. +- Cover the geometry without Accessibility permission. + +`DefiConfig` + +- Add `OverviewConfig` with `windowPreviews = false`. +- Decode `[overview].window_previews` without aliases. +- Add and validate the default toggle binding. + +`DefiRuntime` + +- Accept explicit focus and drop intentions identified by `WindowID` rather + than relying on the previously selected window. +- Validate the current source location and exact destination monitor, + workspace, column index, and stack index at commit time. +- Apply tiled, stacked, floating, transient-owner, and cross-monitor movement + atomically. +- Reject stale window IDs, missing monitors or workspaces, invalid indices, and + native fullscreen moves without partial mutation. +- Reuse the existing focus, width, parking, and descendant movement policies. + +`DefiMacOS` + +- Own the AppKit panels, drawing, pointer interaction, modal keyboard capture, + animation progress, session preview cache, and ScreenCaptureKit calls. +- Create one borderless nonactivating panel per monitor. Keep panels above + application windows but below macOS system UI so Command-Tab, the menu bar, + and Dock remain usable. +- Draw a native dark glass backdrop, full-width workspace ribbons, focus + indication, app icon, title, and optional preview. +- Animate the set of panels with one short scale and fade. Do not morph + individual captured windows. +- Keep the controller independent from `DefiRuntime`; expose callbacks carrying + model identifiers and pure intent values. + +`DefiDaemon` + +- Build immutable snapshots from `RuntimeState`, monitor geometry, and the + existing logical floating-frame cache. +- Wire Overview callbacks to runtime mutations and normal layout/focus effects. +- Reconcile window creation, removal, metadata changes, and focus changes into + the open session. +- Close the session on external focus intent or display topology change. +- Do not move the daemon's existing active-monitor and floating-frame ownership + as part of this feature. The general runtime-domain migration remains a + separate plan. + +The data flow is: + +```text +Runtime state + monitor geometry + floating targets + -> immutable Overview snapshot + -> pure projection + -> AppKit panels + +Panel intent + -> daemon routing + -> DefiRuntime validation and mutation + -> normal focus/layout effects + -> next immutable snapshot + +ScreenCaptureKit result + -> session-generation check + -> presentation cache only + -> panel repaint +``` + +## Drop rules + +- Dropping in either outer quarter of a column creates a neighboring column. +- Dropping in the central half inserts into its stack according to pointer + height. +- Dropping a single-window column onto its own card leaves the column unchanged. +- Dropping into an empty workspace creates its first column. +- Floating drops retain floating classification and store a frame relative to + the target viewport. +- The same rules apply across monitors. +- A source that closes or changes identity during drag cancels the drag. +- A target monitor disappearing closes the complete Overview through the + display-change rule. +- This version does not convert tiled and floating state during drag and does + not create workspaces dynamically. + +## Preview capture + +- Add `NSScreenCaptureUsageDescription` to the app metadata. +- Use public ScreenCaptureKit APIs only. +- Query shareable content with offscreen windows included, then capture each + requested window independently with `SCScreenshotManager`. +- Request previews only for cards intersecting a rendered workspace and + viewport. Capture a window once when it first becomes visible in the current + session. +- Run at most two captures concurrently. +- Size the image to its rendered card instead of capturing native resolution. +- Store images in memory by session generation and `WindowID`; never write them + to disk. Retain at most 16 MiB of validated previews between sessions and + discard a remembered image when its fresh capture fails. +- Discard late results when the session, window identity, or requested size no + longer matches. +- Use the fallback card for permission denial, protected content, empty images, + stale results, and errors. +- Fade captured previews over fallback cards, unless Reduce Motion is enabled, + and progressively blur only the title area for readability. +- Capture no audio or cursor and create no persistent `SCStream`. + +## Implementation phases + +### 1. Commands and configuration + +- Add command parsing, CLI routing, default binding, config decoding, validation, + and documentation. +- Add focused parser and config tests. +- No UI or capture code in this phase. + +### 2. Pure projection and runtime intentions + +- Add Overview geometry, culling, hit testing, viewport navigation, and drop + target calculation in `DefiCore`. +- Add explicit focus and atomic drop handling in `DefiRuntime`. +- Test empty workspaces, stacks, floating windows, transients, fullscreen + rejection, stale intents, and cross-monitor moves. + +### 3. Functional Overview without capture + +- Add panels, rendering, global toggle, modal keyboard handling, pointer input, + per-monitor viewport state, simple animation, and fallback cards. +- Wire live snapshots and closing rules through the daemon. +- Verify that pan and drag perform zero Accessibility writes before a committed + action. + +### 4. Optional previews + +- Add permission handling, visible-first scheduling, bounded screenshot capture, + cache invalidation, and generation checks. +- Add unit tests around scheduling and stale completion policy using injected + capture closures rather than a speculative backend hierarchy. +- Document that a full-display screen share can reveal the Overview and its + previews. + +### 5. Hardening and installed validation + +- Expose Overview open state, panel count, preview configuration, permission + state, in-flight captures, cache count, and failures through status or trace. +- Add accessibility labels and respect the system Reduce Motion setting. +- Exercise click, keyboard navigation, drag, cross-monitor drop, external focus, + permission denial, and display changes with realistic timing. +- Confirm no focus rollback, parking leak, stale image, lost input, or duplicate + daemon. + +## Acceptance checks + +- With `window_previews = false`, opening the Overview performs no capture API + call and presents no privacy dialog. +- Denied Screen Recording produces the same navigation, focus, and drop results + as granted permission. +- Overview opening and input never wait for Accessibility or capture work. +- Pan and in-progress drag produce no native frame or focus writes. +- Each completed drop produces one validated runtime mutation. +- A stale capture or focus completion cannot affect a newer session. +- Each monitor shows and mutates only its own workspace copy unless an explicit + cross-monitor drop commits. +- Each workspace spans the monitor width and preserves window proportions in a + uniformly scaled horizontal ribbon with a consistent leading inset. +- Clicking a card closes the Overview and leaves the intended native window + focused. +- Keyboard navigation keeps the Overview open and uses latest-wins focus. +- Display changes and external app activation close every panel exactly once. +- Fullscreen, transient, floating, empty-workspace, and protected-content cases + preserve their documented fallback behavior. + +## Verification + +Run after each phase: + +```sh +swift build +swift test +./script/build_and_run.sh --verify +``` + +For phases 3 through 5, also run `./script/test_desktop.sh` and validate the +installed build with Computer Use. Restore the initial workspace and confirm +exactly one `defi-daemon` process remains. + +## Out of scope + +- Live or periodically refreshed window previews. +- Per-window zoom morphs. +- Hot corners and custom trackpad gestures. +- Dynamic workspace creation or reordering. +- Tiled/floating conversion during drag. +- Minimized or unmanaged windows. +- Overview zoom, colors, or animation configuration. +- Native macOS Spaces control. +- The general runtime-domain migration. diff --git a/docs/research/macos-overview-implementations.md b/docs/research/macos-overview-implementations.md new file mode 100644 index 0000000..b0c402f --- /dev/null +++ b/docs/research/macos-overview-implementations.md @@ -0,0 +1,157 @@ +# Overview macOS dans Paneru, OmniWM et Rift + +Recherche effectuée le 23 août 2026 sur les dépôts officiels, aux révisions suivantes : + +- [Paneru `bcf248e`](https://github.com/karinushka/paneru/tree/bcf248ef60aa4c0b49002161b97bc6e268b05aa7) +- [OmniWM `430ec6c`](https://github.com/BarutSRB/OmniWM/tree/430ec6c902b12e0a32332a8fe40541d73d8d4b2d) +- [Rift `be8afef`](https://github.com/acsandmann/rift/tree/be8afef6036c77b67b4c49725ced6414601d63b0) + +## Réponse courte + +Paneru ne dessine pas d'overview. Il laisse le Dock afficher Mission Control, ce qui lui donne de vraies miniatures sans demander lui-même Screen Recording. En contrepartie, macOS ne connaît pas ses workspaces virtuels et ne peut pas montrer leur organisation comme Niri. + +OmniWM et Rift dessinent leur propre vue, mais récupèrent tous deux les pixels des fenêtres avec ScreenCaptureKit. Sans Screen Recording, OmniWM conserve une vue utile faite de cartes, icônes, titres et géométrie logique. Rift conserve surtout des cadres vides. Aucun des trois ne produit les pixels réels d'autres applications dans un overview personnalisé sans la permission de capture. + +Apple demande le consentement de la personne avant toute capture ScreenCaptureKit et une clé `NSScreenCaptureUsageDescription` dans l'application. Le framework peut signaler que la personne a refusé la permission. [Documentation ScreenCaptureKit](https://developer.apple.com/documentation/screencapturekit), [erreurs de permission](https://developer.apple.com/documentation/screencapturekit/error-constants) + +| Projet | Vue personnalisée | Pixels des fenêtres | Sans Screen Recording | Fenêtre d'overlay | Activation par geste de l'overview | +| --- | --- | --- | --- | --- | --- | +| Paneru | Non, Mission Control natif | Rendus par macOS | Oui, car Paneru ne capture rien | Aucune pour l'overview | Geste Mission Control géré par macOS | +| OmniWM | Oui | ScreenCaptureKit | Cartes avec icône, titre et structure | `NSPanel` AppKit par moniteur | Non documentée, raccourci global | +| Rift | Oui, expérimental | ScreenCaptureKit | Cadres sans contenu capturé | Fenêtre CGS et `CAContext` privés | Non, raccourcis ou commandes | + +## Paneru + +### Ce qu'il affiche + +Paneru s'appuie sur les Spaces et Mission Control de macOS. Son README décrit des bandes séparées par workspace macOS et des workspaces virtuels internes, mais ne présente aucun overview propriétaire. [README](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/README.md#L25-L44) + +Le code confirme ce choix. `MissionControlHandler` trouve le processus `com.apple.dock`, crée un `AXObserver` et écoute `AXExposeShowAllWindows`, `AXExposeShowFrontWindows`, `AXExposeShowDesktop` et `AXExposeExit`. Ces noms de notifications ne font pas partie de l'API Accessibility publique documentée. [Observation du Dock](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/src/platform/mission_control.rs#L51-L82), [installation de l'observer](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/src/platform/mission_control.rs#L88-L146) + +À l'entrée dans Mission Control, Paneru marque la vue native active et interrompt son scrolling. À la sortie, il recherche les fenêtres déplacées par l'utilisateur et réconcilie son modèle. [Réaction aux événements Mission Control](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/src/ecs/triggers.rs#L348-L399) + +Les overlays AppKit de Paneru ne sont pas des miniatures. Ce sont des fenêtres transparentes et non interactives, une par écran, utilisées pour l'assombrissement et la bordure de focus. [Fabrique et gestion des overlays](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/src/overlay.rs#L211-L267) + +### Permissions et capture + +Paneru exige Accessibility pour déplacer les fenêtres. Son `Info.plist` ne contient aucune justification de capture d'écran et le code ne contient aucun pipeline ScreenCaptureKit. Les vraies miniatures appartiennent au Dock, pas au processus Paneru. [Permission documentée](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/README.md#L68-L76), [`Info.plist`](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/assets/Info.plist#L1-L14) + +Ce chemin utilise tout de même des API privées pour coopérer avec Mission Control, puisque les notifications `AXExpose*` observées sur le Dock sont non documentées. Paneru utilise aussi SkyLight dans son gestionnaire de fenêtres, mais pas pour fabriquer des miniatures d'overview. [Déclaration de l'intégration SkyLight](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/src/manager.rs#L195-L214) + +### Gestes + +Paneru installe un `CGEventTap` HID, convertit les événements de geste en `NSEvent`, lit `allTouches()` et suit les positions `NSTouch`. Si aucun nombre de doigts valide n'est configuré, il laisse explicitement les gestes natifs à macOS. Cette mécanique sert au scrolling et au changement de workspace virtuel, pas à une vue Paneru. [Event tap](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/src/platform/input.rs#L146-L180), [décodage des gestes](https://github.com/karinushka/paneru/blob/bcf248ef60aa4c0b49002161b97bc6e268b05aa7/src/platform/input.rs#L372-L464) + +## OmniWM + +### Ce qu'il affiche + +OmniWM possède un vrai overview de ses workspaces. Il projette la liste des moniteurs, workspaces et fenêtres depuis son modèle, puis récupère la structure Niri de chaque workspace sous forme de colonnes, largeurs et hauteurs. Il ne redimensionne pas les vraies fenêtres pour construire cette projection. [Construction du snapshot](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Overview/OverviewController.swift#L704-L750), [snapshot des colonnes Niri](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Layout/Niri/NiriOverviewSnapshot.swift#L24-L46) + +OmniWM crée un `NSPanel` borderless par moniteur. Chaque panel occupe l'écran, accepte la souris, vit au niveau `.screenSaver` et rejoint tous les Spaces. Le panel principal devient la fenêtre clavier, les autres restent visibles. [Configuration du panel](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Overview/OverviewWindow.swift#L26-L52), [création par moniteur](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Overview/OverviewController.swift#L1143-L1220) + +La vue est interactive. Le code et la documentation couvrent sélection, recherche, fermeture, navigation clavier, déplacement structurel et glisser-déposer entre workspaces et colonnes. [Comportement documenté](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/README.md#L695-L712) + +### Miniatures et fallback sans capture + +À l'ouverture, OmniWM démarre une capture asynchrone. Il vérifie `CGPreflightScreenCaptureAccess()`, demande les fenêtres avec `SCShareableContent`, crée un filtre `desktopIndependentWindow` et appelle `SCScreenshotManager.captureImage`. Il limite le travail à quatre captures concurrentes et met les images en cache pour le rendu. [Pipeline de capture](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Overview/OverviewController.swift#L1270-L1383) + +Si Screen Recording manque, `startThumbnailCapture` s'arrête avant toute capture. Le renderer dessine quand même le fond, la bordure, l'icône d'application, le titre et le nom de l'application. Il ajoute l'image seulement quand le cache contient une miniature. L'overview reste donc navigable et fidèle à la structure, mais ne montre pas le contenu des fenêtres. [Image conditionnelle](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Overview/OverviewRenderer.swift#L420-L465), [icône et textes](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Overview/OverviewRenderer.swift#L480-L572) + +Le produit assume ce compromis. Accessibility et Input Monitoring sont obligatoires au lancement, Screen Recording est optionnel. L'écran de permissions propose « Continue Without Screen Recording », et `Info.plist` explique l'usage des captures. [Modèle des permissions](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/UI/LaunchPermissionsWindowController.swift#L10-L47), [vérification et demande](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/UI/LaunchPermissionsWindowController.swift#L90-L137), [`Info.plist`](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Info.plist#L23-L30) + +### API privées et gestes + +Le rendu de l'overview lui-même repose sur AppKit, Core Graphics et ScreenCaptureKit publics. OmniWM charge toutefois `MultitouchSupport.framework` dynamiquement et résout les symboles privés `MTDevice*` pour ses gestes globaux. Si le framework ou un symbole manque, cette source devient indisponible. [Chargement de MultitouchSupport](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/Sources/OmniWM/Core/Multitouch/MultitouchBinding.swift#L59-L95) + +Le README n'associe pas ces gestes à l'ouverture de l'overview. Il documente un raccourci global pour l'overview et réserve les gestes trackpad au scrolling des colonnes et au changement de workspace. Pour les swipes verticaux à trois ou quatre doigts, il demande même de désactiver le geste Mission Control de macOS afin d'éviter le conflit. [Ouverture de l'overview](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/README.md#L695-L703), [gestes trackpad](https://github.com/BarutSRB/OmniWM/blob/430ec6c902b12e0a32332a8fe40541d73d8d4b2d/README.md#L747-L751) + +## Rift + +### Ce qu'il affiche + +Rift appelle sa vue « Mission Control ». Elle est expérimentale et désactivée par défaut. Deux commandes ouvrent soit tous les workspaces, soit une vue éclatée des fenêtres du workspace courant. [Configuration par défaut](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/rift.default.toml#L215-L221), [commandes d'ouverture](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/rift.default.toml#L495-L500) + +L'acteur copie un snapshot du runtime puis transmet soit les workspaces, soit les fenêtres actives à l'overlay. Les clics déclenchent un changement de workspace ou le focus d'une fenêtre, puis ferment la vue. [Cycle de l'acteur](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/actor/mission_control.rs#L64-L123), [modes d'affichage](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/actor/mission_control.rs#L127-L189) + +Son overlay n'est pas un `NSWindow`. Rift crée une fenêtre WindowServer avec `SLSNewWindowWithOpaqueShapeAndContext`, puis essaie d'y attacher un `CALayer` via la classe privée `CAContext`, `remoteContextWithOptions:` et `SLSSetWindowLayerContext`. Une voie de repli dessine le layer dans un contexte de fenêtre SkyLight. [Création de la fenêtre CGS](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/sys/cgs_window.rs#L90-L123), [liaison du `CAContext`](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/sys/cgs_window.rs#L224-L239), [hébergement et repli](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/ui/mission_control.rs#L1263-L1316) + +### Miniatures et permissions + +Les pixels ne viennent pas de ces API privées. Rift utilise `SCShareableContent`, un filtre `desktopIndependentWindow` et `SCScreenshotManager`. Il récupère l'`IOSurface` du sample buffer et l'assigne au contenu du `CALayer` correspondant. [Préparation des captures](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/ui/mission_control.rs#L110-L187), [capture du sample buffer](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/ui/mission_control.rs#L197-L258), [affectation aux layers](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/ui/mission_control.rs#L1064-L1152) + +Quand ScreenCaptureKit échoue, Rift ignore l'erreur et laisse le layer sans contenu. Les bordures et les labels de workspace restent dessinés, mais le code n'ajoute ni icône, ni titre à chaque carte. Le fallback sans capture est donc moins lisible que celui d'OmniWM. [Layers de fenêtres](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/ui/mission_control.rs#L1010-L1062), [échec de capture toléré](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/ui/mission_control.rs#L226-L266) + +Le dépôt audité ne vérifie pas `CGPreflightScreenCaptureAccess`, ne demande pas `CGRequestScreenCaptureAccess` et son `Info.plist` n'a pas de `NSScreenCaptureUsageDescription`. Il invite seulement à accorder Accessibility. Cela ne contourne pas la protection de macOS, puisque ScreenCaptureKit exige le consentement. C'est une lacune du packaging ou de l'expérience de permission de cette fonctionnalité expérimentale. [`Info.plist`](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/assets/Info.plist#L1-L20), [demande Accessibility](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/sys/accessibility.rs#L61-L88), [règle Apple](https://developer.apple.com/documentation/screencapturekit) + +### Gestes + +Les gestes de Rift ne servent pas à ouvrir son Mission Control. Ils servent au changement de workspace et au scrolling. Rift lit directement des événements CGS de type 29 et leurs collections digitizer IOHID via un `CGEventTap` HID. Cette voie est explicitement décrite comme privée dans le code. [Description du gesture tap](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/actor/gesture_tap.rs#L1-L6), [installation du tap](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/actor/gesture_tap.rs#L336-L399), [décodage CGS et IOHID](https://github.com/acsandmann/rift/blob/be8afef6036c77b67b4c49725ced6414601d63b0/src/sys/gesture.rs#L1-L60) + +## Décision sur Screen Recording pour Defi + +### Ce que la permission autorise + +Sans le picker système, ScreenCaptureKit demande le consentement avant de capturer. macOS conserve ce choix dans `Privacy & Security > Screen & System Audio Recording`, sous la forme d'une autorisation révocable par application. La permission donne donc à Defi la capacité de lire les pixels de l'écran et des autres applications. Elle n'est pas limitée aux fenêtres gérées par Defi. [Présentation de ScreenCaptureKit par Apple](https://developer.apple.com/videos/play/wwdc2022/10156/), [réglage de confidentialité macOS](https://support.apple.com/guide/mac-help/control-access-screen-system-audio-recording-mchld6aa7d23/mac) + +Dans macOS Tahoe, le dialogue peut proposer `Allow` ou `Allow While Using the App`. Apple publie ces deux choix, mais n'explique pas comment le second s'applique à un daemon sans fenêtre active. Defi devra tester ce cas avant de compter sur une autorisation limitée à l'utilisation de l'application. [Dialogue d'autorisation dans macOS Tahoe](https://support.apple.com/guide/mac-help/mchl592e5686/mac) + +Il faut aussi prévoir une friction au premier accord. Le projet d'exemple ScreenCaptureKit d'Apple indique qu'après avoir accordé Screen Recording, il faut redémarrer l'application pour activer la capture. Pour Defi, cela implique au minimum un redémarrage propre du service. [Projet d'exemple ScreenCaptureKit](https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos) + +Depuis macOS Sonoma, `SCContentSharingPicker` offre une autorisation plus étroite. La personne choisit explicitement une fenêtre, une application ou un écran, puis l'application peut capturer cette sélection pendant la session sans obtenir la permission globale Screen Recording. Apple recommande ce picker pour le partage d'écran. Il ne convient toutefois pas à l'overview de Defi : demander à la personne de sélectionner chaque fenêtre gérée casserait l'ouverture instantanée et ne donnerait pas automatiquement accès aux nouveaux contenus. [Modèle d'autorisation du picker](https://developer.apple.com/videos/play/wwdc2023/10053/?time=377), [`SCContentSharingPicker`](https://developer.apple.com/documentation/screencapturekit/sccontentsharingpicker) + +Apple justifie ce picker par la friction de la permission globale et par son risque de partage excessif. Ce constat concerne un outil de visioconférence, mais la même permission paraîtra large pour une fonction d'overview qui peut déjà fonctionner sans pixels. [Présentation des changements de confidentialité](https://developer.apple.com/videos/play/wwdc2023/10053/?time=347) + +### Permission accordée, capture inactive + +Une permission accordée est un état TCC, pas une capture permanente. `SCStream` ne fournit des images qu'après `startCapture()` et cesse après `stopCapture()`. Si Defi n'instancie aucun flux au repos, il n'a ni cadence d'images, ni pool de surfaces de capture à entretenir. Apple ne publie toutefois aucune mesure permettant d'affirmer que le coût exact de la permission inutilisée est nul. [Cycle de vie de `SCStream`](https://developer.apple.com/documentation/screencapturekit/scstream), [fonctionnement des surfaces](https://developer.apple.com/videos/play/wwdc2022/10155/?time=1183) + +Pour des miniatures, Defi n'a pas besoin d'un flux. `SCScreenshotManager.captureImage` produit une image ponctuelle de manière asynchrone sans créer de `SCScreenshotManager` ni de `SCStream`. Le coût se limite alors aux captures demandées à l'ouverture de l'overview et aux images conservées par Defi. Apple ne donne pas de budget chiffré pour une rafale de captures, il faudra donc mesurer sur le bureau réel avant d'en fixer le parallélisme. [API de capture ponctuelle](https://developer.apple.com/videos/play/wwdc2023/10136/?time=586) + +### Coexistence avec Zoom, Meet ou un autre partage + +ScreenCaptureKit n'est pas une ressource exclusive. Apple décrit le menu Vidéo comme listant chaque application qui possède un flux actif et les flux associés à chacune. Le framework accepte aussi plusieurs flux dans une même application. Un flux Defi peut donc coexister avec celui d'un outil de visioconférence, et une capture ponctuelle a une durée encore plus courte. [Intégration multi-application des flux](https://developer.apple.com/videos/play/wwdc2023/10136/?time=144), [`maximumStreamCount`](https://developer.apple.com/documentation/screencapturekit/sccontentsharingpicker/maximumstreamcount-66khx) + +Apple ne publie en revanche ni limite globale inter-application, ni garantie de débit quand plusieurs captures sollicitent le GPU et la mémoire en même temps. La coexistence fonctionnelle ne garantit donc pas un coût nul. Pour Defi, des captures ponctuelles, sans audio et seulement à l'ouverture de l'overview, évitent de laisser un concurrent permanent à Zoom ou Meet. ScreenCaptureKit utilise des surfaces GPU et Apple indique qu'augmenter leur nombre augmente la mémoire consommée. [Coût des surfaces de capture](https://developer.apple.com/videos/play/wwdc2022/10155/?time=1183) + +### Indicateurs visibles dans macOS + +Un `SCStream` actif apparaît dans le menu Vidéo de macOS avec une prévisualisation et des commandes pour modifier ou arrêter le partage. L'écran verrouillé peut aussi afficher « Your screen is being observed » pendant un enregistrement ou un partage. Apple associe ces signaux à une capture active, pas au simple fait que la permission reste accordée dans Réglages Système. [Menu Vidéo des flux actifs](https://developer.apple.com/videos/play/wwdc2023/10136/?time=144), [rappel sur l'écran verrouillé](https://support.apple.com/120315) + +Apple documente `SCScreenshotManager` comme une capture ponctuelle distincte d'un `SCStream`, mais ne précise pas quel indicateur apparaît pendant cet appel bref. Defi ne doit donc pas promettre que la capture de miniatures sera invisible dans l'interface de confidentialité de toutes les versions de macOS. [Différence entre flux et capture ponctuelle](https://developer.apple.com/videos/play/wwdc2023/10136/?time=586) + +### Contenus protégés + +La permission Screen Recording ne contourne pas la protection du contenu. Une application peut protéger un `AVSampleBufferDisplayLayer` contre la capture. Pour les vidéos chiffrées Clear Key lues par `AVPlayer`, la capture est désactivée par défaut et l'option qui l'autorise n'a aucun effet sur FairPlay Streaming. Apple prévient aussi que certaines fenêtres, comme celles d'Apple TV, peuvent ne pas être enregistrables. Une miniature peut donc contenir une zone vide ou protégée même quand le reste de la fenêtre est disponible. Defi doit conserver sa carte avec icône et titre comme repli et ne pas relancer indéfiniment une capture vide. [`preventsCapture`](https://developer.apple.com/documentation/avfoundation/avsamplebufferdisplaylayer/preventscapture), [`allowsCaptureOfClearKeyVideo`](https://developer.apple.com/documentation/avfoundation/avplayer/allowscaptureofclearkeyvideo), [limites de l'enregistrement macOS](https://support.apple.com/fr-fr/102618) + +### Ce que verra une personne pendant un partage d'écran + +Si Zoom, Meet, FaceTime ou un autre outil partage l'écran entier, l'overlay Defi est une fenêtre visible de cet écran et fera partie du partage. Ses cartes, titres et miniatures peuvent alors révéler le contenu d'autres workspaces. Si l'outil partage une fenêtre ou une application choisie, ScreenCaptureKit limite au contraire la sortie à cette sélection. L'overlay Defi n'en fait pas partie, sauf si la personne choisit Defi lui-même. FaceTime expose les trois choix écran, fenêtre et application dans l'interface système. [Filtres de capture](https://developer.apple.com/videos/play/wwdc2022/10155/?time=282), [choix proposés par FaceTime](https://support.apple.com/guide/facetime/fctmdcf2007a/mac) + +Ce comportement ne vient pas du fait que Defi possède la permission. Une version sans miniatures afficherait elle aussi son overlay pendant le partage de l'écran entier. La différence tient aux informations montrées dans les cartes. Il faut donc considérer l'overview comme Mission Control : l'ouvrir pendant un partage plein écran montre la vue d'ensemble au public. Mission Control affiche lui aussi toutes les fenêtres ouvertes et les Spaces quand la personne l'active. [Comportement de Mission Control](https://support.apple.com/guide/mac-help/mh35798/mac) + +Defi ne peut pas rendre son overlay invisible à toutes les applications de partage avec `NSWindow.sharingType = .none`. Apple classe désormais cette valeur comme une constante historique que macOS n'utilise plus. L'application qui partage garde la maîtrise de son propre filtre, Defi ne peut pas lui imposer une exclusion. [`NSWindow.SharingType`](https://developer.apple.com/documentation/appkit/nswindow/sharingtype-swift.enum) + +### Fenêtres parquées hors écran + +Le filtre `desktopIndependentWindow` est adapté au parking de Defi. Apple indique qu'il capture tout le contenu d'une fenêtre même si elle est masquée par une autre, complètement hors écran, sur un autre écran ou un autre Space. Il faut obtenir les candidates avec `onScreenWindowsOnly: false`. Une capture basée sur un display aurait le comportement opposé : elle retirerait une fenêtre déplacée hors de l'écran. [Capture d'une fenêtre hors écran](https://developer.apple.com/videos/play/wwdc2022/10155/?time=575), [`onScreenWindowsOnly`](https://developer.apple.com/documentation/screencapturekit/scshareablecontent/getexcludingdesktopwindows(_:onscreenwindowsonly:completionhandler:)) + +Les fenêtres minimisées sont une exception. Apple met leur flux en pause jusqu'à leur restauration. Cela ne bloque pas Defi, qui parque les fenêtres par position et ne les minimise pas. Sur les versions qui exposent cette option, `ignoreGlobalClipSingleWindow` permet aussi d'inclure le contenu déplacé au-delà des limites de clipping du display d'origine. [Comportement d'une fenêtre minimisée](https://developer.apple.com/videos/play/wwdc2022/10155/?time=599), [`ignoreGlobalClipSingleWindow`](https://developer.apple.com/documentation/screencapturekit/scstreamconfiguration/ignoreglobalclipsinglewindow) + +ScreenCaptureKit garantit la composition de la fenêtre hors écran, pas la fréquence à laquelle l'application propriétaire redessine son contenu quand elle n'est pas visible. Defi doit accepter une miniature ancienne et la remplacer par sa carte si la capture échoue. Aucune documentation Apple consultée ne promet la fraîcheur d'une fenêtre parquée. + +### Acceptation utilisateur + +Il n'existe pas de statistique publique solide sur le taux d'acceptation de Screen Recording pour les window managers macOS. Les dépôts de Paneru, OmniWM et Rift ne publient ni taux d'activation, ni taux de refus. Leur existence ou leur nombre d'étoiles ne mesure pas l'acceptation de cette permission. + +Les choix d'Apple donnent tout de même une direction produit. Mission Control fournit un overview sans demander une permission tierce. FaceTime et Screenshot attendent une action explicite, puis demandent à la personne de choisir ce qu'elle partage ou enregistre. Les Human Interface Guidelines recommandent de demander une permission au moment où la personne utilise la fonction qui la nécessite, et d'éviter une demande au lancement quand l'accès n'est pas indispensable. [Mission Control](https://support.apple.com/guide/mac-help/mh35798/mac), [outil Screenshot](https://support.apple.com/guide/mac-help/take-a-screenshot-mh26782/mac), [règles Apple sur les permissions](https://developer.apple.com/design/human-interface-guidelines/privacy) + +La décision du geste reste séparée. Aucun des deux overview personnalisés audités ne fournit le geste vertical de Niri. Paneru conserve le geste Mission Control natif. OmniWM et Rift utilisent des mécanismes privés ou non documentés pour d'autres gestes, puis ouvrent leur overview par raccourci ou commande. + +### Recommandation + +Livrer d'abord l'overview complet sans Screen Recording, avec cartes, icônes, titres et géométrie logique. Ajouter les vraies miniatures comme option explicite, désactivée par défaut, et ne demander la permission que lorsque la personne active cette option depuis l'overview ou les réglages. + +Quand l'option est active, utiliser `SCScreenshotManager` à l'ouverture de l'overview, une capture `desktopIndependentWindow` par fenêtre, `onScreenWindowsOnly: false`, `ignoreGlobalClipSingleWindow: true`, sans audio et sans flux permanent. Garder les images en mémoire le temps de l'overview, puis les jeter. Conserver la carte schématique en cas de refus, de contenu protégé, de miniature ancienne ou d'erreur. Documenter qu'un partage de l'écran entier montre l'overview et son contenu. + +Ce choix garde le comportement de Niri sans rendre une permission large obligatoire. Il évite aussi un flux GPU permanent et laisse les personnes qui acceptent Screen Recording obtenir les vraies miniatures. Aucun des deux modes n'a besoin d'une API privée. From 6ab474c3beb2a3acbe7dd05e7106fcd756be3c5f Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Tue, 25 Aug 2026 22:36:12 +0200 Subject: [PATCH 2/2] fix: reject preview captures from reused window IDs --- .../DefiMacOS/OverviewPreviewCapture.swift | 13 ++++++++++- .../DefiMacOSTests/OverviewPreviewTests.swift | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Sources/DefiMacOS/OverviewPreviewCapture.swift b/Sources/DefiMacOS/OverviewPreviewCapture.swift index e1782f6..ab17713 100644 --- a/Sources/DefiMacOS/OverviewPreviewCapture.swift +++ b/Sources/DefiMacOS/OverviewPreviewCapture.swift @@ -92,6 +92,13 @@ func overviewPreviewCacheCanStore( return bytesWithoutExisting <= maximumBytes - byteCost } +func overviewPreviewOwnerMatches( + expectedAppID: String, + capturedAppID: String? +) -> Bool { + capturedAppID == expectedAppID +} + func runOverviewPreviewCaptures( _ requests: [OverviewPreviewRequest], maximumConcurrent: Int = 2, @@ -187,7 +194,11 @@ private final class OverviewScreenCaptureBatch { ) async -> OverviewPreviewCaptureResult { guard !Task.isCancelled, let windowID = CGWindowID(exactly: request.windowID.rawValue), - let window = windows[windowID] + let window = windows[windowID], + overviewPreviewOwnerMatches( + expectedAppID: request.expectedAppID, + capturedAppID: window.owningApplication?.bundleIdentifier + ) else { return OverviewPreviewCaptureResult(request: request, image: nil) } diff --git a/Tests/DefiMacOSTests/OverviewPreviewTests.swift b/Tests/DefiMacOSTests/OverviewPreviewTests.swift index 6efbc83..3c09f8d 100644 --- a/Tests/DefiMacOSTests/OverviewPreviewTests.swift +++ b/Tests/DefiMacOSTests/OverviewPreviewTests.swift @@ -58,6 +58,28 @@ struct OverviewPreviewTests { ) } + @Test + func `Preview capture rejects a reused window ID from another app`() { + #expect( + overviewPreviewOwnerMatches( + expectedAppID: "com.example.original", + capturedAppID: "com.example.original" + ) + ) + #expect( + overviewPreviewOwnerMatches( + expectedAppID: "com.example.original", + capturedAppID: "com.example.replacement" + ) == false + ) + #expect( + overviewPreviewOwnerMatches( + expectedAppID: "com.example.original", + capturedAppID: nil + ) == false + ) + } + @Test func `Remembered previews stay inside their memory budget`() { let first = WindowID(rawValue: 1)