diff --git a/AGENTS.md b/AGENTS.md index 5399eb0..6852787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,9 @@ Current strategy may evolve when replacement preserves the same outcomes and tes - coalesce transient focus changes during rapid navigation; commit real AX focus only for the final target - keep focus writes asynchronous so slow applications cannot block command intake - keep horizontal navigation position-only unless a replacement proves synchronous size work cannot enter the input path +- keep vertical workspace transitions position-only and all-or-nothing; use an + immediate switch when any participating AX lane or display topology cannot + animate safely within the refresh budget - settle latency-sensitive windows outside the speculative path while preserving their real targets, hidden state, and parking state The scrolling workspace is one continuous horizontal strip. @@ -105,16 +108,32 @@ The scrolling workspace is one continuous horizontal strip. - keep native click, Dock, and Command-Tab focus compatible with virtual-workspace activation - ignore redundant native focus on the already-selected window; no reflow or animation from a plain click -Each monitor owns an independent copy of the configured virtual workspace set. +Each monitor owns an independent ordered workspace stack. A workspace is +globally unique and belongs to exactly one monitor at a time. - preserve each monitor's own geometry, active workspace, focus, column widths, and scroll offset +- keep exactly one empty trailing ordinary workspace on every monitor; remove + other empty ordinary workspaces only after they become inactive +- keep named workspaces persistent and globally unique; application rules may + target names, never dynamic positions +- preserve workspace identity, ownership, affinity, order, membership, focus, + widths, and scroll state across daemon restarts within one macOS session +- migrate workspaces temporarily after display loss and return them only after a + confident monitor match; explicit monitor moves update affinity, automatic + migration does not, and ambiguous matches leave workspaces on the fallback + monitor - never park one monitor's windows inside another monitor's visible or parking region - reconcile widths, heights, targets, and parking after display connection, disconnection, or geometry change +- an active empty trailing workspace has no native focus target; stale native + focus must not reactivate its previous workspace without newer human intent ## Configuration Defaults live in code and `CONFIGURATION.md`. Example config contains user-specific overrides only. +Built-in defaults declare no named workspaces. `[workspaces].names` declares +persistent names only; ordinary workspaces come from the dynamic lifecycle. + No compatibility aliases before first stable release. Ask before preserving obsolete config. ## Verification diff --git a/CONFIGURATION.md b/CONFIGURATION.md index b42c656..d450dfc 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -53,7 +53,7 @@ reserved_bottom = 0 | `gaps` | `8` | number from `0` to `256` | Uniform logical-pixel gap between windows and monitor edges. | | `outer_top_gap` | `gaps` | number from `0` to `256` | Optional top-edge override. | | `outer_right_gap` | `gaps` | number from `0` to `256` | Optional right-edge override. | -| `outer_bottom_gap` | `gaps` | number from `0` to `256` | Optional bottom-edge override. Set to `0` to meet the macOS visible frame above the Dock. | +| `outer_bottom_gap` | `gaps` | number from `0` to `256` | Optional bottom-edge override. With an outside border, Defi still reserves the border width above the Dock. | | `outer_left_gap` | `gaps` | number from `0` to `256` | Optional left-edge override. | | `reserved_top` | `0` | number from `0` to `512` | Additional top inset after the macOS visible frame, for bars extending below the native menu-bar exclusion. | | `reserved_bottom` | `0` | number from `0` to `512` | Additional bottom inset after the macOS visible frame. | @@ -113,7 +113,7 @@ duration_ms = 35 | Setting | Default | Values/type | Description | | --- | --- | --- | --- | | `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`. | +| `duration_ms` | `35` | integer from `0` to `2000` | Animation duration in milliseconds. Vertical workspace transitions use at least 180 ms when the usable viewport covers the physical display; otherwise they switch immediately to prevent reserved-area leaks. `0` disables animation even when `enabled = true`. | ## `[overview]` @@ -123,12 +123,14 @@ Controls the Overview scale and optional pixels inside window cards. [overview] zoom = 0.5 window_previews = false +window_corner_radius = 12 ``` | 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. | +| `window_corner_radius` | `12` | number from `0` to `64` | Rounds window cards and their borders in the Overview. | With the default `false`, Defi performs no Screen Recording permission check, request, or ScreenCaptureKit content query. With `true`, the next Overview @@ -176,7 +178,7 @@ placement = "outside" With `placement = "outside"`, the stroke no longer overlaps window content, but the ring extends into gaps between adjacent windows; a neighboring window drawn -above the border panel can clip it there. Defi reserves enough horizontal space +above the border panel can clip it there. Defi reserves enough space at monitor edges to keep the full stroke visible without changing gaps between windows. @@ -201,35 +203,49 @@ under the pointer while deciding between a click and a drag. ## `[workspaces]` -Defines stable workspace IDs and startup workspace. +Declares optional persistent named workspaces. Ordinary workspaces are dynamic. ```toml [workspaces] names = ["dev", "web", "tools"] default = "dev" +monitors = { tools = 2 } ``` | Setting | Default | Values/type | Description | | --- | --- | --- | --- | -| `names` | `["1", "2", "3", "4", "5", "6", "7", "8", "9"]` | non-empty array of unique strings | Workspace IDs used by commands, keybindings, and rules. | -| `default` | first entry in `names` (`"1"` by default) | string present in `names` | Workspace active on each monitor at daemon startup. | +| `names` | `[]` | array of unique strings | Persistent globally unique workspace names used by rules and stable bindings. | +| `default` | first entry in `names`, otherwise unset | string present in `names` | Startup workspace on its owning monitor. | +| `monitors` | `{}` | table from workspace name to 1-based display index | Initial monitor affinity for named workspaces; unspecified names use the primary display. | -Every connected monitor owns an independent copy of this workspace set. Each -monitor preserves its own active workspace, focus, widths, and scroll offset. +Each workspace belongs to exactly one monitor. Every monitor keeps one trailing +empty workspace shown as `+`. Populating it turns it into an ordinary workspace +and creates another trailing workspace; an empty ordinary workspace disappears +after it becomes inactive. Named workspaces persist even when empty. + +Workspace identity, ownership, order, focus, widths, and scroll position persist +across daemon restarts in the current macOS login session. If a display +disconnects, its workspaces move temporarily to a fallback display and return +when the same display identity reconnects. An explicit workspace-to-monitor move +updates its affinity. Use stable, whitespace-free names. Workspace command arguments are separated by whitespace, so names containing spaces cannot be addressed by keybindings or the -CLI. +CLI. The `__defi_dynamic_` prefix is reserved for ordinary workspace identity. -Generated number shortcuts target the first nine entries by position. Example: +Generated number shortcuts bind configured names first, then dynamic positions. +Example: ```toml [workspaces] names = ["dev", "web", "tools"] ``` -This generates `alt-1 = "workspace dev"`, `alt-2 = "workspace web"`, and -`alt-3 = "workspace tools"`, plus matching `alt-shift-N` move bindings. +This generates stable `alt-1 = "workspace dev"`, `alt-2 = "workspace web"`, +and `alt-3 = "workspace tools"` bindings. `alt-4` through `alt-9` address the +current monitor's dynamic positions. Matching `alt-shift-N` bindings use +`move-column-to-workspace-name` and follow the moved column. Positions are +1-based, do not wrap, and values beyond the current stack select trailing. ## `default_key_modifier` @@ -306,8 +322,8 @@ Examples: `alt-left`, `cmd-shift-p`, `hyper-backslash`. ### Commands -Commands use the same strings as the `defi` CLI. Workspace arguments must name -an entry from `[workspaces].names`. +Commands use the same strings as the `defi` CLI. Named targets must be declared +in `[workspaces].names`; application rules cannot target dynamic positions. | Command | Description | Generated default | | --- | --- | --- | @@ -315,24 +331,31 @@ an entry from `[workspaces].names`. | `focus-column right` | Focus next column. | `-right` | | `focus-column first` | Focus first column. | `-leftbracket` | | `focus-column last` | Focus last column. | `-rightbracket` | -| `focus-window up` | Focus previous window in current stack. | `-up` | -| `focus-window down` | Focus next window in current stack. | `-down` | +| `focus-workspace up\|down` | Focus the adjacent workspace without wrapping. | `-up` / `-down` | +| `focus-workspace-position ` | Focus a 1-based position, clamped to trailing. | `-1` … `-9` after configured names | +| `focus-workspace-name ` or `workspace ` | Focus a persistent named workspace, including on another monitor. | `-1` … `-9` for configured names | +| `focus-window up` | Focus previous window in current stack. | `-k` | +| `focus-window down` | Focus next window in current stack. | `-j` | | `focus-window first` | Focus first window in current stack. | unset | | `focus-window last` | Focus last window in current stack. | unset | | `move-column left` | Move focused column left. | `-shift-left` | | `move-column right` | Move focused column right. | `-shift-right` | | `move-column first` | Move focused column to first position. | `-shift-leftbracket` | | `move-column last` | Move focused column to last position. | `-shift-rightbracket` | -| `move-window up` | Move focused window up inside current stack. | `-shift-up` | -| `move-window down` | Move focused window down inside current stack. | `-shift-down` | -| `move-column-to-monitor left` | Move focused column to the nearest monitor on the left. | `-shift-h` | -| `move-column-to-monitor down` | Move focused column to the nearest monitor below. | `-shift-j` | -| `move-column-to-monitor up` | Move focused column to the nearest monitor above. | `-shift-k` | -| `move-column-to-monitor right` | Move focused column to the nearest monitor on the right. | `-shift-l` | +| `move-window up` | Move focused window up inside current stack. | `-shift-k` | +| `move-window down` | Move focused window down inside current stack. | `-shift-j` | +| `move-column-to-workspace up\|down\|` | Move the focused column and follow it. Use `move-column-to-workspace-name ` when a name is `up` or `down`. | `-shift-up` / `-shift-down` | +| `send-column-to-workspace up\|down\|` | Move the focused column without following it. | unset | +| `move-column-to-workspace-position ` | Move the focused column to a position and follow it. | `-shift-1` … `-shift-9` after configured names | +| `move-window-to-workspace up\|down\|` | Move only the focused window and follow it. The explicit-name form is `move-window-to-workspace-name `. | unset | +| `send-window-to-workspace up\|down\|` | Move only the focused window without following it. | unset | +| `move-window-to-workspace-position ` | Move only the focused window to a position and follow it. | unset | +| `send-window-to-workspace-position ` | Move only the focused window to a position without following it. | unset | +| `focus-monitor left\|right\|up\|down` | Focus the nearest monitor in that direction. | `ctrl-cmd-` | +| `move-column-to-monitor left\|right\|up\|down` | Move focused column to the nearest monitor. | `ctrl-cmd-shift-` | | `move-window-to-monitor left\|right\|up\|down` | Move only the focused window to the nearest monitor in that direction. | unset | -| `workspace ` | Switch active monitor to workspace. | `-1` … `-9` | -| `move-window-to-workspace ` | Move focused window and follow it. | `-shift-1` … `-shift-9` | -| `send-window-to-workspace ` | Move focused window without switching workspace. | unset | +| `reorder-workspace up\|down` | Reorder the active workspace inside its monitor stack. | unset | +| `move-workspace-to-monitor left\|right\|up\|down` | Move the active workspace and update its monitor affinity. | unset | | `cycle-width previous` | Select previous width preset, wrapping. | `-minus` | | `cycle-width next` | Select next width preset, wrapping. | `-equal` | | `maximize-column` | Toggle focused column between full width and previous width. | `-f` | @@ -375,8 +398,8 @@ CLI-only integration commands: | Command | Description | | --- | --- | -| `list-workspaces` | Print configured workspace names. | -| `list-workspaces --json` | Print versioned per-display workspace and application state. | +| `list-workspaces` | Print current workspace labels per display. | +| `list-workspaces --json` | Print versioned per-display identity, position, name, kind, and application state. | | `--monitor ` | Execute command on 1-based `NSScreen.screens`/SketchyBar display index. | | `set-reserved-area top\|bottom ` | Override extra reserved edge on every display, or targeted `--monitor`. | | `clear-reserved-area` | Restore configured reserved edges. | @@ -474,6 +497,7 @@ duration_ms = 35 [overview] zoom = 0.5 window_previews = false +window_corner_radius = 12 [decorations.borders] enabled = true @@ -485,46 +509,43 @@ capture_enabled = false placement = "outside" [workspaces] -names = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] -default = "1" +names = [] [modifier_combinations] [keys] "alt-left" = "focus-column left" "alt-right" = "focus-column right" -"alt-up" = "focus-window up" -"alt-down" = "focus-window down" +"alt-up" = "focus-workspace up" +"alt-down" = "focus-workspace down" +"alt-j" = "focus-window down" +"alt-k" = "focus-window up" "alt-o" = "toggle-overview" "alt-leftbracket" = "focus-column first" "alt-rightbracket" = "focus-column last" "alt-shift-left" = "move-column left" "alt-shift-right" = "move-column right" -"alt-shift-up" = "move-window up" -"alt-shift-down" = "move-window down" +"alt-shift-up" = "move-column-to-workspace up" +"alt-shift-down" = "move-column-to-workspace down" +"alt-shift-j" = "move-window down" +"alt-shift-k" = "move-window up" "alt-shift-leftbracket" = "move-column first" "alt-shift-rightbracket" = "move-column last" -"alt-1" = "workspace 1" -"alt-2" = "workspace 2" -"alt-3" = "workspace 3" -"alt-4" = "workspace 4" -"alt-5" = "workspace 5" -"alt-6" = "workspace 6" -"alt-7" = "workspace 7" -"alt-8" = "workspace 8" -"alt-9" = "workspace 9" - -"alt-shift-1" = "move-window-to-workspace 1" -"alt-shift-2" = "move-window-to-workspace 2" -"alt-shift-3" = "move-window-to-workspace 3" -"alt-shift-4" = "move-window-to-workspace 4" -"alt-shift-5" = "move-window-to-workspace 5" -"alt-shift-6" = "move-window-to-workspace 6" -"alt-shift-7" = "move-window-to-workspace 7" -"alt-shift-8" = "move-window-to-workspace 8" -"alt-shift-9" = "move-window-to-workspace 9" +"alt-1" = "focus-workspace-position 1" +# ... through alt-9 +"alt-shift-1" = "move-column-to-workspace-position 1" +# ... through alt-shift-9 + +"ctrl-cmd-left" = "focus-monitor left" +"ctrl-cmd-right" = "focus-monitor right" +"ctrl-cmd-up" = "focus-monitor up" +"ctrl-cmd-down" = "focus-monitor down" +"ctrl-cmd-shift-left" = "move-column-to-monitor left" +"ctrl-cmd-shift-right" = "move-column-to-monitor right" +"ctrl-cmd-shift-up" = "move-column-to-monitor up" +"ctrl-cmd-shift-down" = "move-column-to-monitor down" "alt-minus" = "cycle-width previous" "alt-equal" = "cycle-width next" diff --git a/CONTEXT.md b/CONTEXT.md index 09acf24..b8ae284 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,14 +7,55 @@ as the owner of window lifecycle and focus. **Monitor**: Defi's logical representation of one connected macOS display. Each monitor owns -an independent copy of the configured workspaces. +an independent ordered stack of workspaces. _Avoid_: Screen, output **Workspace**: -A named virtual collection of windows owned by exactly one monitor. Workspaces -with the same name on different monitors are distinct. +A globally unique virtual collection of windows owned by exactly one monitor at +a time. A workspace is either ordinary or named and may move between monitors. _Avoid_: macOS Space, desktop +**Ordinary workspace**: +A dynamic workspace without a stable name. Each monitor keeps one empty ordinary +workspace at the bottom, and removes other ordinary workspaces after they become +empty and inactive. +_Avoid_: Numbered workspace, anonymous workspace + +**Named workspace**: +A persistent workspace with a stable name that application rules and commands +can target, including while it is empty. Configuration supplies its name and +optional monitor affinity; otherwise it starts on the primary monitor. +_Avoid_: Numbered workspace, static workspace + +**Trailing workspace**: +The system-owned empty ordinary workspace at the bottom of each monitor's stack. +It may be active while empty; once populated, it becomes ordinary and is +immediately replaced. +_Avoid_: Default workspace, numbered workspace + +**Workspace position**: +The current one-based position of a workspace in its monitor's ordered stack. It +may change when workspaces are inserted, removed, reordered, or moved; a +position beyond the current stack resolves to the trailing workspace. +_Avoid_: Workspace number, workspace ID + +**Workspace affinity**: +The monitor a workspace returns to after a temporary monitor disconnection. +Explicit monitor moves change it, while automatic migration or an unavailable +configured monitor leaves it pending as the workspace lives elsewhere. +_Avoid_: Current monitor, original monitor + +**Placement preference**: +The last existing workspace associated with an application when no application +rule provides a named destination. It never recreates a removed workspace. +_Avoid_: Application rule + +**Workspace topology**: +The workspace identities, monitor ownership, vertical order, window membership, +column structure, focus, widths, and scroll state that Defi preserves across +daemon restarts within a macOS session. +_Avoid_: Layout, configuration + **Scrolling strip**: The continuous horizontal sequence of columns inside a workspace. _Avoid_: Grid, tree diff --git a/README.md b/README.md index 6b6c647..8c0b2a8 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ within macOS's constraints. - **Scrolling columns** — keep neighboring context visible while moving through a wide desktop. -- **Per-monitor workspaces** — every display owns its own workspace set, - focused window, widths, and scroll position. +- **Dynamic per-monitor workspaces** — every display owns an ordered stack with + one trailing empty workspace; populated workspaces appear and empty ordinary + workspaces disappear automatically. - **Keyboard-first control** — focus, move, stack, resize, maximize, and switch workspaces without reaching for a mouse. - **Native focus and frame reconciliation** — Defi follows real macOS focus and @@ -63,7 +64,7 @@ focus, and park windows: Without this permission, Defi can start but cannot manage application windows. Defi targets macOS 26 or newer. No configuration file is required: built-in -defaults provide workspaces `1` through `9` and Option-based shortcuts. +defaults provide dynamic workspaces and Option-based shortcuts. Default keyboard bindings use Option. The complete shortcut and command reference lives in [CONFIGURATION.md](CONFIGURATION.md). @@ -98,10 +99,11 @@ Examples: /Applications/Defi.app/Contents/MacOS/defi trace /Applications/Defi.app/Contents/MacOS/defi diagnostic-mark /Applications/Defi.app/Contents/MacOS/defi focus-column left -/Applications/Defi.app/Contents/MacOS/defi workspace 2 -/Applications/Defi.app/Contents/MacOS/defi --monitor 2 workspace 2 +/Applications/Defi.app/Contents/MacOS/defi focus-workspace down +/Applications/Defi.app/Contents/MacOS/defi focus-workspace-position 2 +/Applications/Defi.app/Contents/MacOS/defi workspace dev /Applications/Defi.app/Contents/MacOS/defi list-workspaces --json -/Applications/Defi.app/Contents/MacOS/defi move-window-to-workspace 3 +/Applications/Defi.app/Contents/MacOS/defi move-column-to-workspace dev /Applications/Defi.app/Contents/MacOS/defi quit ``` @@ -118,7 +120,7 @@ exists, it installs `defi.example.toml` as the initial user config. This example intentionally overrides the built-in defaults: it uses the Hyper modifier and named workspaces such as `dev`, `web`, and `tools`. Remove or edit `~/.config/defi/config.toml` if you want to use the built-in Option -bindings and workspaces `1` through `9` instead. +bindings with unnamed dynamic workspaces instead. The script uses an Apple Development identity. If multiple identities exist, copy `.env.example` to the ignored `.env.local` and select the development diff --git a/SKETCHYBAR.md b/SKETCHYBAR.md index 471470e..c5e2393 100644 --- a/SKETCHYBAR.md +++ b/SKETCHYBAR.md @@ -23,8 +23,8 @@ Source the integration near the end of `~/.config/sketchybar/sketchybarrc`: source "$CONFIG_DIR/defi.sh" ``` -Reload SketchyBar. The setup creates one item for every Defi workspace on every -display. A hidden observer reconciles items after display connection or +Reload SketchyBar. The setup creates one item for every workspace currently +owned by each display. A hidden observer reconciles dynamic workspaces after display connection or disconnection. Clicking an item targets that display, including when another display currently owns focus. @@ -73,11 +73,13 @@ The versioned JSON contains: - focused monitor ID; - AppKit/SketchyBar display index; - active workspace per display; +- stable workspace ID, 1-based position, optional name, and `named`, `ordinary`, + or `trailing` kind; - window count and occupied state; - bundle identifiers present on every workspace; - focused application per active workspace. -`defi list-workspaces` without `--json` prints configured workspace names once. +`defi list-workspaces` without `--json` prints current labels per display. Distributed notifications contain the same JSON object in SketchyBar's `$INFO` variable. Defi emits only when this state changes. diff --git a/Sources/DefiConfig/Config.swift b/Sources/DefiConfig/Config.swift index 20165bd..cd3a536 100644 --- a/Sources/DefiConfig/Config.swift +++ b/Sources/DefiConfig/Config.swift @@ -121,6 +121,11 @@ public struct Config: Equatable, Sendable { guard overview.zoom.isFinite, (0...0.75).contains(overview.zoom) else { throw ConfigError.invalidValue("overview.zoom") } + guard overview.windowCornerRadius.isFinite, + (0...64).contains(overview.windowCornerRadius) + else { + throw ConfigError.invalidValue("overview.window_corner_radius") + } guard (0...64).contains(decorations.borders.width) else { throw ConfigError.invalidValue("decorations.borders.width") } @@ -133,13 +138,27 @@ public struct Config: Equatable, Sendable { guard ["inside", "outside"].contains(decorations.borders.placement) else { throw ConfigError.invalidValue("decorations.borders.placement") } - guard !workspaces.names.isEmpty, - Set(workspaces.names).count == workspaces.names.count + guard Set(workspaces.names).count == workspaces.names.count, + workspaces.names.allSatisfy({ name in + !name.isEmpty + && !name.hasPrefix(WorkspaceID.dynamicPrefix) + && name.allSatisfy { !$0.isWhitespace } + }) else { throw ConfigError.invalidWorkspaces } - guard workspaces.names.contains(workspaces.defaultName) else { - throw ConfigError.unknownWorkspace(workspaces.defaultName) + if let defaultName = workspaces.defaultName, + !workspaces.names.contains(defaultName) + { + throw ConfigError.unknownWorkspace(defaultName) + } + for (name, monitor) in workspaces.monitors { + guard workspaces.names.contains(name) else { + throw ConfigError.unknownWorkspace(name) + } + guard monitor > 0 else { + throw ConfigError.invalidValue("workspaces.monitors.\(name)") + } } for (_, command) in keys { @@ -182,17 +201,21 @@ public struct Config: Equatable, Sendable { } private func validateCommandWorkspace(_ command: Command) throws { - let workspace: WorkspaceID? + let workspaceName: String? switch command { case .switchWorkspace(let value), .moveWindowToWorkspace(let value), .sendWindowToWorkspace(let value): - workspace = value + workspaceName = value.rawValue + case .focusWorkspace(.named(let value)), + .moveColumnToWorkspace(.named(let value), follow: _), + .moveWindowToWorkspaceTarget(.named(let value), follow: _): + workspaceName = value default: - workspace = nil + workspaceName = nil } - if let workspace, !workspaces.names.contains(workspace.rawValue) { - throw ConfigError.unknownWorkspace(workspace.rawValue) + if let workspaceName, !workspaces.names.contains(workspaceName) { + throw ConfigError.unknownWorkspace(workspaceName) } } @@ -203,20 +226,28 @@ public struct Config: Equatable, Sendable { var result = [ "\(modifier)-left": "focus-column left", "\(modifier)-right": "focus-column right", - "\(modifier)-up": "focus-window up", - "\(modifier)-down": "focus-window down", + "\(modifier)-up": "focus-workspace up", + "\(modifier)-down": "focus-workspace down", + "\(modifier)-j": "focus-window down", + "\(modifier)-k": "focus-window up", "\(modifier)-leftbracket": "focus-column first", "\(modifier)-rightbracket": "focus-column last", "\(modifier)-shift-left": "move-column left", "\(modifier)-shift-right": "move-column right", - "\(modifier)-shift-up": "move-window up", - "\(modifier)-shift-down": "move-window down", + "\(modifier)-shift-up": "move-column-to-workspace up", + "\(modifier)-shift-down": "move-column-to-workspace down", + "\(modifier)-shift-j": "move-window down", + "\(modifier)-shift-k": "move-window up", "\(modifier)-shift-leftbracket": "move-column first", "\(modifier)-shift-rightbracket": "move-column last", - "\(modifier)-shift-h": "move-column-to-monitor left", - "\(modifier)-shift-j": "move-column-to-monitor down", - "\(modifier)-shift-k": "move-column-to-monitor up", - "\(modifier)-shift-l": "move-column-to-monitor right", + "ctrl-cmd-left": "focus-monitor left", + "ctrl-cmd-right": "focus-monitor right", + "ctrl-cmd-up": "focus-monitor up", + "ctrl-cmd-down": "focus-monitor down", + "ctrl-cmd-shift-left": "move-column-to-monitor left", + "ctrl-cmd-shift-right": "move-column-to-monitor right", + "ctrl-cmd-shift-up": "move-column-to-monitor up", + "ctrl-cmd-shift-down": "move-column-to-monitor down", "\(modifier)-minus": "cycle-width previous", "\(modifier)-equal": "cycle-width next", "\(modifier)-f": "maximize-column", @@ -229,10 +260,17 @@ public struct Config: Equatable, Sendable { "\(modifier)-r": "unjoin-windows", "\(modifier)-o": "toggle-overview", ] - for (index, workspace) in workspaceNames.prefix(9).enumerated() { - let number = index + 1 + for number in 1...9 { + if workspaceNames.indices.contains(number - 1) { + let workspace = workspaceNames[number - 1] result["\(modifier)-\(number)"] = "workspace \(workspace)" - result["\(modifier)-shift-\(number)"] = "move-window-to-workspace \(workspace)" + result["\(modifier)-shift-\(number)"] = + "move-column-to-workspace-name \(workspace)" + } else { + result["\(modifier)-\(number)"] = "focus-workspace-position \(number)" + result["\(modifier)-shift-\(number)"] = + "move-column-to-workspace-position \(number)" + } } return result } @@ -247,7 +285,8 @@ public enum ConfigError: Error, Equatable, CustomStringConvertible, Sendable { public var description: String { switch self { case .invalidValue(let key): "invalid value: \(key)" - case .invalidWorkspaces: "workspace names must be non-empty and unique" + case .invalidWorkspaces: + "workspace names must be unique, contain no whitespace, and not use Defi's reserved prefix" case .unknownWorkspace(let name): "unknown workspace: \(name)" case .invalidCommand(let command): "invalid command: \(command)" } diff --git a/Sources/DefiConfig/ConfigModels.swift b/Sources/DefiConfig/ConfigModels.swift index 0dbd15c..a71741b 100644 --- a/Sources/DefiConfig/ConfigModels.swift +++ b/Sources/DefiConfig/ConfigModels.swift @@ -157,15 +157,22 @@ public struct AnimationConfig: Codable, Equatable, Sendable { public struct OverviewConfig: Codable, Equatable, Sendable { public var zoom: Double public var windowPreviews: Bool + public var windowCornerRadius: Double - public init(zoom: Double = 0.5, windowPreviews: Bool = false) { + public init( + zoom: Double = 0.5, + windowPreviews: Bool = false, + windowCornerRadius: Double = 12 + ) { self.zoom = zoom self.windowPreviews = windowPreviews + self.windowCornerRadius = windowCornerRadius } enum CodingKeys: String, CodingKey { case zoom case windowPreviews = "window_previews" + case windowCornerRadius = "window_corner_radius" } public init(from decoder: Decoder) throws { @@ -173,6 +180,8 @@ public struct OverviewConfig: Codable, Equatable, Sendable { zoom = try values.decodeIfPresent(Double.self, forKey: .zoom) ?? 0.5 windowPreviews = try values.decodeIfPresent(Bool.self, forKey: .windowPreviews) ?? false + windowCornerRadius = + try values.decodeIfPresent(Double.self, forKey: .windowCornerRadius) ?? 12 } } @@ -271,30 +280,36 @@ public enum CenterFocusedColumnConfig: String, Codable, Sendable { public struct WorkspacesConfig: Codable, Equatable, Sendable { public var names: [String] - public var defaultName: String + public var defaultName: String? + public var monitors: [String: Int] public init( - names: [String] = (1...9).map(String.init), - defaultName: String? = nil + names: [String] = [], + defaultName: String? = nil, + monitors: [String: Int] = [:] ) { self.names = names - self.defaultName = defaultName ?? names.first ?? "1" + self.defaultName = defaultName ?? names.first + self.monitors = monitors } enum CodingKeys: String, CodingKey { case names case defaultName = "default" + case monitors } public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) names = try values.decodeIfPresent([String].self, forKey: .names) - ?? (1...9).map(String.init) + ?? [] defaultName = try values.decodeIfPresent(String.self, forKey: .defaultName) ?? names.first - ?? "1" + monitors = + try values.decodeIfPresent([String: Int].self, forKey: .monitors) + ?? [:] } } diff --git a/Sources/DefiCore/Animation.swift b/Sources/DefiCore/Animation.swift index 2d66b2b..88c24e4 100644 --- a/Sources/DefiCore/Animation.swift +++ b/Sources/DefiCore/Animation.swift @@ -33,38 +33,6 @@ public struct SpringProgressSample: Equatable, Sendable { } } -public func springProgressSample( - elapsed: TimeInterval, - duration: TimeInterval, - initialVelocity: Double = 0, - minimumProgress: Double = 0 -) -> SpringProgressSample { - guard duration > 0 else { - return SpringProgressSample(progress: 1, velocity: 0) - } - let response = max(duration * 1.5, 0.04) - let clampedInitialVelocity = min(max(initialVelocity, 0), 6 / response) - let elapsed = max(elapsed, 0) - guard elapsed > 0 else { - return SpringProgressSample( - progress: min(max(minimumProgress, 0), 1), - velocity: clampedInitialVelocity - ) - } - let step = criticallyDampedSpringStep( - value: 0, - target: 1, - velocity: clampedInitialVelocity, - deltaTime: elapsed, - response: response - ) - let progress = min(max(step.value, minimumProgress), 1) - return SpringProgressSample( - progress: progress, - velocity: progress > minimumProgress ? max(step.velocity, 0) : 0 - ) -} - public func criticallyDampedSpringStep( value: Double, target: Double, @@ -132,17 +100,6 @@ public func retainedSpringProgressVelocity( return min(valid[valid.count / 2], max(maximum, 0)) } -public func shouldEmitAnotherIntermediateFrame( - elapsed: TimeInterval, - predictedFrameLatency: TimeInterval, - budget: TimeInterval, - completedIntermediateFrames: Int -) -> Bool { - guard elapsed < budget else { return false } - guard completedIntermediateFrames > 0 else { return true } - return elapsed + max(predictedFrameLatency, 0) < budget -} - public func adaptiveIntermediateFrameLimit( predictedFrameLatency: TimeInterval, refreshRateHz: Double, @@ -150,54 +107,17 @@ public func adaptiveIntermediateFrameLimit( ) -> Int { guard availableIntermediateFrames > 0 else { return 0 } let latency = max(predictedFrameLatency, 0) - if latency >= 0.025 { - return 0 - } let refreshRate = min(max(refreshRateHz, 30), 120) if latency >= 1 / refreshRate { - return 1 + let budget = Double(availableIntermediateFrames) / refreshRate + return min( + max(Int(floor(budget / latency)) - 1, 0), + availableIntermediateFrames + ) } return availableIntermediateFrames } -public func anticipatedSpringProgressIndex( - predictedFrameLatency: TimeInterval, - refreshRateHz: Double, - availableIntermediateFrames: Int, - minimumIndex: Int = 0, - maximumIndex: Int? = nil -) -> Int? { - guard availableIntermediateFrames > 0 else { return nil } - let refreshRate = min(max(refreshRateHz, 30), 120) - let interval = 1 / refreshRate - let completedIntervals = max( - Int(ceil(max(predictedFrameLatency, interval) / interval)), - 1 - ) - let anticipated = min( - max(completedIntervals - 1, minimumIndex), - availableIntermediateFrames - 1 - ) - return min(anticipated, maximumIndex ?? anticipated) -} - -public func completedFrameSupportsAnotherSample( - duration: TimeInterval, - refreshRateHz: Double -) -> Bool { - let refreshRate = min(max(refreshRateHz, 30), 120) - let maximumDuration = max(1.5 / refreshRate, 0.012) - return max(duration, 0) < maximumDuration -} - -public func nextCompletedFrameDispatchDeadline( - completedAt: TimeInterval, - refreshRateHz: Double -) -> TimeInterval { - let refreshRate = min(max(refreshRateHz, 30), 120) - return completedAt + 1 / refreshRate -} - public func anticipatedFinalFrameDispatchDelay( animationDuration: TimeInterval, predictedFrameLatency: TimeInterval @@ -205,17 +125,6 @@ public func anticipatedFinalFrameDispatchDelay( max(animationDuration - max(predictedFrameLatency, 0), 0) } -public func finalFrameDispatchDeadline( - nominalDeadline: TimeInterval, - nextDisplayDeadline: TimeInterval, - previousFrameWasSlow: Bool, - hardDeadline: TimeInterval = .greatestFiniteMagnitude -) -> TimeInterval { - min( - max(nominalDeadline, nextDisplayDeadline), - hardDeadline - ) -} public func speculativeNavigationSettlementDelay( animationDuration: TimeInterval diff --git a/Sources/DefiCore/Overview.swift b/Sources/DefiCore/Overview.swift index 1abb4ed..fa67373 100644 --- a/Sources/DefiCore/Overview.swift +++ b/Sources/DefiCore/Overview.swift @@ -126,6 +126,7 @@ public func interpolateOverviewProjection( } return OverviewWorkspaceProjection( workspaceID: targetWorkspace.workspaceID, + label: targetWorkspace.label, frame: interpolateOverviewRect( from: sourceWorkspace.frame, to: targetWorkspace.frame, @@ -202,6 +203,7 @@ public struct OverviewProjection: Equatable, Sendable { public struct OverviewWorkspaceProjection: Equatable, Sendable { public let workspaceID: WorkspaceID + public let label: String public let frame: Rect public let windows: [OverviewWindowProjection] public let hiddenTiledWindowCountBefore: Int @@ -209,12 +211,14 @@ public struct OverviewWorkspaceProjection: Equatable, Sendable { public init( workspaceID: WorkspaceID, + label: String? = nil, frame: Rect, windows: [OverviewWindowProjection], hiddenTiledWindowCountBefore: Int = 0, hiddenTiledWindowCountAfter: Int = 0 ) { self.workspaceID = workspaceID + self.label = label ?? workspaceID.rawValue self.frame = frame self.windows = windows self.hiddenTiledWindowCountBefore = hiddenTiledWindowCountBefore @@ -414,6 +418,8 @@ public func projectOverview( projected.append( OverviewWorkspaceProjection( workspaceID: originalWorkspace.id, + label: originalWorkspace.name + ?? (originalWorkspace.kind == .trailing ? "+" : String(workspaceIndex + 1)), frame: workspaceFrame, windows: projectedWindows, hiddenTiledWindowCountBefore: hiddenTiledWindowCountBefore, diff --git a/Sources/DefiDaemon/DaemonAnimation.swift b/Sources/DefiDaemon/DaemonAnimation.swift index 1ff1acb..9f5aa9b 100644 --- a/Sources/DefiDaemon/DaemonAnimation.swift +++ b/Sources/DefiDaemon/DaemonAnimation.swift @@ -20,8 +20,154 @@ struct ScrollAnimation { var startedAt: TimeInterval } +func workspaceTransitionPathIsClear( + ownerFrame: Rect, + otherMonitorFrames: [Rect] +) -> Bool { + let envelope = Rect( + x: ownerFrame.x, + y: ownerFrame.y - ownerFrame.height, + width: ownerFrame.width, + height: ownerFrame.height * 3 + ) + return otherMonitorFrames.allSatisfy { other in + envelope.x + envelope.width <= other.x + || other.x + other.width <= envelope.x + || envelope.y + envelope.height <= other.y + || other.y + other.height <= envelope.y + } +} + +func workspaceVerticalTransitionDuration( + configuredDurationMS: Int +) -> TimeInterval { + guard configuredDurationMS > 0 else { return 0 } + return max(TimeInterval(configuredDurationMS) / 1_000, 0.18) +} + +func workspaceVerticalTransitionCanAnimateWithoutReservedAreaLeak( + viewport: Rect, + physicalFrame: Rect +) -> Bool { + viewport.y - physicalFrame.y <= 0.5 + && physicalFrame.y + physicalFrame.height + - (viewport.y + viewport.height) <= 0.5 +} + +func workspaceVerticalRibbonOffset( + relativePosition: Int, + physicalFrame: Rect +) -> Double { + Double(relativePosition) * physicalFrame.height +} + +func outgoingWorkspaceVerticalRibbonOffset( + workspaceID: WorkspaceID, + monitorID: MonitorID, + transition: WorkspaceVerticalTransition?, + physicalFrame: Rect +) -> Double? { + guard transition?.monitorID == monitorID, + transition?.outgoingWorkspaceID == workspaceID + else { return nil } + return workspaceVerticalRibbonOffset( + relativePosition: -(transition?.direction ?? 0), + physicalFrame: physicalFrame + ) +} + @MainActor extension Daemon { + func safeWorkspaceVerticalTransition( + _ intent: WorkspaceTransitionIntent + ) -> WorkspaceVerticalTransition? { + let duration = workspaceVerticalTransitionDuration( + configuredDurationMS: config.animation.durationMS + ) + guard config.animation.enabled, duration > 0, + pendingDisplaySyncDeadlines.isEmpty, + latestMonitors.count == state.monitors.count, + let monitor = state.monitors.first(where: { $0.id == intent.monitorID }), + let outgoing = monitor.workspaces.first(where: { + $0.id == intent.outgoingWorkspaceID + }), + let incoming = monitor.workspaces.first(where: { + $0.id == intent.incomingWorkspaceID + }), + let ownerFrame = latestMonitors.first(where: { + $0.id == intent.monitorID + })?.physicalFrame, + let viewport = viewportsByMonitor[intent.monitorID], + workspaceVerticalTransitionCanAnimateWithoutReservedAreaLeak( + viewport: viewport, + physicalFrame: ownerFrame + ) + else { return nil } + let outgoingWindowIDs = Set( + outgoing.columns.flatMap(\.windows) + outgoing.floatingWindows + ) + let participantWindowIDs = outgoingWindowIDs.union( + incoming.columns.flatMap(\.windows) + incoming.floatingWindows + ) + guard !outgoingWindowIDs.isEmpty, + participantWindowIDs.isSubset(of: platform.frameWritableWindowIDs), + platform.positionsCanAnimateTogether( + windowIDs: participantWindowIDs, + animationDuration: duration, + refreshRateHz: activeDisplayRefreshRate + ), + participantWindowIDs.isDisjoint(with: state.nativeFullscreenWindowIDs), + workspaceTransitionPathIsClear( + ownerFrame: ownerFrame, + otherMonitorFrames: latestMonitors.compactMap { + $0.id == intent.monitorID ? nil : $0.physicalFrame + } + ) + else { return nil } + return WorkspaceVerticalTransition( + monitorID: intent.monitorID, + outgoingWorkspaceID: intent.outgoingWorkspaceID, + direction: intent.direction + ) + } + + func dispatchWorkspaceVerticalTransition( + _ transition: WorkspaceVerticalTransition, + affectedMonitorIDs: Set, + focusWindowIDAfterCommit: WindowID?, + focusInputTimestampAfterCommit: TimeInterval?, + cursorWarpInputTimestampAfterCommit: TimeInterval?, + focusCompletionAfterCommit: + (@MainActor @Sendable (NativeFocusResult) -> Void)?, + cursorWarpIsCurrentAfterCommit: (@MainActor @Sendable () -> Bool)?, + focusRequestIDAfterCommit: + (@MainActor @Sendable (NativeFocusRequestID?) -> Void)?, + commandPerformance: CommandPerformanceContext + ) { + let duration = workspaceVerticalTransitionDuration( + configuredDurationMS: config.animation.durationMS + ) + beginFrameAnimationActivity() + applyCurrentLayout( + monitorIDs: affectedMonitorIDs, + asynchronousPositions: true, + updateVisibility: true, + positionTimeoutSeconds: 0.05, + animationDuration: duration, + positionsOnly: true, + focusWindowIDAfterCommit: focusWindowIDAfterCommit, + focusInputTimestampAfterCommit: focusInputTimestampAfterCommit, + cursorWarpInputTimestampAfterCommit: cursorWarpInputTimestampAfterCommit, + focusCompletionAfterCommit: focusCompletionAfterCommit, + cursorWarpIsCurrentAfterCommit: cursorWarpIsCurrentAfterCommit, + focusRequestIDAfterCommit: focusRequestIDAfterCommit, + workspaceTransition: transition, + commandPerformance: commandPerformance, + source: "workspace-transition" + ) + needsDesktopSync = true + } + func startScrollAnimationsIfNeeded() { let duration = TimeInterval(config.animation.durationMS) / 1_000 let now = ProcessInfo.processInfo.systemUptime diff --git a/Sources/DefiDaemon/DaemonCommands.swift b/Sources/DefiDaemon/DaemonCommands.swift index 7ec4408..257fc1d 100644 --- a/Sources/DefiDaemon/DaemonCommands.swift +++ b/Sources/DefiDaemon/DaemonCommands.swift @@ -82,10 +82,60 @@ func crossMonitorCommandWindowID( selectedWindowID: WindowID?, selectedTiledWindowID: WindowID? ) -> WindowID? { - if case .moveColumnToMonitor = command { return selectedTiledWindowID } + switch command { + case .moveColumnToMonitor, .moveColumnToWorkspace: + return selectedTiledWindowID + default: + break + } return selectedWindowID } +func workspaceTargetID( + for command: Command, + on monitorID: MonitorID?, + state: RuntimeState +) -> WorkspaceID? { + guard + let monitorIndex = monitorID.flatMap({ id in + state.monitors.firstIndex(where: { $0.id == id }) + }) ?? state.monitors.indices.first + else { return nil } + switch command { + case .switchWorkspace(let id), .moveWindowToWorkspace(let id): + return id + case .focusWorkspace(let target), + .moveColumnToWorkspace(let target, follow: true), + .moveWindowToWorkspaceTarget(let target, follow: true): + return state.resolveWorkspaceTarget(target, on: monitorIndex).map { + state.monitors[$0.monitorIndex].workspaces[$0.workspaceIndex].id + } + case .moveWorkspaceToMonitor: + return state.monitors[monitorIndex].activeWorkspace + default: + return nil + } +} + +func workspaceTransitionIntent( + targetWorkspaceID: WorkspaceID?, + state: RuntimeState +) -> WorkspaceTransitionIntent? { + guard let targetWorkspaceID, + let target = state.workspaceLocation(for: targetWorkspaceID), + let sourceIndex = state.monitors[target.monitorIndex].workspaces.firstIndex(where: { + $0.id == state.monitors[target.monitorIndex].activeWorkspace + }), + sourceIndex != target.workspaceIndex + else { return nil } + return WorkspaceTransitionIntent( + monitorID: state.monitors[target.monitorIndex].id, + outgoingWorkspaceID: state.monitors[target.monitorIndex].activeWorkspace, + incomingWorkspaceID: targetWorkspaceID, + direction: target.workspaceIndex > sourceIndex ? 1 : -1 + ) +} + func commandDiagnosticMetadata( command: String, generation: UInt64, @@ -178,7 +228,14 @@ extension Daemon { inputTimestamp: TimeInterval? = nil ) -> CommandResponse { if rawCommand == "list-workspaces" { - return .success(state.workspaceNames.map(\.rawValue).joined(separator: "\n")) + let lines = currentWorkspaceState().monitors.map { monitor in + let labels = monitor.workspaces.map { workspace in + workspace.name + ?? (workspace.kind == .trailing ? "+" : String(workspace.position)) + } + return "\(monitor.display): \(labels.joined(separator: " "))" + } + return .success(lines.joined(separator: "\n")) } if rawCommand == "list-workspaces --json" { do { @@ -235,11 +292,50 @@ extension Daemon { uniqueKeysWithValues: latestMonitors.map { ($0.id, $0.physicalFrame) } ) let commandViewports = viewportsByMonitor + if case .focusMonitor(let direction) = command { + guard let sourceMonitorID = commandMonitorID, + let targetMonitorID = spatialMonitor( + from: sourceMonitorID, + toward: direction, + frames: physicalMonitorFrames + ) + else { return .success() } + commandGeneration &+= 1 + latestCommandInputTimestamp = max( + latestCommandInputTimestamp, + commandInputTimestamp + ) + suppressNativeFocusUntil = commandStartedAt + 0.25 + invalidatePointerFocusIntent( + recoveringTo: state.selectedWindowID(on: targetMonitorID) + ) + preemptMouseGesture() + activeMonitorID = targetMonitorID + if let windowID = state.selectedWindowID(on: targetMonitorID) { + _ = platform.focus( + windowID, + unlessUserInputAfter: commandInputTimestamp, + allowsNativeFullscreen: true + ) + } + updateMenuBar() + return .success() + } + let intendedWorkspaceID = workspaceTargetID( + for: command, + on: commandMonitorID, + state: state + ) + let verticalTransitionIntent = workspaceTransitionIntent( + targetWorkspaceID: intendedWorkspaceID, + state: state + ) // When frames are already in flight the reducer must run on the live // state after the displayed-frame rebase, so skip the validation copy. let rebasesPendingFrame = !scrollAnimations.isEmpty || platform.hasPendingAnimatedFrameWrites - let validationState = try rebasesPendingFrame + let validationState = + try rebasesPendingFrame ? nil : changedState( after: command, @@ -248,7 +344,11 @@ extension Daemon { monitorFrames: physicalMonitorFrames, viewports: commandViewports ) - if validationState == nil, command.explicitlyFocusesFloating == false { + if commandValidationIsNoOp( + hasValidationState: validationState != nil, + rebasesPendingFrame: rebasesPendingFrame, + explicitlyFocusesFloating: command.explicitlyFocusesFloating + ) { commandGeneration &+= 1 lastCommandDurationMS = (ProcessInfo.processInfo.systemUptime - commandStartedAt) * 1_000 @@ -332,7 +432,13 @@ extension Daemon { && config.animation.enabled && config.animation.durationMS > 0 let previousWorkspaceID = commandMonitorID.flatMap { monitorID in - state.monitors.first(where: { $0.id == monitorID })?.activeWorkspace + let activationMonitorID = + intendedWorkspaceID.flatMap { + state.workspaceLocation(for: $0).map { state.monitors[$0.monitorIndex].id } + } ?? monitorID + return state.monitors.first(where: { + $0.id == activationMonitorID + })?.activeWorkspace } let preCommandWindowMonitorIDs = state.windowLocationMap() let inFlightAnimationMonitorIDs = Set( @@ -355,7 +461,8 @@ extension Daemon { invalidateSubmittedWorkspaceFocus() pendingWorkspaceFocus = nil } - let previousWindowMonitorIDs = movesAcrossMonitors + let previousWindowMonitorIDs = + movesAcrossMonitors ? monitorIDsByWindow(preCommandWindowMonitorIDs) : [:] if rebasesPendingFrame { @@ -370,20 +477,28 @@ extension Daemon { state = validationState } let nextWindowMonitorIDsMap = state.windowLocationMap() - let resultMonitorID = movesAcrossMonitors + let resultMonitorID = + intendedWorkspaceID.flatMap { + state.workspaceLocation(for: $0).map { state.monitors[$0.monitorIndex].id } + } + ?? (movesAcrossMonitors && command.followsWindowMove ? crossMonitorWindowID.flatMap { nextWindowMonitorIDsMap[$0]?.monitorID } ?? commandMonitorID - : commandMonitorID - let nextWindowMonitorIDs = movesAcrossMonitors + : commandMonitorID) + let nextWindowMonitorIDs = + movesAcrossMonitors ? monitorIDsByWindow(nextWindowMonitorIDsMap) : [:] + let movedBetweenMonitors = previousWindowMonitorIDs.contains { + nextWindowMonitorIDs[$0.key] != $0.value + } + let focusBearingMonitorMove = movedBetweenMonitors && command.followsWindowMove let movedFloatingWindowIDs = floatingWindowIDsMovedBetweenMonitors( previousWindowMonitorIDs: previousWindowMonitorIDs, nextWindowMonitorIDs: nextWindowMonitorIDs, windows: state.windows ) if movesAcrossMonitors { - activeMonitorID = resultMonitorID rebaseFloatingWindowFrames( previousViewports: commandViewports, nextViewports: commandViewports, @@ -419,7 +534,7 @@ extension Daemon { selectedFloatingWindowID: state.selectedFloatingWindowID( on: resultMonitorID ), - movesAcrossMonitors: movesAcrossMonitors + movesAcrossMonitors: focusBearingMonitorMove ) } else { commandTransfersFocus = false @@ -498,11 +613,11 @@ extension Daemon { ) let workspaceFocusRequest: PendingWorkspaceFocus? if switchesWorkspace, - let commandMonitorID, + let focusMonitorID = resultMonitorID, let requestedWorkspaceID = state.monitors.first(where: { - $0.id == commandMonitorID + $0.id == focusMonitorID })?.activeWorkspace, - let requestedWindowID = state.selectedWindowID(on: commandMonitorID) + let requestedWindowID = state.selectedWindowID(on: focusMonitorID) { let restoresPreviousWorkspaceOnCancellation: Bool if case .switchWorkspace = command { @@ -511,7 +626,7 @@ extension Daemon { restoresPreviousWorkspaceOnCancellation = false } workspaceFocusRequest = PendingWorkspaceFocus( - monitorID: commandMonitorID, + monitorID: focusMonitorID, requestedWorkspaceID: requestedWorkspaceID, previousWorkspaceID: previousWorkspaceID, requestedWindowID: requestedWindowID, @@ -588,26 +703,45 @@ extension Daemon { cursorWarpIsCurrentAfterCommit = nil focusRequestIDAfterCommit = nil } - applyCurrentLayout( - monitorIDs: affectedMonitorIDs, - asynchronousPositions: true, - updateVisibility: scrollAnimations.isEmpty, - positionTimeoutSeconds: scrollAnimations.isEmpty ? 0.05 : 0.016, - positionsOnly: speculativeRibbonNavigation, - stagesVisibleBeforeParking: switchesWorkspace, - focusWindowIDAfterCommit: focusWindowIDAfterCommit, - focusInputTimestampAfterCommit: - workspaceFocusRequest?.focusInputTimestamp, - cursorWarpInputTimestampAfterCommit: - workspaceFocusRequest?.cursorWarpInputTimestamp, - focusCompletionAfterCommit: focusCompletionAfterCommit, - cursorWarpIsCurrentAfterCommit: - cursorWarpIsCurrentAfterCommit, - focusRequestIDAfterCommit: focusRequestIDAfterCommit, - forcingFloatingFrameWritesFor: movedFloatingWindowIDs, - commandPerformance: commandPerformance, - source: switchesWorkspace ? "workspace-command" : "command" - ) + if switchesWorkspace, !movedBetweenMonitors, + let verticalTransitionIntent, + let transition = safeWorkspaceVerticalTransition(verticalTransitionIntent) + { + dispatchWorkspaceVerticalTransition( + transition, + affectedMonitorIDs: affectedMonitorIDs, + focusWindowIDAfterCommit: focusWindowIDAfterCommit, + focusInputTimestampAfterCommit: + workspaceFocusRequest?.focusInputTimestamp, + cursorWarpInputTimestampAfterCommit: + workspaceFocusRequest?.cursorWarpInputTimestamp, + focusCompletionAfterCommit: focusCompletionAfterCommit, + cursorWarpIsCurrentAfterCommit: cursorWarpIsCurrentAfterCommit, + focusRequestIDAfterCommit: focusRequestIDAfterCommit, + commandPerformance: commandPerformance + ) + } else { + applyCurrentLayout( + monitorIDs: affectedMonitorIDs, + asynchronousPositions: true, + updateVisibility: scrollAnimations.isEmpty, + positionTimeoutSeconds: scrollAnimations.isEmpty ? 0.05 : 0.016, + positionsOnly: speculativeRibbonNavigation, + stagesVisibleBeforeParking: switchesWorkspace, + focusWindowIDAfterCommit: focusWindowIDAfterCommit, + focusInputTimestampAfterCommit: + workspaceFocusRequest?.focusInputTimestamp, + cursorWarpInputTimestampAfterCommit: + workspaceFocusRequest?.cursorWarpInputTimestamp, + focusCompletionAfterCommit: focusCompletionAfterCommit, + cursorWarpIsCurrentAfterCommit: + cursorWarpIsCurrentAfterCommit, + focusRequestIDAfterCommit: focusRequestIDAfterCommit, + forcingFloatingFrameWritesFor: movedFloatingWindowIDs, + commandPerformance: commandPerformance, + source: switchesWorkspace ? "workspace-command" : "command" + ) + } } if !switchesWorkspace, let monitorID = resultMonitorID ?? state.monitors.first?.id, @@ -618,7 +752,7 @@ extension Daemon { previousSelectedWindowID: previouslySelectedWindowID, selectedWindowID: selected, selectedFloatingWindowID: state.selectedFloatingWindowID(on: monitorID), - movesAcrossMonitors: movesAcrossMonitors + movesAcrossMonitors: focusBearingMonitorMove ) { if focusIsReady(on: monitorID, targetWindowID: selected) { @@ -668,7 +802,7 @@ extension Daemon { cursorWarpInputTimestamp: cursorWarpInputTimestamp ) } - if movesAcrossMonitors, + if focusBearingMonitorMove, let selectedWindowID = resultMonitorID.flatMap({ state.selectedWindowID(on: $0) }), platform.isWindowNativelyFocused(selectedWindowID) { @@ -706,6 +840,14 @@ func commandLayoutMonitorIDs( affected.union(inFlightAnimations) } +func commandValidationIsNoOp( + hasValidationState: Bool, + rebasesPendingFrame: Bool, + explicitlyFocusesFloating: Bool +) -> Bool { + !hasValidationState && !rebasesPendingFrame && !explicitlyFocusesFloating +} + func affectedMonitorIDsForWindowMove( commandMonitorID: MonitorID?, resultMonitorID: MonitorID?, @@ -728,7 +870,8 @@ func floatingWindowIDsMovedBetweenMonitors( nextWindowMonitorIDs: [WindowID: MonitorID], windows: [WindowID: Window] ) -> Set { - Set(previousWindowMonitorIDs.compactMap { windowID, previousMonitorID in + Set( + previousWindowMonitorIDs.compactMap { windowID, previousMonitorID in guard nextWindowMonitorIDs[windowID] != previousMonitorID, windows[windowID]?.floating == true else { return nil } diff --git a/Sources/DefiDaemon/DaemonDesktop.swift b/Sources/DefiDaemon/DaemonDesktop.swift index 50b7bcd..1b020c0 100644 --- a/Sources/DefiDaemon/DaemonDesktop.swift +++ b/Sources/DefiDaemon/DaemonDesktop.swift @@ -62,6 +62,7 @@ extension Daemon { (@MainActor @Sendable (NativeFocusRequestID?) -> Void)? = nil, forceFloatingFrameWrites: Bool = false, forcingFloatingFrameWritesFor forcedFloatingWindowIDs: Set = [], + workspaceTransition: WorkspaceVerticalTransition? = nil, commandPerformance: CommandPerformanceContext? = nil, source: String = "layout" ) { @@ -130,7 +131,9 @@ extension Daemon { ) monitorAssignments.append(contentsOf: strip.frames) monitorBorderAssignments.append(contentsOf: strip.frames) - monitorHiddenWindowIDs.formUnion(strip.parkedWindowIDs) + if workspaceTransition?.monitorID != monitor.id { + monitorHiddenWindowIDs.formUnion(strip.parkedWindowIDs) + } if !state.nativeFullscreenWindowIDs.isEmpty { let fullscreenStrip = continuousStripFramesForActiveWorkspace( @@ -163,6 +166,26 @@ extension Daemon { monitorAssignments.append(assignment) } } + } else if !overviewParksWindows, + let deltaY = outgoingWorkspaceVerticalRibbonOffset( + workspaceID: workspace.id, + monitorID: monitor.id, + transition: workspaceTransition, + physicalFrame: physicalFrame + ) + { + let strip = continuousStripFramesForActiveWorkspace( + sizedFrames, + viewport: viewport, + ownerFrame: physicalFrame, + parkingFrame: viewport, + allMonitorFrames: allPhysicalMonitorFrames + ) + let leaving = (strip.frames + floatingAssignments(in: workspace)).map { + translatedAssignment($0, deltaY: deltaY) + } + monitorAssignments.append(contentsOf: leaving) + monitorBorderAssignments.append(contentsOf: leaving) } else { monitorHiddenWindowIDs.formUnion(sizedFrames.map(\.windowID)) let floatingFrames = floatingAssignments(in: workspace) @@ -314,4 +337,13 @@ extension Daemon { ) } } + + private func translatedAssignment( + _ assignment: FrameAssignment, + deltaY: Double + ) -> FrameAssignment { + var frame = assignment.frame + frame.y += deltaY + return FrameAssignment(windowID: assignment.windowID, frame: frame) + } } diff --git a/Sources/DefiDaemon/DaemonDesktopReconciliation.swift b/Sources/DefiDaemon/DaemonDesktopReconciliation.swift index 9234cda..51bdb0c 100644 --- a/Sources/DefiDaemon/DaemonDesktopReconciliation.swift +++ b/Sources/DefiDaemon/DaemonDesktopReconciliation.swift @@ -31,6 +31,7 @@ extension Daemon { } func persistPlacements() { + persistTopology() var updated = placementPreferences updated.recordPlacements(from: state) guard placementPreferencesDirty || updated != placementPreferences else { return } @@ -39,6 +40,39 @@ extension Daemon { schedulePlacementStoreWrite(updated) } + func persistTopology() { + let topology = state.topology + guard topology != lastPersistedTopology else { return } + lastPersistedTopology = topology + topologySaveWorkItem?.cancel() + let sessionID = topologySessionID + let operation: @Sendable () -> Void = { [weak self] in + self?.writeTopologyStore(topology, sessionID: sessionID) + } + let item = DispatchWorkItem(block: operation) + topologySaveWorkItem = item + placementSaveQueue.asyncAfter( + deadline: .now() + Self.placementSaveDebounce, + execute: item + ) + } + + nonisolated private func writeTopologyStore( + _ topology: WorkspaceTopology, + sessionID: String + ) { + do { + try topologyStore.save(topology, sessionID: sessionID) + } catch { + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.lastPersistedTopology = nil + self?.log("workspace topology persistence failed: \(error)") + } + } + } + } + static let placementSaveDebounce: TimeInterval = 0.5 func schedulePlacementStoreWrite(_ preferences: PlacementPreferences) { @@ -83,6 +117,19 @@ extension Daemon { } } + func flushPendingTopologyWrite() { + guard topologySaveWorkItem != nil else { return } + topologySaveWorkItem?.cancel() + topologySaveWorkItem = nil + do { + try placementSaveQueue.sync { + try topologyStore.save(state.topology, sessionID: topologySessionID) + } + } catch { + log("workspace topology persistence failed: \(error)") + } + } + func consumeDeferredMouseFocusIntent() { if let timestamp = deferredMouseFocusIntent?.timestamp { consumedMouseFocusIntentTimestamp = max( diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index ac1592b..f13a7a3 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -19,6 +19,18 @@ func shouldCloseOverviewAfterNativeFocusChange( > overviewOpenedAt } +func desktopSnapshotWaitsForCommandAnimation( + animationPending: Bool, + latestCommandInputTimestamp: TimeInterval, + mouseFocusIntentTimestamp: TimeInterval?, + keyboardFocusIntentTimestamp: TimeInterval?, + mouseGestureActive: Bool = false +) -> Bool { + guard animationPending, !mouseGestureActive else { return false } + return max(mouseFocusIntentTimestamp ?? 0, keyboardFocusIntentTimestamp ?? 0) + <= latestCommandInputTimestamp +} + @MainActor extension Daemon { func synchronizeDesktop( @@ -669,26 +681,36 @@ extension Daemon { } else { nativeCursorWarpIsCurrentAfterCommit = nil } - applyCurrentLayout( - asynchronousPositions: true, - updateVisibility: true, - positionTimeoutSeconds: 0.05, - animationDuration: animatesMouseReorder - ? TimeInterval(config.animation.durationMS) / 1_000 - : 0, - skipping: nativeFocusSkippedWindowIDs, - positionsOnly: animatesMouseReorder, - stagesVisibleBeforeParking: nativelyActivatedWorkspace, - cursorWarpWindowIDAfterCommit: nativeCursorWarpWindowID, - cursorWarpInputTimestampAfterCommit: nativeCursorWarpInputTimestamp, - cursorWarpIsCurrentAfterCommit: - nativeCursorWarpIsCurrentAfterCommit, - forceFloatingFrameWrites: displayGeometryChanged, - forcingFloatingFrameWritesFor: relocatedFloatingWindowIDs, - source: nativelyActivatedWorkspace - ? "native-workspace" - : (animatesMouseReorder ? "mouse-reorder-animation" : "desktop-sync") - ) + if desktopSnapshotWaitsForCommandAnimation( + animationPending: platform.hasPendingAnimatedFrameWrites, + latestCommandInputTimestamp: latestCommandInputTimestamp, + mouseFocusIntentTimestamp: snapshot.mouseFocusIntentTimestamp, + keyboardFocusIntentTimestamp: snapshot.keyboardFocusIntentTimestamp, + mouseGestureActive: mouseResizeGestureActive + ) { + needsDesktopSync = true + } else { + applyCurrentLayout( + asynchronousPositions: true, + updateVisibility: true, + positionTimeoutSeconds: 0.05, + animationDuration: animatesMouseReorder + ? TimeInterval(config.animation.durationMS) / 1_000 + : 0, + skipping: nativeFocusSkippedWindowIDs, + positionsOnly: animatesMouseReorder, + stagesVisibleBeforeParking: nativelyActivatedWorkspace, + cursorWarpWindowIDAfterCommit: nativeCursorWarpWindowID, + cursorWarpInputTimestampAfterCommit: nativeCursorWarpInputTimestamp, + cursorWarpIsCurrentAfterCommit: + nativeCursorWarpIsCurrentAfterCommit, + forceFloatingFrameWrites: displayGeometryChanged, + forcingFloatingFrameWritesFor: relocatedFloatingWindowIDs, + source: nativelyActivatedWorkspace + ? "native-workspace" + : (animatesMouseReorder ? "mouse-reorder-animation" : "desktop-sync") + ) + } if let guardedRemovalFocus { platform.focus( guardedRemovalFocus.windowID, diff --git a/Sources/DefiDaemon/DaemonOverview.swift b/Sources/DefiDaemon/DaemonOverview.swift index 317f831..7295117 100644 --- a/Sources/DefiDaemon/DaemonOverview.swift +++ b/Sources/DefiDaemon/DaemonOverview.swift @@ -19,6 +19,7 @@ extension Daemon { borders: config.decorations.borders, animation: config.animation, zoom: config.overview.zoom, + windowCornerRadius: config.overview.windowCornerRadius, windowPreviewsEnabled: config.overview.windowPreviews ) return .success() @@ -32,6 +33,7 @@ extension Daemon { borders: config.decorations.borders, animation: config.animation, zoom: config.overview.zoom, + windowCornerRadius: config.overview.windowCornerRadius, windowPreviewsEnabled: config.overview.windowPreviews ) } diff --git a/Sources/DefiDaemon/DaemonStatusLifecycle.swift b/Sources/DefiDaemon/DaemonStatusLifecycle.swift index 56461fd..8b552d8 100644 --- a/Sources/DefiDaemon/DaemonStatusLifecycle.swift +++ b/Sources/DefiDaemon/DaemonStatusLifecycle.swift @@ -198,14 +198,20 @@ extension Daemon { } func updateMenuBar() { - let workspace = + let monitor = activeMonitorID .flatMap { id in state.monitors.first(where: { $0.id == id }) } - .map(\.activeWorkspace.rawValue) - ?? "" + ?? state.monitors.first + let workspace = monitor?.activeWorkspace.rawValue ?? "" menuBar?.update( activeWorkspace: workspace, - workspaceNames: state.workspaceNames.map(\.rawValue) + workspaces: monitor?.workspaces.enumerated().map { offset, workspace in + MenuWorkspace( + id: workspace.id.rawValue, + label: workspace.name + ?? (workspace.kind == .trailing ? "+" : String(offset + 1)) + ) + } ?? [] ) publishWorkspaceStateIfNeeded() } @@ -305,6 +311,7 @@ extension Daemon { timer?.cancel() ipcSource?.cancel() flushPendingPlacementWrite() + flushPendingTopologyWrite() platform.finishCommandDiagnostics() diagnostics.flush() platform.hideWindowBorders() diff --git a/Sources/DefiDaemon/DefiDaemon.swift b/Sources/DefiDaemon/DefiDaemon.swift index 22e6ed6..db34d77 100644 --- a/Sources/DefiDaemon/DefiDaemon.swift +++ b/Sources/DefiDaemon/DefiDaemon.swift @@ -144,6 +144,19 @@ struct MonitorLayoutPlan { let hiddenWindowIDs: Set } +struct WorkspaceTransitionIntent: Equatable { + let monitorID: MonitorID + let outgoingWorkspaceID: WorkspaceID + let incomingWorkspaceID: WorkspaceID + let direction: Int +} + +struct WorkspaceVerticalTransition: Equatable { + let monitorID: MonitorID + let outgoingWorkspaceID: WorkspaceID + let direction: Int +} + enum DisplacedPointerFocusRecovery: Equatable { case command(PendingAnimatedFocus, timestamp: TimeInterval) case workspace(PendingWorkspaceFocus, timestamp: TimeInterval) @@ -155,6 +168,8 @@ final class Daemon: NSObject { let platform = MacOSPlatform() let server: UnixSocketServer let placementStore: PlacementStore + let topologyStore: WorkspaceTopologyStore + let topologySessionID: String let diagnostics = DiagnosticRecorder() let readResponseCache = DaemonReadResponseCache() var state: RuntimeState @@ -165,6 +180,8 @@ final class Daemon: NSObject { qos: .utility ) var placementSaveWorkItem: DispatchWorkItem? + var topologySaveWorkItem: DispatchWorkItem? + var lastPersistedTopology: WorkspaceTopology? var hotKeys: HotKeyManager? var overviewController: OverviewController? var overviewOpenedAt: TimeInterval? @@ -267,8 +284,12 @@ final class Daemon: NSObject { config = try Config.load(from: options.configURL) server = try UnixSocketServer(url: options.socketURL) placementStore = PlacementStore() + topologyStore = WorkspaceTopologyStore() + topologySessionID = String(audit_session_self()) placementPreferences = (try? placementStore.load()) ?? PlacementPreferences() - state = RuntimeState(config: config) + let restoredTopology = try? topologyStore.load(sessionID: topologySessionID) + state = RuntimeState(config: config, topology: restoredTopology) + lastPersistedTopology = restoredTopology super.init() platform.setCommandDiagnosticHandler { [weak diagnostics] sample in diagnostics?.record(sample) @@ -387,6 +408,24 @@ final class Daemon: NSObject { } let animatedWritesPending = platform.hasPendingAnimatedFrameWrites let nativeFocusSyncPending = platform.hasPendingNativeFocusEvent + let recentCommandAnimationInputTimestamp = + animatedWritesPending + && now - latestCommandInputTimestamp < 0.3 + ? latestCommandInputTimestamp + : nil + let pendingCommandFocusInputTimestamp = [ + pendingAnimatedFocus?.focusInputTimestamp, + submittedCommandFocus?.focusInputTimestamp, + pendingWorkspaceFocus?.focusInputTimestamp, + submittedCommandFocusRequestTimestamp, + submittedWorkspaceFocusRequestTimestamp, + recentCommandAnimationInputTimestamp, + ].compactMap { $0 }.max() + let latestFocusIntentTimestamp = platform.userInputTracker.snapshot + .latestFocusIntent?.timestamp + let nativeFocusHasNewerHumanIntent = pendingCommandFocusInputTimestamp.map { + (latestFocusIntentTimestamp ?? 0) > $0 + } ?? true if liveBorderGesture { setTimerFrequency(min(activeDisplayRefreshRate, 120)) } @@ -459,6 +498,7 @@ final class Daemon: NSObject { || applicationInventoryRefreshDue, commandQuietPeriodElapsed: commandQuietPeriodElapsed, nativeFocusSyncPending: nativeFocusSyncPending, + nativeFocusHasNewerHumanIntent: nativeFocusHasNewerHumanIntent, frameDebtPending: platform.hasPendingFrameDebt, lifecycleEventPending: platform.hasPendingWindowTopologyEvent ) { diff --git a/Sources/DefiDaemon/WorkspaceTopologyStore.swift b/Sources/DefiDaemon/WorkspaceTopologyStore.swift new file mode 100644 index 0000000..75255f2 --- /dev/null +++ b/Sources/DefiDaemon/WorkspaceTopologyStore.swift @@ -0,0 +1,50 @@ +import DefiRuntime +import Foundation + +struct WorkspaceTopologyStore { + let url: URL + + init(url: URL = WorkspaceTopologyStore.defaultURL) { + self.url = url + } + + func load(sessionID: String) throws -> WorkspaceTopology? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let stored = try JSONDecoder().decode( + StoredWorkspaceTopology.self, + from: Data(contentsOf: url) + ) + return stored.version == 1 && stored.sessionID == sessionID + ? stored.topology + : nil + } + + func save(_ topology: WorkspaceTopology, sessionID: String) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try encoder.encode( + StoredWorkspaceTopology(sessionID: sessionID, topology: topology) + ).write(to: url, options: .atomic) + } + + static var defaultURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Library/Application Support/Defi/workspace-topology.json") + } +} + +private struct StoredWorkspaceTopology: Codable { + let version = 1 + let sessionID: String + let topology: WorkspaceTopology + + private enum CodingKeys: String, CodingKey { + case version + case sessionID + case topology + } +} diff --git a/Sources/DefiIPC/IPC.swift b/Sources/DefiIPC/IPC.swift index 04b4775..cccd983 100644 --- a/Sources/DefiIPC/IPC.swift +++ b/Sources/DefiIPC/IPC.swift @@ -18,7 +18,7 @@ public struct WorkspaceStateSnapshot: Codable, Equatable, Sendable { public let monitors: [MonitorWorkspaceSnapshot] public init( - version: Int = 1, + version: Int = 2, focusedMonitorID: UInt64?, monitors: [MonitorWorkspaceSnapshot] ) { @@ -51,7 +51,10 @@ public struct MonitorWorkspaceSnapshot: Codable, Equatable, Sendable { } public struct WorkspaceSnapshot: Codable, Equatable, Sendable { - public let name: String + public let id: String + public let position: Int + public let name: String? + public let kind: WorkspaceKind public let active: Bool public let windowCount: Int public let occupied: Bool @@ -59,13 +62,19 @@ public struct WorkspaceSnapshot: Codable, Equatable, Sendable { public let focusedApplication: String? public init( - name: String, + id: String, + position: Int, + name: String?, + kind: WorkspaceKind, active: Bool, windowCount: Int, applications: [String], focusedApplication: String? ) { + self.id = id + self.position = position self.name = name + self.kind = kind self.active = active self.windowCount = windowCount self.occupied = windowCount > 0 @@ -93,7 +102,7 @@ public func makeWorkspaceStateSnapshot( display: offset + 1, focused: monitor.id == focusedMonitorID, activeWorkspace: monitor.activeWorkspace.rawValue, - workspaces: monitor.workspaces.map { workspace in + workspaces: monitor.workspaces.enumerated().map { offset, workspace in let windowIDs = workspace.columns.flatMap(\.windows) + workspace.floatingWindows let workspaceWindows = windowIDs.compactMap { windows[$0] } let focusedWindowID: WindowID? = { @@ -111,7 +120,10 @@ public func makeWorkspaceStateSnapshot( return column.windows[column.focusedWindow] }() return WorkspaceSnapshot( - name: workspace.id.rawValue, + id: workspace.id.rawValue, + position: offset + 1, + name: workspace.name, + kind: workspace.kind, active: workspace.id == monitor.activeWorkspace, windowCount: workspaceWindows.count, applications: Array(Set(workspaceWindows.map(\.appID))).sorted(), diff --git a/Sources/DefiMacOS/AXFrameCoordinator.swift b/Sources/DefiMacOS/AXFrameCoordinator.swift index 2805bb9..9191ec4 100644 --- a/Sources/DefiMacOS/AXFrameCoordinator.swift +++ b/Sources/DefiMacOS/AXFrameCoordinator.swift @@ -26,6 +26,10 @@ final class AXFrameCoordinator: @unchecked Sendable { label: "com.quentin.defi.ax-frame-coordinator", qos: .userInteractive ) + let animationClockQueue = DispatchQueue( + label: "com.quentin.defi.animation-clock", + qos: .userInteractive + ) let finalOnlyAnimationQueue = DispatchQueue( label: "com.quentin.defi.ax-final-only-animation", qos: .userInitiated @@ -35,9 +39,10 @@ final class AXFrameCoordinator: @unchecked Sendable { qos: .utility ) let parkingSettlementGroup = DispatchGroup() + let animationLaneWriteGroup = DispatchGroup() let lock = NSLock() + let animationLaneLock = NSLock() let accessibilityWriter = AXFrameAccessibilityWriter() - let displayLinkClock = DisplayLinkClock() var pending: QueuedPositionFrame? var nextGeneration: UInt64 = 0 var latestGeneration: UInt64 = 0 @@ -82,6 +87,11 @@ final class AXFrameCoordinator: @unchecked Sendable { var latencySensitiveProcessIDs = Set() var processLatencyStreaks: [pid_t: ProcessLatencyStreak] = [:] var processWriteQueues: [pid_t: DispatchQueue] = [:] + var processAnimationLanes: + [pid_t: LatestAnimationSampleState] = [:] + var nextEnhancedUIRestoreToken: UInt64 = 0 + var deferredEnhancedUIRestores: + [pid_t: (token: UInt64, application: AXUIElement)] = [:] var immediateReadbackProcessDeadlines: [pid_t: TimeInterval] = [:] var liveBorderWindowID: WindowID? @@ -121,11 +131,6 @@ final class AXFrameCoordinator: @unchecked Sendable { ProcessInfo.processInfo.systemUptime + duration } - @MainActor - func startDisplayLink() { - displayLinkClock.start() - } - func updateParkingTargets(_ targets: [WindowID: AsyncPositionWrite]) { lock.lock() parkingTargets = targets @@ -238,8 +243,10 @@ final class AXFrameCoordinator: @unchecked Sendable { func invalidateAndWaitForWrites() { invalidate(reason: "synchronous-restore") queue.sync {} + animationLaneWriteGroup.wait() parkingSettlementGroup.wait() parkingSettlementQueue.sync {} + restoreDeferredEnhancedUserInterfaces() } func submit( @@ -328,8 +335,13 @@ final class AXFrameCoordinator: @unchecked Sendable { var isBusy: Bool { lock.lock() - defer { lock.unlock() } - return running || pending != nil || !deferredParkingWriteGenerations.isEmpty + let coordinatorIsBusy = + running || pending != nil || !deferredParkingWriteGenerations.isEmpty + lock.unlock() + animationLaneLock.lock() + let lanesAreBusy = !processAnimationLanes.isEmpty + animationLaneLock.unlock() + return coordinatorIsBusy || lanesAreBusy } func isBusy(for windowID: WindowID) -> Bool { @@ -552,10 +564,22 @@ final class AXFrameCoordinator: @unchecked Sendable { let retiredQueues = processWriteQueues.filter { !liveProcessIDs.contains($0.key) } + let retiredEnhancedUIRestores = deferredEnhancedUIRestores.filter { + !liveProcessIDs.contains($0.key) + } for processID in retiredQueues.keys { processWriteQueues[processID] = nil } + for processID in retiredEnhancedUIRestores.keys { + deferredEnhancedUIRestores[processID] = nil + } lock.unlock() + for restore in retiredEnhancedUIRestores.values { + accessibilityWriter.setEnhancedUserInterface( + true, + application: restore.application + ) + } // Drain outside the lock: pending work items observe the empty queue map // and their writes are generation-checked, so they become no-ops. for (_, queue) in retiredQueues { diff --git a/Sources/DefiMacOS/AXFrameCoordinatorAnimation.swift b/Sources/DefiMacOS/AXFrameCoordinatorAnimation.swift index c8bce5f..c1719ba 100644 --- a/Sources/DefiMacOS/AXFrameCoordinatorAnimation.swift +++ b/Sources/DefiMacOS/AXFrameCoordinatorAnimation.swift @@ -5,7 +5,19 @@ import DefiConfig import DefiCore import DefiModel import OSLog +import Synchronization +private struct AnimationClockState: Sendable { + var nextProgressIndex = 0 + var frames = 0 + var previousDispatchAt: TimeInterval? + var maximumDispatchGapMS = 0.0 + var maximumLatenessMS = 0.0 + var maximumSubmissionMS = 0.0 + var coalescedLaneCount = 0 + var finalizedProcessIDs: Set = [] + var finished = false +} extension AXFrameCoordinator { func animate( @@ -21,6 +33,7 @@ extension AXFrameCoordinator { let blockingStaticWrites = staticWrites.filter { !$0.value.isParked } let finalOnlyProcessIDs = finalOnlyAnimationProcessIDs( for: animatedWrites, + animationDuration: frame.animationDuration, refreshRateHz: frame.refreshRateHz ) let lanePlan = frameAnimationLanePlan( @@ -103,60 +116,92 @@ extension AXFrameCoordinator { completion: nil, cursorWarpAfterWindowCommit: frame.cursorWarpAfterWindowCommit ) + var applied = 0 + var stale = 0 + let stagingGroup = DispatchGroup() + let stagingAccumulator = FrameResultAccumulator() + let reentryWrites = loopWrites.filter { $0.value.isReentering } + if !reentryWrites.isEmpty { + let reentryFrame = QueuedPositionFrame( + generation: frame.generation, + source: frame.source, + writes: reentryWrites, + animatedWindowIDs: Set(reentryWrites.keys), + animationDuration: 0, + refreshRateHz: frame.refreshRateHz, + displayIDs: frame.displayIDs, + initialProgressVelocity: 0, + stagesVisibleBeforeParking: frame.stagesVisibleBeforeParking, + successfulWrite: frame.successfulWrite, + completion: nil, + cursorWarpAfterWindowCommit: frame.cursorWarpAfterWindowCommit + ) + let stagingBatches = processWriteBatches( + reentryWrites, + windowIDs: Set(reentryWrites.keys) + ) + for batch in stagingBatches { + stagingGroup.enter() + processWriteQueue(for: batch.processID).async { [self] in + defer { stagingGroup.leave() } + let startedAt = ProcessInfo.processInfo.systemUptime + let result = applyBatch( + batch, + frame: reentryFrame, + progress: 0, + intermediate: true, + stagingReentry: true, + recordFinalSuccess: false + ) + let completedAt = ProcessInfo.processInfo.systemUptime + let latencyMS = (completedAt - startedAt) * 1_000 + stagingAccumulator.add( + applied: result.applied, + stale: result.stale, + slowProcesses: result.slowProcesses, + processID: batch.processID, + processLatencyMS: latencyMS, + attempted: result.attempted, + completedAt: completedAt + ) + if result.attempted { + recordProcessLatencySamples([batch.processID: latencyMS]) + } + } + } + } + let startedAt = ProcessInfo.processInfo.systemUptime let interval = 1 / frame.refreshRateHz - let intermediateBudget = max(frame.animationDuration, interval * 2) let availableIntermediateSamples = completedFrameSpringSamples( duration: frame.animationDuration, refreshRateHz: frame.refreshRateHz, initialVelocity: frame.initialProgressVelocity ) - let maximumIntermediateFrames = availableIntermediateSamples.count - let initialPredictedLatency = predictedFrameLatency( - for: interpolatedWrites + let batches = processWriteBatches( + loopWrites, + windowIDs: Set(loopWrites.keys) ) - var intermediateFrameLimit = - loopWrites.isEmpty - ? 0 - : adaptiveIntermediateFrameLimit( - predictedFrameLatency: initialPredictedLatency, - refreshRateHz: frame.refreshRateHz, - availableIntermediateFrames: maximumIntermediateFrames - ) - if !loopWrites.isEmpty, - intermediateFrameLimit < maximumIntermediateFrames - { - lock.lock() - appendTraceLocked( - "quality g=\(frame.generation) predictedMs=\(String(format: "%.2f", initialPredictedLatency * 1_000)) intermediates=\(intermediateFrameLimit)" - ) - lock.unlock() - } - var nextDeadline = startedAt - var applied = 0 - var stale = 0 - var frames = 0 - var nextProgressIndex = 0 - var lastCompletedFrameDuration = 0.0 - var completedFrameWasSlow = false - var lastSpringProgress = 0.0 - - if !loopWrites.isEmpty { - displayLinkClock.setActive( - true, - displayIDs: frame.displayIDs, - generation: frame.generation - ) - } - defer { - if !loopWrites.isEmpty { - displayLinkClock.setActive( - false, - displayIDs: frame.displayIDs, - generation: frame.generation + let processQueues = Dictionary( + uniqueKeysWithValues: batches.map { + ($0.processID, processWriteQueue(for: $0.processID)) + } + ) + let finalSubmissionDelayByProcess = Dictionary( + uniqueKeysWithValues: batches.map { batch in + let writes = Dictionary(uniqueKeysWithValues: batch.writes) + return ( + batch.processID, + anticipatedFinalFrameDispatchDelay( + animationDuration: frame.animationDuration, + predictedFrameLatency: predictedFrameLatency(for: writes) + ) ) } - } + ) + let clockState = Mutex(AnimationClockState()) + let laneAccumulator = FrameResultAccumulator() + let finalGroup = DispatchGroup() let finalOnlyGroup = DispatchGroup() let finalOnlyResultStore = ConcurrentFrameResultStore() @@ -186,165 +231,197 @@ extension AXFrameCoordinator { lock.unlock() } - let reentryWrites = loopWrites.filter { $0.value.isReentering } - while frames < intermediateFrameLimit - && nextProgressIndex < availableIntermediateSamples.count - && isCurrent(generation: frame.generation) - { - let remaining = nextDeadline - ProcessInfo.processInfo.systemUptime - let displayTargetTimestamp = remaining > 0 - ? displayLinkClock.wait( - untilDisplayTarget: nextDeadline, - displayIDs: frame.displayIDs - ) - : nil - let displayAligned = displayTargetTimestamp != nil + let clockDone = DispatchSemaphore(value: 0) + let intervalNanoseconds = max( + Int((interval * 1_000_000_000).rounded()), + 1 + ) + let clock = DispatchSource.makeTimerSource( + flags: .strict, + queue: animationClockQueue + ) + clock.schedule( + deadline: .now() + .nanoseconds(intervalNanoseconds), + repeating: .nanoseconds(intervalNanoseconds), + leeway: .microseconds(100) + ) + clock.setEventHandler { [self] in + guard isCurrent(generation: frame.generation) else { + let shouldSignal = clockState.withLock { state in + guard !state.finished else { return false } + state.finished = true + return true + } + if shouldSignal { + clock.cancel() + clockDone.signal() + } + return + } let now = ProcessInfo.processInfo.systemUptime - let elapsed = now - startedAt - let predictedLatency = predictedFrameLatency(for: interpolatedWrites) - if frames > 0, - !completedFrameSupportsAnotherSample( - duration: lastCompletedFrameDuration, - refreshRateHz: frame.refreshRateHz + let progressIndex = clockState.withLock { state -> Int? in + guard state.nextProgressIndex < availableIntermediateSamples.count + else { return nil } + let elapsedIntervals = max( + Int(floor((now - startedAt) / interval)), + state.nextProgressIndex + 1 + ) + let index = min( + elapsedIntervals - 1, + availableIntermediateSamples.count - 1 ) - { - completedFrameWasSlow = true - break + state.nextProgressIndex = index + 1 + state.maximumLatenessMS = max( + state.maximumLatenessMS, + max(now - startedAt - Double(index + 1) * interval, 0) * 1_000 + ) + return index } - if !shouldEmitAnotherIntermediateFrame( - elapsed: elapsed, - predictedFrameLatency: predictedLatency, - budget: intermediateBudget, - completedIntermediateFrames: frames - ) { - break + guard let progressIndex else { + let shouldSignal = clockState.withLock { state in + guard !state.finished else { return false } + state.finished = true + return true + } + if shouldSignal { + clock.cancel() + clockDone.signal() + } + return } - guard - let progressIndex = anticipatedSpringProgressIndex( - predictedFrameLatency: predictedLatency, - refreshRateHz: frame.refreshRateHz, - availableIntermediateFrames: availableIntermediateSamples.count, - minimumIndex: nextProgressIndex, - maximumIndex: frames == 0 ? 1 : nil - ) - else { - break + let springSample = availableIntermediateSamples[progressIndex] + let elapsed = now - startedAt + let (finalBatches, finalizedProcessIDs) = clockState.withLock { state in + let due = batches.filter { + !state.finalizedProcessIDs.contains($0.processID) + && elapsed >= (finalSubmissionDelayByProcess[$0.processID] ?? .infinity) + } + state.finalizedProcessIDs.formUnion(due.map(\.processID)) + return (due, state.finalizedProcessIDs) } - let sampledSpring = displayTargetTimestamp.map { - springProgressSample( - elapsed: $0 - startedAt + predictedLatency, - duration: frame.animationDuration, - initialVelocity: frame.initialProgressVelocity, - minimumProgress: lastSpringProgress - ) - } ?? availableIntermediateSamples[progressIndex] - let springSample = sampledSpring.progress < lastSpringProgress - ? SpringProgressSample(progress: lastSpringProgress, velocity: 0) - : sampledSpring - let springProgress = springSample.progress - lastSpringProgress = springProgress - nextProgressIndex = progressIndex + 1 - let applyStartedAt = ProcessInfo.processInfo.systemUptime - let result = applyFrame( - animatedFrame, - progress: springProgress, - skippedProcesses: [], - intermediate: true, - stagingReentry: frames == 0 && !reentryWrites.isEmpty - ) - applied += result.applied - stale += result.stale - frames += 1 - publishCompletedBorderGeometry(animatedFrame.writes) - recordRetargetVelocity( - frame: animatedFrame, - progressVelocity: springSample.velocity + let intermediateBatches = batches.filter { batch in + !finalizedProcessIDs.contains(batch.processID) + } + let submissionStartedAt = ProcessInfo.processInfo.systemUptime + for _ in finalBatches { + finalGroup.enter() + } + let coalesced = submitAnimationSamples( + intermediateBatches.map { batch in + ProcessAnimationSample( + frame: animatedFrame, + batch: batch, + progress: springSample.progress, + progressVelocity: springSample.velocity, + intermediate: true, + stagingReentry: false, + recordFinalSuccess: false, + accumulator: laneAccumulator, + completion: nil + ) + } + finalBatches.map { batch in + ProcessAnimationSample( + frame: animatedFrame, + batch: batch, + progress: 1, + progressVelocity: 0, + intermediate: false, + stagingReentry: false, + recordFinalSuccess: true, + accumulator: laneAccumulator, + completion: { finalGroup.leave() } + ) + }, + processQueues: processQueues ) - let applyDurationMS = - (ProcessInfo.processInfo.systemUptime - applyStartedAt) * 1_000 - let frameCompletedAt = ProcessInfo.processInfo.systemUptime - lastCompletedFrameDuration = applyDurationMS / 1_000 - if frames == 1, - intermediateFrameLimit < maximumIntermediateFrames, - completedFrameSupportsAnotherSample( - duration: lastCompletedFrameDuration, - refreshRateHz: frame.refreshRateHz - ) - { - intermediateFrameLimit = maximumIntermediateFrames - lock.lock() - appendTraceLocked( - "quality-recovered g=\(frame.generation) actualMs=\(String(format: "%.2f", applyDurationMS)) intermediates=\(intermediateFrameLimit)" + let dispatchedAt = ProcessInfo.processInfo.systemUptime + clockState.withLock { state in + state.coalescedLaneCount += coalesced + state.maximumSubmissionMS = max( + state.maximumSubmissionMS, + (dispatchedAt - submissionStartedAt) * 1_000 ) - lock.unlock() + if let previousDispatchAt = state.previousDispatchAt { + state.maximumDispatchGapMS = max( + state.maximumDispatchGapMS, + (dispatchedAt - previousDispatchAt) * 1_000 + ) + } + state.previousDispatchAt = dispatchedAt + state.frames += 1 } - lock.lock() - appendTraceLocked( - "sample g=\(frame.generation) i=\(frames) pi=\(progressIndex) p=\(String(format: "%.3f", springProgress)) applied=\(result.applied) spread=\(String(format: "%.2f", result.completionSpreadMS)) ms=\(String(format: "%.2f", applyDurationMS)) display=\(displayAligned ? 1 : 0) reentry=\(frames == 1 ? reentryWrites.count : 0)" - ) - lock.unlock() - nextDeadline = nextCompletedFrameDispatchDeadline( - completedAt: frameCompletedAt, - refreshRateHz: frame.refreshRateHz - ) } + clock.resume() + let clockTimeout = + DispatchTime.now() + + .milliseconds(Int(frame.animationDuration * 1_000) + 100) + if clockDone.wait(timeout: clockTimeout) == .timedOut { + clockState.withLock { $0.finished = true } + clock.cancel() + } + animationClockQueue.sync {} + stagingGroup.wait() + let stagingResult = stagingAccumulator.result + applied += stagingResult.applied + stale += stagingResult.stale + let clockMetrics = clockState.withLock { $0 } + let frames = clockMetrics.frames + let maximumDispatchGapMS = clockMetrics.maximumDispatchGapMS + let maximumDisplayWaitMS = clockMetrics.maximumLatenessMS + let maximumSubmissionMS = clockMetrics.maximumSubmissionMS + let coalescedLaneCount = clockMetrics.coalescedLaneCount guard isCurrent(generation: frame.generation) else { + animationLaneWriteGroup.wait() finalOnlyGroup.wait() - markAnimationFinished( + let laneResult = laneAccumulator.result + recordAnimationCadence( generation: frame.generation, - startedAt: startedAt - ) - return (applied, stale + animatedWrites.count, frames) - } - let finalDispatchDelay = - intermediateFrameLimit == 0 - ? 0 - : anticipatedFinalFrameDispatchDelay( - animationDuration: frame.animationDuration, - predictedFrameLatency: predictedFrameLatency(for: interpolatedWrites) + frames: frames, + maximumDispatchGapMS: maximumDispatchGapMS, + maximumDisplayWaitMS: maximumDisplayWaitMS, + maximumSubmissionMS: maximumSubmissionMS, + coalescedLaneCount: coalescedLaneCount ) - let finalDeadline = finalFrameDispatchDeadline( - nominalDeadline: startedAt + finalDispatchDelay, - nextDisplayDeadline: frames > 0 ? nextDeadline : startedAt, - previousFrameWasSlow: completedFrameWasSlow, - hardDeadline: startedAt + frame.animationDuration - ) - let finalRemaining = - finalDeadline - ProcessInfo.processInfo.systemUptime - let finalDisplayTargetTimestamp = finalRemaining > 0 - ? displayLinkClock.wait( - untilDisplayTarget: finalDeadline, - displayIDs: frame.displayIDs - ) - : nil - let finalDisplayAligned = finalDisplayTargetTimestamp != nil - guard isCurrent(generation: frame.generation) else { - finalOnlyGroup.wait() markAnimationFinished( generation: frame.generation, startedAt: startedAt ) - return (applied, stale + animatedWrites.count, frames) + return ( + applied + laneResult.applied, + stale + laneResult.stale + animatedWrites.count, + frames + ) + } + let remainingBatches = clockState.withLock { state in + let remaining = batches.filter { + !state.finalizedProcessIDs.contains($0.processID) + } + state.finalizedProcessIDs.formUnion(remaining.map(\.processID)) + return remaining } - if !loopWrites.isEmpty { - let finalStartedAt = ProcessInfo.processInfo.systemUptime - let final = applyFrame( - animatedFrame, + let finalSamples = remainingBatches.map { batch in + finalGroup.enter() + return ProcessAnimationSample( + frame: animatedFrame, + batch: batch, progress: 1, - skippedProcesses: [] + progressVelocity: 0, + intermediate: false, + stagingReentry: false, + recordFinalSuccess: true, + accumulator: laneAccumulator, + completion: { finalGroup.leave() } ) - applied += final.applied - stale += final.stale - publishCompletedBorderGeometry(animatedFrame.writes) - let finalDurationMS = - (ProcessInfo.processInfo.systemUptime - finalStartedAt) * 1_000 - lock.lock() - appendTraceLocked( - "sample g=\(frame.generation) i=final p=1.000 applied=\(final.applied) spread=\(String(format: "%.2f", final.completionSpreadMS)) ms=\(String(format: "%.2f", finalDurationMS)) display=\(finalDisplayAligned ? 1 : 0) reentry=0" - ) - lock.unlock() } + _ = submitAnimationSamples( + finalSamples, + processQueues: processQueues + ) + finalGroup.wait() + let laneResult = laneAccumulator.result + applied += laneResult.applied + stale += laneResult.stale finalOnlyGroup.wait() let finalOnlyResult = finalOnlyResultStore.result if let finalOnlyResult { @@ -356,6 +433,14 @@ extension AXFrameCoordinator { ) lock.unlock() } + recordAnimationCadence( + generation: frame.generation, + frames: frames + (batches.isEmpty ? 0 : 1), + maximumDispatchGapMS: maximumDispatchGapMS, + maximumDisplayWaitMS: maximumDisplayWaitMS, + maximumSubmissionMS: maximumSubmissionMS, + coalescedLaneCount: coalescedLaneCount + ) markAnimationFinished( generation: frame.generation, startedAt: startedAt @@ -397,6 +482,21 @@ extension AXFrameCoordinator { ) } + func recordAnimationCadence( + generation: UInt64, + frames: Int, + maximumDispatchGapMS: Double, + maximumDisplayWaitMS: Double, + maximumSubmissionMS: Double, + coalescedLaneCount: Int + ) { + lock.lock() + appendTraceLocked( + "cadence g=\(generation) frames=\(frames) maxGapMs=\(String(format: "%.2f", maximumDispatchGapMS)) waitMs=\(String(format: "%.2f", maximumDisplayWaitMS)) submitMs=\(String(format: "%.2f", maximumSubmissionMS)) coalesced=\(coalescedLaneCount)" + ) + lock.unlock() + } + /// Border overlays ride the geometry that has actually been written and /// accepted - never the interpolated target - so they always match the /// displayed window frame. @@ -442,14 +542,15 @@ extension AXFrameCoordinator { func recordRetargetVelocity( frame: QueuedPositionFrame, - progressVelocity: Double + progressVelocity: Double, + windowIDs: Set? = nil ) { lock.lock() guard latestGeneration == frame.generation else { lock.unlock() return } - for windowID in frame.animatedWindowIDs { + for windowID in windowIDs ?? frame.animatedWindowIDs { guard let write = frame.writes[windowID] else { continue } retargetHorizontalVelocities[windowID] = (write.point.x - write.fromPoint.x) * progressVelocity diff --git a/Sources/DefiMacOS/AXFrameCoordinatorWrites.swift b/Sources/DefiMacOS/AXFrameCoordinatorWrites.swift index e275d92..9387704 100644 --- a/Sources/DefiMacOS/AXFrameCoordinatorWrites.swift +++ b/Sources/DefiMacOS/AXFrameCoordinatorWrites.swift @@ -6,6 +6,7 @@ import DefiCore import DefiModel import OSLog +private let enhancedUIRestoreDelay: TimeInterval = 0.12 extension AXFrameCoordinator { func applyFrame( @@ -42,22 +43,11 @@ extension AXFrameCoordinator { ) lock.unlock() } - let orderedWrites = frame.writes - .filter { phase.contains($0.key) } - .sorted { - if $0.value.processID != $1.value.processID { - return $0.value.processID < $1.value.processID - } - return $0.key.rawValue < $1.key.rawValue - } - let batches = Dictionary( - grouping: orderedWrites.filter { - !skippedProcesses.contains($0.value.processID) - }, - by: \.value.processID - ).map { - ProcessWriteBatch(processID: $0.key, writes: $0.value) - }.sorted { $0.processID < $1.processID } + let batches = processWriteBatches( + frame.writes, + windowIDs: phase, + skippedProcesses: skippedProcesses + ) let group = DispatchGroup() for batch in batches { group.enter() @@ -100,6 +90,117 @@ extension AXFrameCoordinator { ) } + func processWriteBatches( + _ writes: [WindowID: AsyncPositionWrite], + windowIDs: Set, + skippedProcesses: Set = [] + ) -> [ProcessWriteBatch] { + let orderedWrites = writes.filter { windowIDs.contains($0.key) } + .sorted { + if $0.value.processID != $1.value.processID { + return $0.value.processID < $1.value.processID + } + return $0.key.rawValue < $1.key.rawValue + } + return Dictionary( + grouping: orderedWrites.filter { + !skippedProcesses.contains($0.value.processID) + }, + by: \.value.processID + ).map { + ProcessWriteBatch(processID: $0.key, writes: $0.value) + }.sorted { $0.processID < $1.processID } + } + + func submitAnimationSamples( + _ samples: [ProcessAnimationSample], + processQueues: [pid_t: DispatchQueue] + ) -> Int { + for _ in samples { + animationLaneWriteGroup.enter() + } + var displacedSamples: [ProcessAnimationSample] = [] + var startingSamples: [ProcessAnimationSample] = [] + animationLaneLock.lock() + for sample in samples { + var lane = processAnimationLanes[sample.batch.processID] + ?? LatestAnimationSampleState() + let submission = lane.submit(sample) + processAnimationLanes[sample.batch.processID] = lane + if let displaced = submission.displaced { + displacedSamples.append(displaced) + } + if submission.startsDrain { + startingSamples.append(sample) + } + } + animationLaneLock.unlock() + for displaced in displacedSamples { + displaced.completion?() + animationLaneWriteGroup.leave() + } + for sample in startingSamples { + let queue = + processQueues[sample.batch.processID] + ?? processWriteQueue(for: sample.batch.processID) + queue.async { [self] in + drainAnimationLane(processID: sample.batch.processID) + } + } + return displacedSamples.count + } + + func drainAnimationLane(processID: pid_t) { + while true { + animationLaneLock.lock() + guard var lane = processAnimationLanes[processID], + let sample = lane.takeNext() + else { + processAnimationLanes[processID] = nil + animationLaneLock.unlock() + return + } + processAnimationLanes[processID] = lane + animationLaneLock.unlock() + + let startedAt = ProcessInfo.processInfo.systemUptime + let result = applyBatch( + sample.batch, + frame: sample.frame, + progress: sample.progress, + intermediate: sample.intermediate, + stagingReentry: sample.stagingReentry, + recordFinalSuccess: sample.recordFinalSuccess + ) + let completedAt = ProcessInfo.processInfo.systemUptime + let latencyMS = (completedAt - startedAt) * 1_000 + sample.accumulator.add( + applied: result.applied, + stale: result.stale, + slowProcesses: result.slowProcesses, + processID: processID, + processLatencyMS: latencyMS, + attempted: result.attempted, + completedAt: completedAt + ) + if result.attempted { + recordProcessLatencySamples([processID: latencyMS]) + } + publishCompletedBorderGeometry( + Dictionary(uniqueKeysWithValues: sample.batch.writes) + ) + if sample.intermediate { + recordRetargetVelocity( + frame: sample.frame, + progressVelocity: sample.progressVelocity, + windowIDs: Set(sample.batch.writes.map(\.key)) + ) + } + sample.completion?() + animationLaneWriteGroup.leave() + } + } + func processWriteQueue(for processID: pid_t) -> DispatchQueue { lock.lock() defer { lock.unlock() } @@ -108,7 +209,7 @@ extension AXFrameCoordinator { } let queue = DispatchQueue( label: "com.quentin.defi.ax-process-\(processID)", - qos: .userInteractive, + qos: .userInitiated, autoreleaseFrequency: .workItem ) processWriteQueues[processID] = queue @@ -130,9 +231,45 @@ extension AXFrameCoordinator { func finalOnlyAnimationProcessIDs( for writes: [WindowID: AsyncPositionWrite], + animationDuration: TimeInterval, refreshRateHz: Double ) -> Set { - let processIDs = Set(writes.values.map(\.processID)) + finalOnlyAnimationProcessIDs( + for: Set(writes.values.map(\.processID)), + animationDuration: animationDuration, + refreshRateHz: refreshRateHz + ) + } + + func animationSupportsIntermediateFrames( + processIDs: Set, + animationDuration: TimeInterval, + refreshRateHz: Double + ) -> Bool { + let availableIntermediateFrames = completedFrameSpringSamples( + duration: animationDuration, + refreshRateHz: refreshRateHz + ).count + lock.lock() + defer { lock.unlock() } + return processIDs.allSatisfy { + adaptiveIntermediateFrameLimit( + predictedFrameLatency: (predictedProcessLatencyMS[$0] ?? 0) / 1_000, + refreshRateHz: refreshRateHz, + availableIntermediateFrames: availableIntermediateFrames + ) >= 2 + } + } + + private func finalOnlyAnimationProcessIDs( + for processIDs: Set, + animationDuration: TimeInterval, + refreshRateHz: Double + ) -> Set { + let availableIntermediateFrames = completedFrameSpringSamples( + duration: animationDuration, + refreshRateHz: refreshRateHz + ).count lock.lock() let predictions = Dictionary( uniqueKeysWithValues: processIDs.map { processID in @@ -145,7 +282,7 @@ extension AXFrameCoordinator { adaptiveIntermediateFrameLimit( predictedFrameLatency: latency, refreshRateHz: refreshRateHz, - availableIntermediateFrames: 1 + availableIntermediateFrames: availableIntermediateFrames ) == 0 ? processID : nil @@ -218,24 +355,56 @@ extension AXFrameCoordinator { var stale = 0 var slowProcesses = Set() var attempted = false + let batchApplication = batch.writes.first?.value.application + let enhancedUIWasEnabled = batch.writes.contains { + $0.value.enhancedUIWasEnabled + } + let pendingEnhancedUIRestore = hasDeferredEnhancedUIRestore( + processID: batch.processID + ) + let defersEnhancedUIRestore = + enhancedUIWasEnabled + && ( + pendingEnhancedUIRestore + || batch.writes.contains { + DefiMacOS.defersEnhancedUIRestore( + stagesVisibleBeforeParking: frame.stagesVisibleBeforeParking, + isIntermediate: intermediate, + enhancedUIWasEnabled: $0.value.enhancedUIWasEnabled, + positionChanged: $0.value.positionChanged + ) + } + ) // Hoist the AXEnhancedUserInterface toggle to batch granularity: one // disable/restore pair per application instead of two round-trips per // parked or verified-offscreen write. let managesEnhancedUI = - batch.writes.contains { $0.value.enhancedUIWasEnabled } + enhancedUIWasEnabled && batch.writes.contains { $0.value.isParked || $0.value.requiresVerifiedOffscreenWrite } - if let batchApplication = batch.writes.first?.value.application, managesEnhancedUI { - accessibilityWriter.setEnhancedUserInterface( - false, + let enhancedUIRestoreToken: UInt64? + if let batchApplication, defersEnhancedUIRestore { + enhancedUIRestoreToken = beginDeferredEnhancedUIRestore( + processID: batch.processID, application: batchApplication ) + } else { + enhancedUIRestoreToken = nil + if let batchApplication, managesEnhancedUI { + accessibilityWriter.setEnhancedUserInterface( + false, + application: batchApplication + ) + } } defer { - if let batchApplication = batch.writes.first?.value.application, - managesEnhancedUI - { + if let enhancedUIRestoreToken { + scheduleEnhancedUIRestore( + processID: batch.processID, + token: enhancedUIRestoreToken + ) + } else if let batchApplication, managesEnhancedUI { accessibilityWriter.setEnhancedUserInterface( true, application: batchApplication @@ -298,6 +467,7 @@ extension AXFrameCoordinator { item.value, size: size, enhancedUIManagedByBatch: managesEnhancedUI + || defersEnhancedUIRestore ) ) let sizeApplied = frameSizeWriteSucceeded( @@ -326,6 +496,7 @@ extension AXFrameCoordinator { isIntermediate: intermediate ), enhancedUIManagedByBatch: managesEnhancedUI + || defersEnhancedUIRestore ) ) let acceptedPosition = @@ -441,6 +612,65 @@ extension AXFrameCoordinator { return (applied, stale, slowProcesses, attempted) } + func hasDeferredEnhancedUIRestore(processID: pid_t) -> Bool { + lock.lock() + defer { lock.unlock() } + return deferredEnhancedUIRestores[processID] != nil + } + + func beginDeferredEnhancedUIRestore( + processID: pid_t, + application: AXUIElement + ) -> UInt64 { + lock.lock() + nextEnhancedUIRestoreToken &+= 1 + let token = nextEnhancedUIRestoreToken + deferredEnhancedUIRestores[processID] = (token, application) + lock.unlock() + accessibilityWriter.setEnhancedUserInterface( + false, + application: application + ) + return token + } + + func scheduleEnhancedUIRestore( + processID: pid_t, + token: UInt64 + ) { + processWriteQueue(for: processID).asyncAfter( + deadline: .now() + enhancedUIRestoreDelay + ) { [weak self] in + guard let self else { return } + lock.lock() + guard let restore = deferredEnhancedUIRestores[processID], + restore.token == token + else { + lock.unlock() + return + } + deferredEnhancedUIRestores[processID] = nil + lock.unlock() + accessibilityWriter.setEnhancedUserInterface( + true, + application: restore.application + ) + } + } + + func restoreDeferredEnhancedUserInterfaces() { + lock.lock() + let restores = Array(deferredEnhancedUIRestores.values) + deferredEnhancedUIRestores.removeAll(keepingCapacity: true) + lock.unlock() + for restore in restores { + accessibilityWriter.setEnhancedUserInterface( + true, + application: restore.application + ) + } + } + func commitFinalSizesOnce( _ writes: [WindowID: AsyncPositionWrite], generation: UInt64 diff --git a/Sources/DefiMacOS/FrameCommit.swift b/Sources/DefiMacOS/FrameCommit.swift index 8d000a1..c76c84a 100644 --- a/Sources/DefiMacOS/FrameCommit.swift +++ b/Sources/DefiMacOS/FrameCommit.swift @@ -4,154 +4,6 @@ import DefiConfig import DefiCore import DefiModel import OSLog -import QuartzCore - -final class DisplayLinkClock: NSObject, @unchecked Sendable { - private let condition = NSCondition() - private var displayIDsByLink: [ObjectIdentifier: UInt64] = [:] - private var nextTargetTimestamps: [UInt64: TimeInterval] = [:] - private var nextActivationRequestID: UInt64 = 0 - private var latestActivationRequestID: UInt64 = 0 - private var latestActivationGeneration: UInt64 = 0 - @MainActor private var displayLinks: [UInt64: CADisplayLink] = [:] - - @MainActor - func start() { - for link in displayLinks.values { - link.invalidate() - } - displayLinks.removeAll(keepingCapacity: true) - condition.lock() - displayIDsByLink.removeAll(keepingCapacity: true) - nextTargetTimestamps.removeAll(keepingCapacity: true) - nextActivationRequestID &+= 1 - latestActivationRequestID = nextActivationRequestID - latestActivationGeneration = 0 - for screen in NSScreen.screens { - guard - let number = screen.deviceDescription[ - NSDeviceDescriptionKey("NSScreenNumber") - ] as? NSNumber - else { continue } - let displayID = number.uint64Value - let link = screen.displayLink( - target: self, - selector: #selector(displayLinkDidFire(_:)) - ) - link.isPaused = true - link.add(to: .main, forMode: .common) - displayLinks[displayID] = link - displayIDsByLink[ObjectIdentifier(link)] = displayID - } - condition.unlock() - } - - func setActive( - _ active: Bool, - displayIDs: Set, - generation: UInt64 - ) { - condition.lock() - guard generation >= latestActivationGeneration else { - condition.unlock() - return - } - nextActivationRequestID &+= 1 - let requestID = nextActivationRequestID - latestActivationRequestID = requestID - latestActivationGeneration = generation - condition.unlock() - - Task { @MainActor [weak self] in - guard let self else { return } - guard self.activationRequestIsCurrent( - generation: generation, - requestID: requestID - ) else { return } - let availableDisplayIDs = Set(displayLinks.keys) - let selectedDisplayIDs = resolvedAnimationDisplayIDs( - requested: displayIDs, - available: availableDisplayIDs - ) - for (candidateID, link) in displayLinks { - link.isPaused = - !active || selectedDisplayIDs.contains(candidateID) == false - } - } - } - - private func activationRequestIsCurrent( - generation: UInt64, - requestID: UInt64 - ) -> Bool { - condition.lock() - defer { condition.unlock() } - return displayLinkActivationIsCurrent( - generation: generation, - latestGeneration: latestActivationGeneration, - requestID: requestID, - latestRequestID: latestActivationRequestID - ) - } - - func wait( - untilDisplayTarget deadline: TimeInterval, - displayIDs: Set - ) -> TimeInterval? { - guard deadline > ProcessInfo.processInfo.systemUptime else { return nil } - condition.lock() - defer { condition.unlock() } - let availableDisplayIDs = Set(displayIDsByLink.values) - let selectedDisplayIDs = resolvedAnimationDisplayIDs( - requested: displayIDs, - available: availableDisplayIDs - ) - while selectedDisplayIDs.contains(where: { - nextTargetTimestamps[$0] ?? 0 < deadline - }) { - let remaining = deadline - ProcessInfo.processInfo.systemUptime - guard remaining > 0 else { break } - _ = condition.wait( - until: Date(timeIntervalSinceNow: remaining) - ) - } - let timestamps = selectedDisplayIDs.compactMap { nextTargetTimestamps[$0] } - guard timestamps.count == selectedDisplayIDs.count, - timestamps.allSatisfy({ $0 >= deadline }) - else { return nil } - return timestamps.max() - } - - @objc private func displayLinkDidFire(_ sender: CADisplayLink) { - condition.lock() - if let displayID = displayIDsByLink[ObjectIdentifier(sender)] { - nextTargetTimestamps[displayID] = sender.targetTimestamp - } - condition.broadcast() - condition.unlock() - } -} - -func resolvedAnimationDisplayIDs( - requested: Set, - available: Set -) -> Set { - let selected = requested.intersection(available) - if selected.isEmpty, let fallback = available.first { - return [fallback] - } - return selected -} - -func displayLinkActivationIsCurrent( - generation: UInt64, - latestGeneration: UInt64, - requestID: UInt64, - latestRequestID: UInt64 -) -> Bool { - generation == latestGeneration && requestID == latestRequestID -} - struct FrameWriteIntent: Equatable, Sendable { let position: Bool @@ -172,6 +24,14 @@ func frameWriteIntent( ) } +func reentryStartRequiresStaging( + observed: CGPoint, + planned: CGPoint +) -> Bool { + abs(observed.x - planned.x) >= 0.5 + || abs(observed.y - planned.y) >= 0.5 +} + func successfulFrameWriteIntent( positionChanged: Bool, positionApplied: Bool, @@ -514,6 +374,16 @@ func suppressesNativePositionAnimation( stagesVisibleBeforeParking && !isParked && !isIntermediate } +func defersEnhancedUIRestore( + stagesVisibleBeforeParking: Bool, + isIntermediate: Bool, + enhancedUIWasEnabled: Bool, + positionChanged: Bool +) -> Bool { + stagesVisibleBeforeParking && !isIntermediate + && enhancedUIWasEnabled && positionChanged +} + func shouldApplyDeferredFocus( targetWindowID: WindowID, selectedWindowID: WindowID? @@ -526,6 +396,42 @@ struct ProcessWriteBatch: @unchecked Sendable { let writes: [(key: WindowID, value: AsyncPositionWrite)] } +struct ProcessAnimationSample: @unchecked Sendable { + let frame: QueuedPositionFrame + let batch: ProcessWriteBatch + let progress: Double + let progressVelocity: Double + let intermediate: Bool + let stagingReentry: Bool + let recordFinalSuccess: Bool + let accumulator: FrameResultAccumulator + let completion: (@Sendable () -> Void)? +} + +struct LatestAnimationSampleState { + private(set) var isRunning = false + private var pending: Sample? + + mutating func submit( + _ sample: Sample + ) -> (startsDrain: Bool, displaced: Sample?) { + let displaced = pending + pending = sample + guard !isRunning else { return (false, displaced) } + isRunning = true + return (true, displaced) + } + + mutating func takeNext() -> Sample? { + guard let pending else { + isRunning = false + return nil + } + self.pending = nil + return pending + } +} + final class FrameResultAccumulator: @unchecked Sendable { private let lock = NSLock() private var applied = 0 diff --git a/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift b/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift index d2a5b0f..18d15a1 100644 --- a/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift +++ b/Sources/DefiMacOS/MacOSPlatform+EventObservation.swift @@ -11,7 +11,6 @@ extension MacOSPlatform { public func invalidateFrameStateForDisplayChange() { frameCoordinator.invalidate(reason: "display-change") - frameCoordinator.startDisplayLink() clearFrameState() } diff --git a/Sources/DefiMacOS/MacOSPlatform+FrameApplication.swift b/Sources/DefiMacOS/MacOSPlatform+FrameApplication.swift index fa13ebc..6ecbeef 100644 --- a/Sources/DefiMacOS/MacOSPlatform+FrameApplication.swift +++ b/Sources/DefiMacOS/MacOSPlatform+FrameApplication.swift @@ -6,6 +6,30 @@ import DefiCore import DefiModel import OSLog +func reentryTransitionDelta( + reentryStart: CGPoint, + reentryTarget: Rect, + candidateStart: CGPoint, + candidateTarget: Rect +) -> CGPoint? { + let delta = CGPoint( + x: candidateTarget.x - candidateStart.x, + y: candidateTarget.y - candidateStart.y + ) + guard abs(delta.x) >= 0.5 || abs(delta.y) >= 0.5 else { return nil } + guard abs(delta.y) > abs(delta.x) else { + return CGPoint(x: delta.x, y: 0) + } + let reentryDelta = CGPoint( + x: reentryTarget.x - reentryStart.x, + y: reentryTarget.y - reentryStart.y + ) + guard abs(reentryDelta.x - delta.x) >= 0.5 + || abs(reentryDelta.y - delta.y) >= 0.5 + else { return nil } + return CGPoint(x: 0, y: delta.y) +} + @MainActor extension MacOSPlatform { @@ -145,28 +169,40 @@ extension MacOSPlatform { var reenteringWindowIDs = Set() for assignment in assignments where newlyUnparkedWindowIDs.contains(assignment.windowID) { + guard let reentryStart = animationStartPositions[assignment.windowID] + else { continue } let nearestTransition = assignments.compactMap { - candidate -> (distance: Double, deltaX: Double)? in + candidate -> (distance: Double, delta: CGPoint)? in guard candidate.windowID != assignment.windowID, !hiddenWindowIDs.contains(candidate.windowID), !newlyUnparkedWindowIDs.contains(candidate.windowID), - let start = animationStartPositions[candidate.windowID] + let candidateStart = animationStartPositions[candidate.windowID], + let delta = reentryTransitionDelta( + reentryStart: reentryStart, + reentryTarget: assignment.frame, + candidateStart: candidateStart, + candidateTarget: candidate.frame + ) else { return nil } - let deltaX = candidate.frame.x - start.x - guard abs(deltaX) >= 0.5 else { return nil } let distance = abs(candidate.frame.x - assignment.frame.x) + abs(candidate.frame.y - assignment.frame.y) - return (distance, deltaX) + return (distance, delta) }.min { $0.distance < $1.distance } guard let nearestTransition else { continue } - animationStartPositions[assignment.windowID] = CGPoint( - x: assignment.frame.x - nearestTransition.deltaX, - y: assignment.frame.y + let plannedStart = CGPoint( + x: assignment.frame.x - nearestTransition.delta.x, + y: assignment.frame.y - nearestTransition.delta.y ) - reenteringWindowIDs.insert(assignment.windowID) + animationStartPositions[assignment.windowID] = plannedStart + if reentryStartRequiresStaging( + observed: reentryStart, + planned: plannedStart + ) { + reenteringWindowIDs.insert(assignment.windowID) + } } for assignment in assignments where newlyDiscoveredWindowIDs.contains(assignment.windowID) diff --git a/Sources/DefiMacOS/MacOSPlatform+FramePerformance.swift b/Sources/DefiMacOS/MacOSPlatform+FramePerformance.swift index fa44a0f..bd87c27 100644 --- a/Sources/DefiMacOS/MacOSPlatform+FramePerformance.swift +++ b/Sources/DefiMacOS/MacOSPlatform+FramePerformance.swift @@ -45,6 +45,27 @@ extension MacOSPlatform { ) } + public var frameWritableWindowIDs: Set { + Set( + elements.keys.filter { windowID in + processIDs[windowID].map { applications[$0] != nil } == true + }) + } + + public func positionsCanAnimateTogether( + windowIDs: Set, + animationDuration: TimeInterval, + refreshRateHz: Double + ) -> Bool { + let processIDs = windowIDs.compactMap { self.processIDs[$0] } + guard processIDs.count == windowIDs.count else { return false } + return frameCoordinator.animationSupportsIntermediateFrames( + processIDs: Set(processIDs), + animationDuration: animationDuration, + refreshRateHz: refreshRateHz + ) + } + public var latencySensitiveProcessCount: Int { frameCoordinator.slowProcessIDs.count } diff --git a/Sources/DefiMacOS/MacOSPlatform.swift b/Sources/DefiMacOS/MacOSPlatform.swift index b521188..01d4922 100644 --- a/Sources/DefiMacOS/MacOSPlatform.swift +++ b/Sources/DefiMacOS/MacOSPlatform.swift @@ -558,9 +558,7 @@ public final class MacOSPlatform { public let userInputTracker = UserInputTracker() public let pointerMotionTracker = PointerMotionTracker() - public init() { - frameCoordinator.startDisplayLink() - } + public init() {} public func updateNativeFullscreenWindowIDs( _ windowIDs: Set, diff --git a/Sources/DefiMacOS/MenuBar.swift b/Sources/DefiMacOS/MenuBar.swift index 536b7d8..e96ccae 100644 --- a/Sources/DefiMacOS/MenuBar.swift +++ b/Sources/DefiMacOS/MenuBar.swift @@ -1,6 +1,16 @@ import AppKit import OSLog +public struct MenuWorkspace: Equatable, Sendable { + public let id: String + public let label: String + + public init(id: String, label: String) { + self.id = id + self.label = label + } +} + private let menuBarLogger = Logger( subsystem: "com.quentin.defi", category: "MenuBar" @@ -12,7 +22,7 @@ public final class MenuBarController: NSObject { withLength: NSStatusItem.variableLength ) private let commandHandler: (String) -> Void - private var workspaceNames: [String] = [] + private var workspaces: [MenuWorkspace] = [] private var activeWorkspace = "" public init(commandHandler: @escaping (String) -> Void) { @@ -23,14 +33,15 @@ public final class MenuBarController: NSObject { menuBarLogger.info("Menu bar item installed") } - public func update(activeWorkspace: String, workspaceNames: [String]) { - guard self.activeWorkspace != activeWorkspace - || self.workspaceNames != workspaceNames + public func update(activeWorkspace: String, workspaces: [MenuWorkspace]) { + guard + self.activeWorkspace != activeWorkspace + || self.workspaces != workspaces else { return } self.activeWorkspace = activeWorkspace - self.workspaceNames = workspaceNames + self.workspaces = workspaces updateButton() rebuildMenu() } @@ -38,9 +49,9 @@ public final class MenuBarController: NSObject { private func updateButton() { guard let button = statusItem.button else { return } button.image = nil - if let index = workspaceNames.firstIndex(of: activeWorkspace) { - button.title = String(index + 1) - button.toolTip = "Defi — Workspace \(index + 1): \(activeWorkspace)" + if let workspace = workspaces.first(where: { $0.id == activeWorkspace }) { + button.title = workspace.label + button.toolTip = "Defi — Workspace \(workspace.label)" } else { button.title = "–" button.toolTip = "Defi" @@ -54,14 +65,14 @@ public final class MenuBarController: NSObject { private func rebuildMenu() { let menu = NSMenu() - for workspace in workspaceNames { + for workspace in workspaces { let item = commandItem( - title: workspace == activeWorkspace ? "✓ \(workspace)" : workspace, - command: "workspace \(workspace)" + title: workspace.id == activeWorkspace ? "✓ \(workspace.label)" : workspace.label, + command: "workspace \(workspace.id)" ) menu.addItem(item) } - if !workspaceNames.isEmpty { + if !workspaces.isEmpty { menu.addItem(.separator()) } menu.addItem(commandItem(title: "Quit Defi", command: "quit")) diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index 4b4f34a..98ee661 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -158,6 +158,7 @@ public final class OverviewController: NSObject { private var alignSelectionOnNextUpdate = false private var animationsEnabled = true private var overviewZoom = 0.5 + private var windowCornerRadius = 12.0 private var viewportAnimations: [MonitorID: OverviewViewportAnimation] = [:] private var projectionAnimations: [MonitorID: OverviewProjectionAnimation] = [:] private var viewportDisplayLinks: [MonitorID: CADisplayLink] = [:] @@ -222,6 +223,7 @@ public final class OverviewController: NSObject { borders: BordersConfig = BordersConfig(), animation: AnimationConfig = AnimationConfig(), zoom: Double = 0.5, + windowCornerRadius: Double = 12, windowPreviewsEnabled: Bool = false ) { if isOpen { @@ -233,6 +235,7 @@ public final class OverviewController: NSObject { borders: borders, animation: animation, zoom: zoom, + windowCornerRadius: windowCornerRadius, windowPreviewsEnabled: windowPreviewsEnabled ) } @@ -244,6 +247,7 @@ public final class OverviewController: NSObject { borders: BordersConfig = BordersConfig(), animation: AnimationConfig = AnimationConfig(), zoom: Double = 0.5, + windowCornerRadius: Double = 12, windowPreviewsEnabled: Bool = false ) { closePanelsImmediately() @@ -253,6 +257,7 @@ public final class OverviewController: NSObject { borderStyle = WindowBorderStyle(config: borders) animationsEnabled = animation.enabled overviewZoom = zoom + self.windowCornerRadius = windowCornerRadius self.windowPreviewsEnabled = windowPreviewsEnabled usesWorkspaceParking = overviewUsesWorkspaceParking( windowPreviewsEnabled: windowPreviewsEnabled, @@ -308,6 +313,7 @@ public final class OverviewController: NSObject { borders: BordersConfig = BordersConfig(), animation: AnimationConfig = AnimationConfig(), zoom: Double = 0.5, + windowCornerRadius: Double = 12, windowPreviewsEnabled: Bool? = nil ) { guard isOpen else { return } @@ -337,6 +343,7 @@ public final class OverviewController: NSObject { borderStyle = WindowBorderStyle(config: borders) animationsEnabled = animation.enabled overviewZoom = zoom + self.windowCornerRadius = windowCornerRadius var movedSelectionPositions: ( previous: OverviewTiledPosition, next: OverviewTiledPosition @@ -773,6 +780,7 @@ public final class OverviewController: NSObject { selection: selection, drag: drag?.presentation(on: monitorID, panel: panel), borderStyle: borderStyle, + windowCornerRadius: windowCornerRadius, previews: previewCache, previewOpacities: previewOpacities ) @@ -1067,12 +1075,24 @@ public final class OverviewController: NSObject { let scale = panels[monitorID]?.window.backingScaleFactor ?? 1 for card in projection.workspaces.flatMap(\.windows) { guard let window = snapshot.windows[card.windowID] else { continue } + let width = min(max(Int((card.frame.width * scale).rounded(.up)), 32), 1_600) + let height = min(max(Int((card.frame.height * scale).rounded(.up)), 24), 1_200) + let titleBandHeight = overviewWindowTitleBandHeight( + iconSize: overviewWindowTitleIconSize(cardHeight: card.frame.height) + ) 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) + width: width, + height: height, + blurFadeHeight: Int( + overviewPreviewBlurFadeHeight( + titleBandHeight: titleBandHeight, + imageScale: CGFloat(height) / card.frame.height, + imageHeight: CGFloat(height) + ).rounded(.up) + ) ) ) } @@ -1779,8 +1799,6 @@ private final class OverviewPanel { @MainActor private final class OverviewView: NSView { - private static let windowCornerRadius = 9.0 - let monitorID: MonitorID private weak var delegate: OverviewViewDelegate? private var snapshot: OverviewSnapshot? @@ -1788,6 +1806,7 @@ private final class OverviewView: NSView { private var selection: OverviewSelection? private var drag: OverviewDragPresentation? private var borderStyle = WindowBorderStyle(config: BordersConfig()) + private var windowCornerRadius = 12.0 private var previews: [WindowID: NSImage] = [:] private var previewOpacities: [WindowID: Double] = [:] private var mouseDownPoint: NSPoint? @@ -1817,6 +1836,7 @@ private final class OverviewView: NSView { selection: OverviewSelection?, drag: OverviewDragPresentation?, borderStyle: WindowBorderStyle, + windowCornerRadius: Double, previews: [WindowID: NSImage], previewOpacities: [WindowID: Double] ) { @@ -1825,6 +1845,7 @@ private final class OverviewView: NSView { self.selection = selection self.drag = drag self.borderStyle = borderStyle + self.windowCornerRadius = windowCornerRadius self.previews = previews self.previewOpacities = previewOpacities needsDisplay = true @@ -1836,12 +1857,13 @@ private final class OverviewView: NSView { drawWorkspace(workspace, snapshot: snapshot) } if let overlayWindowID = projection.overlayWindowID, - let overlay = projection.workspaces.lazy.flatMap(\.windows).first(where: { - $0.windowID == overlayWindowID - }) + let workspace = projection.workspaces.first(where: { + $0.windows.contains { $0.windowID == overlayWindowID } + }), + let overlay = workspace.windows.first(where: { $0.windowID == overlayWindowID }) { drawWindow(overlay, snapshot: snapshot) - drawWindowBorder(overlay) + drawWindowBorder(overlay, scale: contentScale(for: workspace, snapshot: snapshot)) } drawDraggedCard(snapshot: snapshot) drawDropTarget() @@ -1854,7 +1876,7 @@ private final class OverviewView: NSView { workspaceElement.setAccessibilityParent(self) workspaceElement.setAccessibilityRole(.group) workspaceElement.setAccessibilityEnabled(true) - workspaceElement.setAccessibilityLabel("Workspace \(workspace.workspaceID.rawValue)") + workspaceElement.setAccessibilityLabel("Workspace \(workspace.label)") workspaceElement.setAccessibilityFrame( window.convertToScreen(convert(nsRect(workspace.frame), to: nil)) ) @@ -1875,7 +1897,7 @@ private final class OverviewView: NSView { element.setAccessibilityLabel( managedWindow.title.isEmpty ? managedWindow.appID : managedWindow.title ) - element.setAccessibilityHelp("Workspace \(workspace.workspaceID.rawValue)") + element.setAccessibilityHelp("Workspace \(workspace.label)") element.setAccessibilitySelected(selection?.windowID == card.windowID) element.setAccessibilityFrame( window.convertToScreen(convert(visibleFrame, to: nil)) @@ -1891,6 +1913,7 @@ private final class OverviewView: NSView { snapshot: OverviewSnapshot ) { let frame = nsRect(workspace.frame) + let scale = contentScale(for: workspace, snapshot: snapshot) NSGraphicsContext.saveGraphicsState() frame.clip() for window in workspace.windows @@ -1902,12 +1925,13 @@ private final class OverviewView: NSView { NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.saveGraphicsState() - frame.insetBy(dx: -borderStyle.width, dy: -borderStyle.width).clip() + let borderWidth = borderStyle.width * scale + frame.insetBy(dx: -borderWidth, dy: -borderWidth).clip() for window in workspace.windows where drag?.windowID != window.windowID && projection?.overlayWindowID != window.windowID { - drawWindowBorder(window) + drawWindowBorder(window, scale: scale) } NSGraphicsContext.restoreGraphicsState() drawHorizontalOverflowIndicators(for: workspace) @@ -1921,11 +1945,18 @@ private final class OverviewView: NSView { let frame = nsRect(card.frame) let path = NSBezierPath( roundedRect: frame, - xRadius: Self.windowCornerRadius, - yRadius: Self.windowCornerRadius + xRadius: windowCornerRadius, + yRadius: windowCornerRadius ) NSColor(calibratedWhite: card.isNativeFullscreen ? 0.19 : 0.15, alpha: 1).setFill() path.fill() + let iconSize = overviewWindowTitleIconSize(cardHeight: frame.height) + let titleBandHeight = overviewWindowTitleBandHeight(iconSize: iconSize) + let titleFadeHeight = overviewPreviewBlurFadeHeight( + titleBandHeight: titleBandHeight, + imageScale: 1, + imageHeight: frame.height + ) if let preview = previews[card.windowID] { let opacity = previewOpacities[card.windowID] ?? 1 NSGraphicsContext.saveGraphicsState() @@ -1938,40 +1969,48 @@ private final class OverviewView: NSView { 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) + colorsAndLocations: + (NSColor.black.withAlphaComponent( + overviewTitleScrimAlpha(progress: 0, opacity: CGFloat(opacity)) + ), 0), + (NSColor.black.withAlphaComponent( + overviewTitleScrimAlpha(progress: 0.25, opacity: CGFloat(opacity)) + ), 0.25), + (NSColor.black.withAlphaComponent( + overviewTitleScrimAlpha(progress: 0.5, opacity: CGFloat(opacity)) + ), 0.5), + (NSColor.black.withAlphaComponent( + overviewTitleScrimAlpha(progress: 0.75, opacity: CGFloat(opacity)) + ), 0.75), + (NSColor.black.withAlphaComponent( + overviewTitleScrimAlpha(progress: 0.9, opacity: CGFloat(opacity)) + ), 0.9), + (NSColor.black.withAlphaComponent( + overviewTitleScrimAlpha(progress: 0.97, opacity: CGFloat(opacity)) + ), 0.97), + (NSColor.clear, 1) )?.draw( from: NSPoint(x: frame.midX, y: frame.minY), - to: NSPoint(x: frame.midX, y: frame.minY + scrimHeight), + to: NSPoint(x: frame.midX, y: frame.minY + titleFadeHeight), 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), - ] + let title = (window.title.isEmpty ? window.appID : window.title) as NSString + let titleAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: min(13, max(frame.height * 0.09, 10)), weight: .medium), + .foregroundColor: NSColor.white.withAlphaComponent(0.9), + ] + let titleLayout = overviewWindowTitleLayout( + cardFrame: frame, + iconSize: iconSize, + titleSize: title.size(withAttributes: titleAttributes), + blurHeight: titleBandHeight ) + let icon = icon(for: window) + icon.draw(in: titleLayout.iconFrame) + title.draw(in: titleLayout.titleFrame, withAttributes: titleAttributes) if card.isNativeFullscreen { let label = "Full Screen" as NSString label.draw( @@ -1984,7 +2023,7 @@ private final class OverviewView: NSView { } } - private func drawWindowBorder(_ card: OverviewWindowProjection) { + private func drawWindowBorder(_ card: OverviewWindowProjection, scale: Double) { let selected = selection == .window( windowID: card.windowID, monitorID: monitorID, @@ -1992,12 +2031,13 @@ private final class OverviewView: NSView { ) if let border = overviewWindowBorderAppearance( isSelected: selected, - style: borderStyle + style: borderStyle, + scale: scale ) { overviewBorderColor(border.color).setStroke() let geometry = overviewWindowBorderGeometry( cardFrame: card.frame, - cardRadius: Self.windowCornerRadius, + cardRadius: windowCornerRadius, width: border.width, placement: borderStyle.placement ) @@ -2011,6 +2051,16 @@ private final class OverviewView: NSView { } } + private func contentScale( + for workspace: OverviewWorkspaceProjection, + snapshot: OverviewSnapshot + ) -> Double { + guard let monitorFrame = snapshot.monitorFrames[monitorID], monitorFrame.height > 0 else { + return 1 + } + return workspace.frame.height / monitorFrame.height + } + private func drawHorizontalOverflowIndicators( for workspace: OverviewWorkspaceProjection ) { @@ -2138,7 +2188,11 @@ private final class OverviewView: NSView { width: drag.cardSize.width, height: drag.cardSize.height ) - let path = NSBezierPath(roundedRect: frame, xRadius: 9, yRadius: 9) + let path = NSBezierPath( + roundedRect: frame, + xRadius: windowCornerRadius, + yRadius: windowCornerRadius + ) NSColor(calibratedWhite: 0.18, alpha: 0.94).setFill() path.fill() NSColor.controlAccentColor.setStroke() @@ -2270,6 +2324,45 @@ private final class OverviewView: NSView { } } +func overviewWindowTitleIconSize(cardHeight: CGFloat) -> CGFloat { + min(24, max(cardHeight * 0.16, 14)) +} + +func overviewWindowTitleBandHeight(iconSize: CGFloat) -> CGFloat { + iconSize + 20 +} + +func overviewTitleScrimAlpha(progress: CGFloat, opacity: CGFloat) -> CGFloat { + let remaining = 1 - min(max(progress, 0), 1) + return 0.48 * opacity * remaining * remaining +} + +func overviewWindowTitleLayout( + cardFrame: CGRect, + iconSize: CGFloat, + titleSize: CGSize, + blurHeight: CGFloat +) -> (iconFrame: CGRect, titleFrame: CGRect) { + let spacing = 8.0 + let titleWidth = min(titleSize.width, max(cardFrame.width - iconSize - spacing - 20, 1)) + let iconX = cardFrame.minX + 10 + let centerY = cardFrame.minY + blurHeight / 2 + return ( + CGRect( + x: iconX, + y: centerY - iconSize / 2, + width: iconSize, + height: iconSize + ), + CGRect( + x: iconX + iconSize + spacing, + y: centerY - titleSize.height / 2, + width: titleWidth, + height: titleSize.height + ) + ) +} + @MainActor private final class OverviewAccessibilityElement: NSAccessibilityElement { private nonisolated let press: @MainActor @Sendable () -> Void diff --git a/Sources/DefiMacOS/OverviewPreviewCapture.swift b/Sources/DefiMacOS/OverviewPreviewCapture.swift index ab17713..53369e1 100644 --- a/Sources/DefiMacOS/OverviewPreviewCapture.swift +++ b/Sources/DefiMacOS/OverviewPreviewCapture.swift @@ -6,11 +6,18 @@ import ScreenCaptureKit private let overviewPreviewCIContext = CIContext(options: [.cacheIntermediates: false]) -func overviewPreviewBlurFadeHeight(imageHeight: CGFloat) -> CGFloat { - min(max(imageHeight * 0.24, 72), 144) +func overviewPreviewBlurFadeHeight( + titleBandHeight: CGFloat, + imageScale: CGFloat, + imageHeight: CGFloat +) -> CGFloat { + min(max((titleBandHeight + 20) * imageScale, 1), imageHeight) } -func progressivelyBlurredOverviewPreview(_ image: CGImage) -> CGImage? { +func progressivelyBlurredOverviewPreview( + _ image: CGImage, + fadeHeight requestedFadeHeight: CGFloat +) -> CGImage? { let source = CIImage(cgImage: image) let extent = source.extent guard extent.width > 1, extent.height > 1, @@ -18,7 +25,7 @@ func progressivelyBlurredOverviewPreview(_ image: CGImage) -> CGImage? { let blur = CIFilter(name: "CIMaskedVariableBlur") else { return nil } - let fadeHeight = overviewPreviewBlurFadeHeight(imageHeight: extent.height) + let fadeHeight = min(max(requestedFadeHeight, 1), extent.height) gradient.setValue( CIVector(x: extent.midX, y: extent.maxY), forKey: "inputPoint0" @@ -61,6 +68,7 @@ struct OverviewPreviewRequest: Equatable, Sendable { let expectedAppID: String let width: Int let height: Int + let blurFadeHeight: Int } struct OverviewPreviewCaptureResult: Sendable { @@ -213,7 +221,10 @@ private final class OverviewScreenCaptureBatch { configuration: configuration ) let styledImage = await Task.detached(priority: .userInitiated) { - progressivelyBlurredOverviewPreview(image) ?? image + progressivelyBlurredOverviewPreview( + image, + fadeHeight: CGFloat(request.blurFadeHeight) + ) ?? image }.value return OverviewPreviewCaptureResult(request: request, image: styledImage) } catch { diff --git a/Sources/DefiMacOS/WindowBorderPlanning.swift b/Sources/DefiMacOS/WindowBorderPlanning.swift index 78f1bdc..3685c64 100644 --- a/Sources/DefiMacOS/WindowBorderPlanning.swift +++ b/Sources/DefiMacOS/WindowBorderPlanning.swift @@ -143,16 +143,18 @@ func planWindowBorders( func overviewWindowBorderAppearance( isSelected: Bool, - style: WindowBorderStyle + style: WindowBorderStyle, + scale: Double ) -> (color: UInt32, width: Double)? { - guard style.enabled, style.width > 0 else { return nil } + let width = style.width * scale + guard style.enabled, width > 0 else { return nil } if isSelected, windowBorderAlpha(of: style.activeColor) > 0 { - return (style.activeColor, style.width) + return (style.activeColor, width) } guard style.inactiveEnabled, windowBorderAlpha(of: style.inactiveColor) > 0 else { return nil } - return (style.inactiveColor, style.width) + return (style.inactiveColor, width) } func overviewWindowBorderGeometry( diff --git a/Sources/DefiModel/Command.swift b/Sources/DefiModel/Command.swift index a6d0a4d..6b95c50 100644 --- a/Sources/DefiModel/Command.swift +++ b/Sources/DefiModel/Command.swift @@ -11,6 +11,12 @@ public enum Direction: String, Equatable, Codable, Sendable { case last } +public enum WorkspaceTarget: Equatable, Codable, Sendable { + case relative(Direction) + case position(Int) + case named(String) +} + public enum Command: Equatable, Codable, Sendable { case focusColumn(Direction) case focusFloating(Direction) @@ -22,6 +28,12 @@ public enum Command: Equatable, Codable, Sendable { case moveWindowToWorkspace(WorkspaceID) case sendWindowToWorkspace(WorkspaceID) case switchWorkspace(WorkspaceID) + case focusWorkspace(WorkspaceTarget) + case moveColumnToWorkspace(WorkspaceTarget, follow: Bool) + case moveWindowToWorkspaceTarget(WorkspaceTarget, follow: Bool) + case reorderWorkspace(Direction) + case moveWorkspaceToMonitor(Direction) + case focusMonitor(Direction) case cycleWidth(Direction) case maximizeColumn case toggleFloating @@ -34,7 +46,7 @@ public enum Command: Equatable, Codable, Sendable { public var resizesManagedLayout: Bool { switch self { case .cycleWidth, .maximizeColumn, .joinWindow, .unjoinWindows, - .moveColumnToMonitor, .moveWindowToMonitor: + .moveColumnToMonitor, .moveWindowToMonitor, .moveWorkspaceToMonitor: true default: false @@ -43,7 +55,10 @@ public enum Command: Equatable, Codable, Sendable { public var activatesWorkspace: Bool { switch self { - case .switchWorkspace, .moveWindowToWorkspace: + case .switchWorkspace, .moveWindowToWorkspace, .focusWorkspace, + .moveColumnToWorkspace(_, follow: true), + .moveWindowToWorkspaceTarget(_, follow: true), .moveWorkspaceToMonitor, + .focusMonitor: true default: false @@ -52,7 +67,8 @@ public enum Command: Equatable, Codable, Sendable { public var movesWindowBetweenWorkspaces: Bool { switch self { - case .moveWindowToWorkspace, .sendWindowToWorkspace: + case .moveWindowToWorkspace, .sendWindowToWorkspace, + .moveColumnToWorkspace, .moveWindowToWorkspaceTarget: true default: false @@ -70,7 +86,20 @@ public enum Command: Equatable, Codable, Sendable { public var movesWindowsAcrossMonitors: Bool { switch self { - case .moveColumnToMonitor, .moveWindowToMonitor: + case .moveColumnToMonitor, .moveWindowToMonitor, .moveWorkspaceToMonitor, + .moveWindowToWorkspace, .sendWindowToWorkspace, + .moveColumnToWorkspace, .moveWindowToWorkspaceTarget: + true + default: + false + } + } + + public var followsWindowMove: Bool { + switch self { + case .moveColumnToMonitor, .moveWindowToMonitor, .moveWindowToWorkspace, + .moveColumnToWorkspace(_, follow: true), + .moveWindowToWorkspaceTarget(_, follow: true), .moveWorkspaceToMonitor: true default: false diff --git a/Sources/DefiModel/CommandParser.swift b/Sources/DefiModel/CommandParser.swift index 88dc3fd..5a8c372 100644 --- a/Sources/DefiModel/CommandParser.swift +++ b/Sources/DefiModel/CommandParser.swift @@ -5,6 +5,7 @@ public enum CommandParseError: Error, Equatable, Sendable { case unknownCommand(String) case missingArgument(String) case invalidDirection(String) + case invalidPosition(String) } public func parseCommand(_ input: String) throws -> Command { @@ -48,11 +49,66 @@ public func parseCommand(_ input: String) throws -> Command { case "focus-window": return .focusWindow(try parseDirection(argument("direction"))) case "move-window-to-workspace": - return .moveWindowToWorkspace(WorkspaceID(rawValue: try argument("workspace"))) + let value = try argument("workspace") + if value == "up" || value == "down" { + return .moveWindowToWorkspaceTarget(try parseWorkspaceTarget(value), follow: true) + } + return .moveWindowToWorkspace(WorkspaceID(rawValue: value)) case "send-window-to-workspace": - return .sendWindowToWorkspace(WorkspaceID(rawValue: try argument("workspace"))) + let value = try argument("workspace") + if value == "up" || value == "down" { + return .moveWindowToWorkspaceTarget(try parseWorkspaceTarget(value), follow: false) + } + return .sendWindowToWorkspace(WorkspaceID(rawValue: value)) + case "move-column-to-workspace": + return .moveColumnToWorkspace( + try parseWorkspaceTarget(argument("workspace")), + follow: true + ) + case "move-column-to-workspace-name": + return .moveColumnToWorkspace(.named(try argument("workspace")), follow: true) + case "send-column-to-workspace": + return .moveColumnToWorkspace( + try parseWorkspaceTarget(argument("workspace")), + follow: false + ) + case "send-column-to-workspace-name": + return .moveColumnToWorkspace(.named(try argument("workspace")), follow: false) + case "move-column-to-workspace-position": + return .moveColumnToWorkspace( + try parseWorkspacePosition(argument("position")), + follow: true + ) + case "move-window-to-workspace-position": + return .moveWindowToWorkspaceTarget( + try parseWorkspacePosition(argument("position")), + follow: true + ) + case "send-window-to-workspace-position": + return .moveWindowToWorkspaceTarget( + try parseWorkspacePosition(argument("position")), + follow: false + ) + case "move-window-to-workspace-name": + return .moveWindowToWorkspaceTarget(.named(try argument("workspace")), follow: true) + case "send-window-to-workspace-name": + return .moveWindowToWorkspaceTarget(.named(try argument("workspace")), follow: false) case "workspace": return .switchWorkspace(WorkspaceID(rawValue: try argument("workspace"))) + case "focus-workspace": + return .focusWorkspace(try parseRelativeWorkspace(argument("direction"))) + case "focus-workspace-position": + return .focusWorkspace(try parseWorkspacePosition(argument("position"))) + case "focus-workspace-name": + return .focusWorkspace(.named(try argument("workspace"))) + case "reorder-workspace": + return .reorderWorkspace(try parseVerticalDirection(argument("direction"))) + case "move-workspace-to-monitor": + return .moveWorkspaceToMonitor( + try parseSpatialDirection(argument("direction")) + ) + case "focus-monitor": + return .focusMonitor(try parseSpatialDirection(argument("direction"))) case "cycle-width": return .cycleWidth(try parseDirection(argument("direction"))) case "maximize-column": @@ -74,6 +130,32 @@ public func parseCommand(_ input: String) throws -> Command { } } +private func parseWorkspaceTarget(_ input: String) throws -> WorkspaceTarget { + if input == "up" || input == "down" { + return try parseRelativeWorkspace(input) + } + return .named(input) +} + +private func parseRelativeWorkspace(_ input: String) throws -> WorkspaceTarget { + .relative(try parseVerticalDirection(input)) +} + +private func parseWorkspacePosition(_ input: String) throws -> WorkspaceTarget { + guard let position = Int(input), position > 0 else { + throw CommandParseError.invalidPosition(input) + } + return .position(position) +} + +private func parseVerticalDirection(_ input: String) throws -> Direction { + let direction = try parseDirection(input) + guard [.up, .down].contains(direction) else { + throw CommandParseError.invalidDirection(input) + } + return direction +} + private func parseSpatialDirection(_ input: String) throws -> Direction { let direction = try parseDirection(input) guard [.left, .right, .up, .down].contains(direction) else { diff --git a/Sources/DefiModel/Identifiers.swift b/Sources/DefiModel/Identifiers.swift index 560da53..a7069bd 100644 --- a/Sources/DefiModel/Identifiers.swift +++ b/Sources/DefiModel/Identifiers.swift @@ -9,6 +9,8 @@ public struct WindowID: RawRepresentable, Hashable, Codable, Sendable { } public struct WorkspaceID: RawRepresentable, Hashable, Codable, Sendable, CustomStringConvertible { + public static let dynamicPrefix = "__defi_dynamic_" + public let rawValue: String public init(rawValue: String) { diff --git a/Sources/DefiModel/Workspace.swift b/Sources/DefiModel/Workspace.swift index 069301b..23920e8 100644 --- a/Sources/DefiModel/Workspace.swift +++ b/Sources/DefiModel/Workspace.swift @@ -33,6 +33,10 @@ public struct Column: Equatable, Codable, Sendable { public struct Workspace: Equatable, Codable, Sendable { public let id: WorkspaceID + public var kind: WorkspaceKind + public var name: String? + public var affinity: MonitorID? + public var affinityPosition: Int public var columns: [Column] public var floatingWindows: [WindowID] public var focusedFloatingWindow: Int @@ -43,6 +47,10 @@ public struct Workspace: Equatable, Codable, Sendable { public init( id: WorkspaceID, + kind: WorkspaceKind = .named, + name: String? = nil, + affinity: MonitorID? = nil, + affinityPosition: Int = 0, columns: [Column] = [], floatingWindows: [WindowID] = [], focusedFloatingWindow: Int = 0, @@ -52,6 +60,10 @@ public struct Workspace: Equatable, Codable, Sendable { targetScrollOffset: Double = 0 ) { self.id = id + self.kind = kind + self.name = name ?? (kind == .named ? id.rawValue : nil) + self.affinity = affinity + self.affinityPosition = affinityPosition self.columns = columns self.floatingWindows = floatingWindows self.focusedFloatingWindow = focusedFloatingWindow @@ -60,6 +72,17 @@ public struct Workspace: Equatable, Codable, Sendable { self.scrollOffset = scrollOffset self.targetScrollOffset = targetScrollOffset } + + public var isEmpty: Bool { + columns.isEmpty && floatingWindows.isEmpty + } + +} + +public enum WorkspaceKind: String, Equatable, Codable, Sendable { + case named + case ordinary + case trailing } public enum WindowFocusLayer: String, Equatable, Codable, Sendable { diff --git a/Sources/DefiRuntime/CommandReducer.swift b/Sources/DefiRuntime/CommandReducer.swift index 47fc5d1..e6421dc 100644 --- a/Sources/DefiRuntime/CommandReducer.swift +++ b/Sources/DefiRuntime/CommandReducer.swift @@ -175,25 +175,68 @@ public func reduce( ) } case .switchWorkspace(let workspaceID): - guard state.monitors[monitorIndex].workspaces.contains(where: { $0.id == workspaceID }) - else { + guard let target = state.workspaceLocation(for: workspaceID) else { throw ReducerError.unknownWorkspace(workspaceID) } - state.monitors[monitorIndex].activeWorkspace = workspaceID + state.monitors[target.monitorIndex].activeWorkspace = workspaceID + case .focusWorkspace(let target): + guard let destination = state.resolveWorkspaceTarget(target, on: monitorIndex) else { + throw ReducerError.unknownWorkspace(workspaceID(for: target)) + } + state.monitors[destination.monitorIndex].activeWorkspace = + state.monitors[destination.monitorIndex].workspaces[destination.workspaceIndex].id case .moveWindowToWorkspace(let workspaceID): - try moveFocusedWindow( - to: workspaceID, + try moveFocusedSelectionToWorkspace( + .named(workspaceID.rawValue), + movesWholeColumn: false, follow: true, - monitorIndex: monitorIndex, + sourceMonitorIndex: monitorIndex, + viewports: viewports, state: &state ) case .sendWindowToWorkspace(let workspaceID): - try moveFocusedWindow( - to: workspaceID, + try moveFocusedSelectionToWorkspace( + .named(workspaceID.rawValue), + movesWholeColumn: false, follow: false, + sourceMonitorIndex: monitorIndex, + viewports: viewports, + state: &state + ) + case .moveColumnToWorkspace(let target, let follow): + try moveFocusedSelectionToWorkspace( + target, + movesWholeColumn: true, + follow: follow, + sourceMonitorIndex: monitorIndex, + viewports: viewports, + state: &state + ) + case .moveWindowToWorkspaceTarget(let target, let follow): + try moveFocusedSelectionToWorkspace( + target, + movesWholeColumn: false, + follow: follow, + sourceMonitorIndex: monitorIndex, + viewports: viewports, + state: &state + ) + case .reorderWorkspace(let direction): + try reorderActiveWorkspace( + direction, monitorIndex: monitorIndex, state: &state ) + case .moveWorkspaceToMonitor(let direction): + try moveActiveWorkspaceToMonitor( + direction, + sourceMonitorIndex: monitorIndex, + monitorFrames: monitorFrames, + viewports: viewports, + state: &state + ) + case .focusMonitor: + break case .joinWindow(let direction): let index = try workspaceIndex(state.monitors[monitorIndex]) let workspace = state.monitors[monitorIndex].workspaces[index] @@ -238,6 +281,7 @@ public func reduce( } catch let error as LayoutError { throw ReducerError.layout(error) } + state.maintainWorkspaceLifecycle() normalizeNativeFullscreenColumns(state: &state) } @@ -268,9 +312,12 @@ private func commandMutatesNativeFullscreenSelection(_ command: Command) -> Bool switch command { case .moveColumn, .moveWindow, .moveColumnToMonitor, .moveWindowToMonitor, .moveWindowToWorkspace, .sendWindowToWorkspace, .cycleWidth, + .moveColumnToWorkspace, .moveWindowToWorkspaceTarget, + .reorderWorkspace, .moveWorkspaceToMonitor, .maximizeColumn, .toggleFloating, .joinWindow, .unjoinWindows: true case .focusColumn, .focusFloating, .focusWindow, .switchWorkspace, + .focusWorkspace, .focusMonitor, .activateFloating, .toggleOverview, .runStartupCommands: false } @@ -303,11 +350,12 @@ private func moveFocusedSelectionToMonitor( state: inout RuntimeState ) throws { let sourceMonitorID = state.monitors[sourceMonitorIndex].id - guard let targetMonitorID = spatialMonitor( - from: sourceMonitorID, - toward: direction, - frames: monitorFrames - ), + guard + let targetMonitorID = spatialMonitor( + from: sourceMonitorID, + toward: direction, + frames: monitorFrames + ), let targetMonitorIndex = state.monitors.firstIndex(where: { $0.id == targetMonitorID }), @@ -319,16 +367,83 @@ private func moveFocusedSelectionToMonitor( ) else { return } + try moveFocusedSelection( + movesWholeColumn: movesWholeColumn, + follow: true, + preservesUserFloatingPlacement: true, + sourceMonitorIndex: sourceMonitorIndex, + sourceWorkspaceIndex: sourceWorkspaceIndex, + targetMonitorIndex: targetMonitorIndex, + targetWorkspaceIndex: targetWorkspaceIndex, + monitorFrames: monitorFrames, + viewports: viewports, + state: &state + ) +} + +private func moveFocusedSelectionToWorkspace( + _ target: WorkspaceTarget, + movesWholeColumn: Bool, + follow: Bool, + sourceMonitorIndex: Int, + viewports: [MonitorID: Rect], + state: inout RuntimeState +) throws { + guard + let sourceWorkspaceIndex = state.monitors[sourceMonitorIndex].workspaces.firstIndex( + where: { $0.id == state.monitors[sourceMonitorIndex].activeWorkspace } + ) + else { + throw ReducerError.unknownWorkspace(state.monitors[sourceMonitorIndex].activeWorkspace) + } + guard let destination = state.resolveWorkspaceTarget(target, on: sourceMonitorIndex) else { + throw ReducerError.unknownWorkspace(workspaceID(for: target)) + } + guard + sourceMonitorIndex != destination.monitorIndex + || sourceWorkspaceIndex != destination.workspaceIndex + else { return } + try moveFocusedSelection( + movesWholeColumn: movesWholeColumn, + follow: follow, + preservesUserFloatingPlacement: false, + sourceMonitorIndex: sourceMonitorIndex, + sourceWorkspaceIndex: sourceWorkspaceIndex, + targetMonitorIndex: destination.monitorIndex, + targetWorkspaceIndex: destination.workspaceIndex, + monitorFrames: viewports, + viewports: viewports, + state: &state + ) +} + +private func moveFocusedSelection( + movesWholeColumn: Bool, + follow: Bool, + preservesUserFloatingPlacement: Bool, + sourceMonitorIndex: Int, + sourceWorkspaceIndex: Int, + targetMonitorIndex: Int, + targetWorkspaceIndex: Int, + monitorFrames: [MonitorID: Rect], + viewports: [MonitorID: Rect], + state: inout RuntimeState +) throws { + let sourceMonitorID = state.monitors[sourceMonitorIndex].id + let targetMonitorID = state.monitors[targetMonitorIndex].id + let sourceWorkspace = state.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex] - guard let selectedWindowID = movesWholeColumn - ? state.selectedTiledWindowID(on: sourceMonitorID) - : state.selectedWindowID(on: sourceMonitorID) + guard + let selectedWindowID = movesWholeColumn + ? state.selectedTiledWindowID(on: sourceMonitorID) + : state.selectedWindowID(on: sourceMonitorID) else { return } let rootWindowID = transientRootWindowID(selectedWindowID, windows: state.windows) let rootColumnIndex = sourceWorkspace.columns.firstIndex { $0.windows.contains(rootWindowID) } - let movedColumnIndex = movesWholeColumn + let movedColumnIndex = + movesWholeColumn ? sourceWorkspace.columns.firstIndex { $0.windows.contains(selectedWindowID) } @@ -349,9 +464,11 @@ private func moveFocusedSelectionToMonitor( ) } } - let ownerWindowIDs = Set(primaryWindowIDs.map { - transientRootWindowID($0, windows: state.windows) - }) + let ownerWindowIDs = Set( + primaryWindowIDs.map { + transientRootWindowID($0, windows: state.windows) + } + ) let chainWindowIDs = primaryWindowIDs.union(ownerWindowIDs) let movedWindowIDs = transientDescendants( of: chainWindowIDs, @@ -365,10 +482,11 @@ private func moveFocusedSelectionToMonitor( let topologyWindowIDs = state.monitors.flatMap(\.workspaces).flatMap { $0.columns.flatMap(\.windows) + $0.floatingWindows } - let orderedMovedWindowIDs = topologyWindowIDs.filter(movedWindowIDs.contains) - + movedWindowIDs.subtracting(topologyWindowIDs).sorted { - $0.rawValue < $1.rawValue - } + let orderedMovedWindowIDs = + topologyWindowIDs.filter(movedWindowIDs.contains) + + movedWindowIDs.subtracting(topologyWindowIDs).sorted { + $0.rawValue < $1.rawValue + } if let movedColumnIndex { state.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex] @@ -376,13 +494,14 @@ private func moveFocusedSelectionToMonitor( let remainingColumnCount = state.monitors[sourceMonitorIndex] .workspaces[sourceWorkspaceIndex].columns.count state.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex] - .focusedColumn = remainingColumnCount == 0 - ? 0 - : min( - state.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex] - .focusedColumn, - remainingColumnCount - 1 - ) + .focusedColumn = + remainingColumnCount == 0 + ? 0 + : min( + state.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex] + .focusedColumn, + remainingColumnCount - 1 + ) repairWorkspaceScroll( &state.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex], settings: state.layout @@ -400,9 +519,10 @@ private func moveFocusedSelectionToMonitor( let sourceViewport = viewports[sourceMonitorID] ?? monitorFrames[sourceMonitorID] let targetViewport = viewports[targetMonitorID] ?? monitorFrames[targetMonitorID] - let widthScale = sourceViewport.flatMap { source in - targetViewport.map { $0.width / max(source.width, 1) } - } ?? 1 + let widthScale = + sourceViewport.flatMap { source in + targetViewport.map { $0.width / max(source.width, 1) } + } ?? 1 var transferredColumnIndex: Int? if var column = transferredColumn { scalePixelWidths(in: &column, by: widthScale) @@ -471,24 +591,32 @@ private func moveFocusedSelectionToMonitor( state.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex] .focusedLayer = .tiled } - state.monitors[targetMonitorIndex].activeWorkspace = - state.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex].id + if follow { + state.monitors[targetMonitorIndex].activeWorkspace = + state.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex].id + } repairWorkspaceScroll( &state.monitors[targetMonitorIndex].workspaces[targetWorkspaceIndex], settings: state.layout ) for windowID in orderedMovedWindowIDs { if let placement = state.suspendedTiledPlacements[windowID] { - var column = placement.column - scalePixelWidths(in: &column, by: widthScale) - state.suspendedTiledPlacements[windowID] = SuspendedTiledPlacement( - monitorID: targetMonitorID, - workspaceID: state.monitors[targetMonitorIndex] - .workspaces[targetWorkspaceIndex].id, - columnIndex: transferredColumnIndex ?? placement.columnIndex, - windowIndex: placement.windowIndex, - column: column - ) + if preservesUserFloatingPlacement + || state.windows[windowID]?.floatingOrigin == .automatic + { + var column = placement.column + scalePixelWidths(in: &column, by: widthScale) + state.suspendedTiledPlacements[windowID] = SuspendedTiledPlacement( + monitorID: targetMonitorID, + workspaceID: state.monitors[targetMonitorIndex] + .workspaces[targetWorkspaceIndex].id, + columnIndex: transferredColumnIndex ?? placement.columnIndex, + windowIndex: placement.windowIndex, + column: column + ) + } else { + state.suspendedTiledPlacements[windowID] = nil + } } state.windows[windowID]?.monitorID = targetMonitorID if state.windows[windowID]?.floating == true, @@ -503,6 +631,129 @@ private func moveFocusedSelectionToMonitor( ) } } + state.maintainWorkspaceLifecycle() +} + +private func workspaceID(for target: WorkspaceTarget) -> WorkspaceID { + switch target { + case .named(let name): + WorkspaceID(rawValue: name) + case .position(let position): + WorkspaceID(rawValue: "position-\(position)") + case .relative(let direction): + WorkspaceID(rawValue: direction.rawValue) + } +} + +private func reorderActiveWorkspace( + _ direction: Direction, + monitorIndex: Int, + state: inout RuntimeState +) throws { + guard + let sourceIndex = state.monitors[monitorIndex].workspaces.firstIndex(where: { + $0.id == state.monitors[monitorIndex].activeWorkspace + }) + else { + throw ReducerError.unknownWorkspace(state.monitors[monitorIndex].activeWorkspace) + } + guard state.monitors[monitorIndex].workspaces[sourceIndex].kind != .trailing else { return } + let targetIndex: Int + switch direction { + case .up: + targetIndex = sourceIndex - 1 + case .down: + targetIndex = sourceIndex + 1 + default: + return + } + guard targetIndex >= 0, + targetIndex < state.monitors[monitorIndex].workspaces.count, + state.monitors[monitorIndex].workspaces[targetIndex].kind != .trailing + else { return } + state.monitors[monitorIndex].workspaces.swapAt(sourceIndex, targetIndex) + state.refreshAffinityPositions(on: monitorIndex) +} + +private func moveActiveWorkspaceToMonitor( + _ direction: Direction, + sourceMonitorIndex: Int, + monitorFrames: [MonitorID: Rect], + viewports: [MonitorID: Rect], + state: inout RuntimeState +) throws { + let sourceMonitorID = state.monitors[sourceMonitorIndex].id + guard + let targetMonitorID = spatialMonitor( + from: sourceMonitorID, + toward: direction, + frames: monitorFrames + ), + let targetMonitorIndex = state.monitors.firstIndex(where: { + $0.id == targetMonitorID + }), + let sourceWorkspaceIndex = state.monitors[sourceMonitorIndex].workspaces.firstIndex(where: { + $0.id == state.monitors[sourceMonitorIndex].activeWorkspace + }), + state.monitors[sourceMonitorIndex].workspaces[sourceWorkspaceIndex].kind != .trailing + else { return } + + var workspace = state.monitors[sourceMonitorIndex].workspaces.remove( + at: sourceWorkspaceIndex + ) + let scale = + (viewports[targetMonitorID] ?? monitorFrames[targetMonitorID]).flatMap { + target in + (viewports[sourceMonitorID] ?? monitorFrames[sourceMonitorID]).map { + target.width / max($0.width, 1) + } + } ?? 1 + for columnIndex in workspace.columns.indices { + scalePixelWidths(in: &workspace.columns[columnIndex], by: scale) + } + workspace.affinity = targetMonitorID + workspace.affinityPosition = state.monitors[targetMonitorIndex].workspaces.count - 1 + let targetWorkspaceIndex = + state.monitors[targetMonitorIndex].workspaces.firstIndex(where: { + $0.kind == .trailing + }) ?? state.monitors[targetMonitorIndex].workspaces.count + state.monitors[targetMonitorIndex].workspaces.insert(workspace, at: targetWorkspaceIndex) + state.monitors[targetMonitorIndex].activeWorkspace = workspace.id + if !state.monitors[sourceMonitorIndex].workspaces.isEmpty { + state.monitors[sourceMonitorIndex].activeWorkspace = + state.monitors[sourceMonitorIndex].workspaces[ + min(sourceWorkspaceIndex, state.monitors[sourceMonitorIndex].workspaces.count - 1) + ].id + } + let movedWindowIDs = workspace.columns.flatMap(\.windows) + workspace.floatingWindows + for windowID in movedWindowIDs { + state.windows[windowID]?.monitorID = targetMonitorID + if let placement = state.suspendedTiledPlacements[windowID] { + var column = placement.column + scalePixelWidths(in: &column, by: scale) + state.suspendedTiledPlacements[windowID] = SuspendedTiledPlacement( + monitorID: targetMonitorID, + workspaceID: workspace.id, + columnIndex: placement.columnIndex, + windowIndex: placement.windowIndex, + column: column + ) + } + if let placement = state.nativeFullscreenTiledPlacements[windowID] { + var column = placement.column + scalePixelWidths(in: &column, by: scale) + state.nativeFullscreenTiledPlacements[windowID] = SuspendedTiledPlacement( + monitorID: targetMonitorID, + workspaceID: workspace.id, + columnIndex: placement.columnIndex, + windowIndex: placement.windowIndex, + column: column + ) + } + } + state.refreshAffinityPositions(on: sourceMonitorIndex) + state.refreshAffinityPositions(on: targetMonitorIndex) + state.maintainWorkspaceLifecycle() } func transientRootWindowID( @@ -527,13 +778,15 @@ func transientDescendants( var descendants = Set() var owners = ownerWindowIDs while true { - let next = Set(windows.compactMap { windowID, window in - window.transientOwnerID.map(owners.contains) == true - && !ownerWindowIDs.contains(windowID) - && !descendants.contains(windowID) - ? windowID - : nil - }) + let next = Set( + windows.compactMap { windowID, window in + window.transientOwnerID.map(owners.contains) == true + && !ownerWindowIDs.contains(windowID) + && !descendants.contains(windowID) + ? windowID + : nil + } + ) guard !next.isEmpty else { return descendants } descendants.formUnion(next) owners = next @@ -552,9 +805,10 @@ func groupedTiledColumns( movedWindowIDs.contains($0) && !excludedWindowIDs.contains($0) } guard let firstWindowID = windows.first else { continue } - let focusedWindowID = column.windows.indices.contains(column.focusedWindow) - ? column.windows[column.focusedWindow] - : nil + let focusedWindowID = + column.windows.indices.contains(column.focusedWindow) + ? column.windows[column.focusedWindow] + : nil columns[firstWindowID] = Column( windows: windows, focusedWindow: focusedWindowID.flatMap(windows.firstIndex(of:)) @@ -582,118 +836,6 @@ func removeWindowFromEveryWorkspace( } } -private func moveFocusedWindow( - to workspaceID: WorkspaceID, - follow: Bool, - monitorIndex: Int, - state: inout RuntimeState -) throws { - let sourceIndex = state.monitors[monitorIndex].workspaces.firstIndex( - where: { $0.id == state.monitors[monitorIndex].activeWorkspace } - ) - let targetIndex = state.monitors[monitorIndex].workspaces.firstIndex( - where: { $0.id == workspaceID } - ) - guard let sourceIndex, let targetIndex else { - throw ReducerError.unknownWorkspace(workspaceID) - } - guard sourceIndex != targetIndex else { return } - let source = state.monitors[monitorIndex].workspaces[sourceIndex] - let selectedWindowID: WindowID - if let floatingWindowID = effectiveSelectedFloatingWindowID(in: source) { - selectedWindowID = floatingWindowID - } else { - guard source.columns.indices.contains(source.focusedColumn) else { - throw ReducerError.noFocusedWindow - } - let column = source.columns[source.focusedColumn] - guard column.windows.indices.contains(column.focusedWindow) else { - throw ReducerError.noFocusedWindow - } - selectedWindowID = column.windows[column.focusedWindow] - } - - let rootWindowID = transientRootWindowID(selectedWindowID, windows: state.windows) - let movedWindowIDs = transientDescendants(of: [rootWindowID], windows: state.windows) - .union([rootWindowID]) - let orderedMovedWindowIDs = state.monitors[monitorIndex].workspaces.flatMap { - $0.columns.flatMap(\.windows) + $0.floatingWindows - }.filter(movedWindowIDs.contains) - let tiledColumns = groupedTiledColumns( - moving: movedWindowIDs, - from: source - ) - - for windowID in orderedMovedWindowIDs { - removeWindowFromEveryWorkspace(windowID, state: &state) - } - var insertionIndex = min( - state.monitors[monitorIndex].workspaces[targetIndex].focusedColumn + 1, - state.monitors[monitorIndex].workspaces[targetIndex].columns.count - ) - var selectedTiledColumnIndex: Int? - for windowID in orderedMovedWindowIDs { - if state.windows[windowID]?.floating == true - && state.windows[windowID]?.forceTiling != true - { - state.monitors[monitorIndex].workspaces[targetIndex].floatingWindows.append(windowID) - } else if let column = tiledColumns.byFirstWindowID[windowID] { - state.monitors[monitorIndex].workspaces[targetIndex].columns.insert( - column, - at: insertionIndex - ) - if column.windows.contains(selectedWindowID) { - selectedTiledColumnIndex = insertionIndex - } - insertionIndex += 1 - } else if tiledColumns.windowIDs.contains(windowID) == false { - state.monitors[monitorIndex].workspaces[targetIndex].columns.insert( - Column( - window: windowID, - width: .fraction(state.layout.defaultColumnWidth) - ), - at: insertionIndex - ) - if windowID == selectedWindowID { - selectedTiledColumnIndex = insertionIndex - } - insertionIndex += 1 - } - if let placement = state.suspendedTiledPlacements[windowID] { - if state.windows[windowID]?.floatingOrigin == .automatic { - state.suspendedTiledPlacements[windowID] = SuspendedTiledPlacement( - monitorID: placement.monitorID, - workspaceID: workspaceID, - columnIndex: placement.columnIndex, - windowIndex: placement.windowIndex, - column: placement.column - ) - } else { - state.suspendedTiledPlacements[windowID] = nil - } - } - } - if let selectedTiledColumnIndex { - state.monitors[monitorIndex].workspaces[targetIndex].focusedColumn = - selectedTiledColumnIndex - repairWorkspaceScroll( - &state.monitors[monitorIndex].workspaces[targetIndex], - settings: state.layout - ) - } - if let selectedIndex = state.monitors[monitorIndex].workspaces[targetIndex] - .floatingWindows.firstIndex(of: selectedWindowID) - { - state.monitors[monitorIndex].workspaces[targetIndex].focusedFloatingWindow = selectedIndex - state.monitors[monitorIndex].workspaces[targetIndex].focusedLayer = .floating - } else { - state.monitors[monitorIndex].workspaces[targetIndex].focusedLayer = .tiled - } - if follow { - state.monitors[monitorIndex].activeWorkspace = workspaceID - } -} - private func toggleFocusedFloating( monitorIndex: Int, state: inout RuntimeState diff --git a/Sources/DefiRuntime/FocusSelection.swift b/Sources/DefiRuntime/FocusSelection.swift index 0615fe6..4b294f7 100644 --- a/Sources/DefiRuntime/FocusSelection.swift +++ b/Sources/DefiRuntime/FocusSelection.swift @@ -21,6 +21,7 @@ public func focusWindow( state.monitors[monitorIndex].workspaces[workspaceIndex].focusedLayer = .floating state.monitors[monitorIndex].activeWorkspace = state.monitors[monitorIndex].workspaces[workspaceIndex].id + state.maintainWorkspaceLifecycle() return activatedWorkspace } for columnIndex in state.monitors[monitorIndex].workspaces[workspaceIndex].columns.indices { @@ -41,6 +42,7 @@ public func focusWindow( .workspaces[workspaceIndex] .columns[columnIndex] .focusedWindow = windowIndex + state.maintainWorkspaceLifecycle() return activatedWorkspace } } diff --git a/Sources/DefiRuntime/MouseReordering.swift b/Sources/DefiRuntime/MouseReordering.swift index f66d8c0..18849ab 100644 --- a/Sources/DefiRuntime/MouseReordering.swift +++ b/Sources/DefiRuntime/MouseReordering.swift @@ -449,10 +449,13 @@ public func desktopSynchronizationIsReady( periodicSyncDue: Bool, commandQuietPeriodElapsed: Bool, nativeFocusSyncPending: Bool = false, + nativeFocusHasNewerHumanIntent: Bool = true, frameDebtPending: Bool = false, lifecycleEventPending: Bool = false ) -> Bool { - guard !scrollAnimationActive || nativeFocusSyncPending, + let nativeFocusPreemptsAnimation = + nativeFocusSyncPending && nativeFocusHasNewerHumanIntent + guard !scrollAnimationActive || nativeFocusPreemptsAnimation, commandQuietPeriodElapsed || mouseGestureSyncPending || nativeFocusSyncPending || frameDebtPending || lifecycleEventPending, needsDesktopSync || periodicSyncDue @@ -460,7 +463,7 @@ public func desktopSynchronizationIsReady( return false } return !animatedWritesPending - || nativeFocusSyncPending + || nativeFocusPreemptsAnimation || (mouseGestureSyncPending && needsDesktopSync) } diff --git a/Sources/DefiRuntime/OverviewRuntime.swift b/Sources/DefiRuntime/OverviewRuntime.swift index e01814b..336a9f0 100644 --- a/Sources/DefiRuntime/OverviewRuntime.swift +++ b/Sources/DefiRuntime/OverviewRuntime.swift @@ -68,10 +68,13 @@ public func focusOverviewWorkspace( ) throws -> WindowID? { guard let monitorIndex = state.monitors.firstIndex(where: { $0.id == monitorID }) else { throw OverviewRuntimeError.unknownMonitor(monitorID) } - guard state.monitors[monitorIndex].workspaces.contains(where: { + guard + state.monitors[monitorIndex].workspaces.contains(where: { $0.id == workspaceID - }) else { throw OverviewRuntimeError.unknownWorkspace(workspaceID) } + }) + else { throw OverviewRuntimeError.unknownWorkspace(workspaceID) } state.monitors[monitorIndex].activeWorkspace = workspaceID + state.maintainWorkspaceLifecycle() return state.selectedWindowID(on: monitorID) } @@ -301,6 +304,7 @@ public func applyOverviewDrop( ) _ = focusWindow(intent.windowID, state: &next) next.monitors[targetMonitorIndex].activeWorkspace = destination.workspaceID + next.maintainWorkspaceLifecycle() state = next return OverviewDropResult( monitorID: destination.monitorID, diff --git a/Sources/DefiRuntime/RuntimeState.swift b/Sources/DefiRuntime/RuntimeState.swift index 5102347..e66c840 100644 --- a/Sources/DefiRuntime/RuntimeState.swift +++ b/Sources/DefiRuntime/RuntimeState.swift @@ -4,12 +4,48 @@ import DefiModel public typealias WindowLocationMap = [WindowID: (monitorID: MonitorID, workspaceID: WorkspaceID)] +public struct DisconnectedMonitor: Equatable, Codable, Sendable { + public var activeWorkspace: WorkspaceID + + public init(activeWorkspace: WorkspaceID) { + self.activeWorkspace = activeWorkspace + } +} + +public struct WorkspaceTopology: Equatable, Codable, Sendable { + public var monitors: [Monitor] + public var windows: [WindowID: Window] + public var nextOrdinaryWorkspaceNumber: UInt64 + public var disconnectedMonitors: [MonitorID: DisconnectedMonitor] + public var nativeFullscreenWindowIDs: Set + public var nativeFullscreenFloatingWindowIDs: Set + public var nativeFullscreenTiledPlacements: [WindowID: SuspendedTiledPlacement] + public var pendingNativeFullscreenWidthResetWindowIDs: Set + public var suspendedTiledPlacements: [WindowID: SuspendedTiledPlacement] + + public init(state: RuntimeState) { + monitors = state.monitors + windows = state.windows + nextOrdinaryWorkspaceNumber = state.nextOrdinaryWorkspaceNumber + disconnectedMonitors = state.disconnectedMonitors + nativeFullscreenWindowIDs = state.nativeFullscreenWindowIDs + nativeFullscreenFloatingWindowIDs = state.nativeFullscreenFloatingWindowIDs + nativeFullscreenTiledPlacements = state.nativeFullscreenTiledPlacements + pendingNativeFullscreenWidthResetWindowIDs = + state.pendingNativeFullscreenWidthResetWindowIDs + suspendedTiledPlacements = state.suspendedTiledPlacements + } +} + public struct RuntimeState: Equatable, Sendable { public var monitors: [Monitor] public var windows: [WindowID: Window] public var layout: LayoutSettings public var workspaceNames: [WorkspaceID] - public var defaultWorkspace: WorkspaceID + public var defaultWorkspace: WorkspaceID? + public var workspaceMonitorPositions: [WorkspaceID: Int] + public var nextOrdinaryWorkspaceNumber: UInt64 + public var disconnectedMonitors: [MonitorID: DisconnectedMonitor] public var nativeFullscreenWindowIDs: Set public var nativeFullscreenFloatingWindowIDs: Set public var nativeFullscreenTiledPlacements: [WindowID: SuspendedTiledPlacement] @@ -23,7 +59,14 @@ public struct RuntimeState: Equatable, Sendable { self.windows = [:] self.layout = LayoutSettings(config: config) self.workspaceNames = names - self.defaultWorkspace = WorkspaceID(rawValue: config.workspaces.defaultName) + self.defaultWorkspace = config.workspaces.defaultName.map(WorkspaceID.init(rawValue:)) + self.workspaceMonitorPositions = Dictionary( + uniqueKeysWithValues: config.workspaces.monitors.map { + (WorkspaceID(rawValue: $0.key), $0.value) + } + ) + self.nextOrdinaryWorkspaceNumber = 1 + self.disconnectedMonitors = [:] self.nativeFullscreenWindowIDs = [] self.nativeFullscreenFloatingWindowIDs = [] self.nativeFullscreenTiledPlacements = [:] @@ -32,18 +75,77 @@ public struct RuntimeState: Equatable, Sendable { self.reservedEdgesByMonitor = [:] } + public init(config: Config, topology: WorkspaceTopology?) { + self.init(config: config) + guard let topology else { return } + monitors = topology.monitors + windows = topology.windows + nextOrdinaryWorkspaceNumber = topology.nextOrdinaryWorkspaceNumber + disconnectedMonitors = topology.disconnectedMonitors + nativeFullscreenWindowIDs = topology.nativeFullscreenWindowIDs + nativeFullscreenFloatingWindowIDs = topology.nativeFullscreenFloatingWindowIDs + nativeFullscreenTiledPlacements = topology.nativeFullscreenTiledPlacements + pendingNativeFullscreenWidthResetWindowIDs = + topology.pendingNativeFullscreenWidthResetWindowIDs + suspendedTiledPlacements = topology.suspendedTiledPlacements + reconcileConfiguredWorkspaces() + } + + public var topology: WorkspaceTopology { + WorkspaceTopology(state: self) + } + public mutating func attachMonitor(_ monitorID: MonitorID) { + attachMonitor(monitorID, previousViewports: [:], nextViewports: [:]) + } + + private mutating func attachMonitor( + _ monitorID: MonitorID, + previousViewports: [MonitorID: Rect], + nextViewports: [MonitorID: Rect] + ) { guard !monitors.contains(where: { $0.id == monitorID }) else { return } - let workspaces = workspaceNames.map { Workspace(id: $0) } + let isPrimary = monitors.isEmpty + var workspaces: [Workspace] = [] + if isPrimary { + workspaces = workspaceNames.enumerated().map { index, id in + Workspace( + id: id, + kind: .named, + affinity: (workspaceMonitorPositions[id] ?? 1) == 1 ? monitorID : nil, + affinityPosition: index + ) + } + } + let trailing = makeOrdinaryWorkspace( + kind: .trailing, + affinity: monitorID, + affinityPosition: workspaces.count + ) + workspaces.append(trailing) monitors.append( Monitor( id: monitorID, workspaces: workspaces, - activeWorkspace: workspaces.contains(where: { $0.id == defaultWorkspace }) - ? defaultWorkspace - : workspaces[0].id + activeWorkspace: defaultWorkspace.flatMap { defaultID in + workspaces.contains(where: { $0.id == defaultID }) ? defaultID : nil + } ?? trailing.id ) ) + restoreAffinedWorkspaces( + to: monitorID, + previousViewports: previousViewports, + nextViewports: nextViewports + ) + redistributeConfiguredNamedWorkspaces() + if let disconnected = disconnectedMonitors.removeValue(forKey: monitorID), + monitors.last?.workspaces.contains(where: { + $0.id == disconnected.activeWorkspace + }) == true + { + monitors[monitors.count - 1].activeWorkspace = disconnected.activeWorkspace + } + maintainWorkspaceLifecycle() } public mutating func retainMonitors( @@ -51,9 +153,6 @@ public struct RuntimeState: Equatable, Sendable { previousViewports: [MonitorID: Rect] = [:], nextViewports: [MonitorID: Rect] = [:] ) { - for monitorID in monitorIDs { - attachMonitor(monitorID) - } guard !monitorIDs.isEmpty else { return } for monitorIndex in monitors.indices where monitorIDs.contains(monitors[monitorIndex].id) { @@ -74,6 +173,33 @@ public struct RuntimeState: Equatable, Sendable { } let removed = monitors.filter { !monitorIDs.contains($0.id) } monitors.removeAll { !monitorIDs.contains($0.id) } + for monitor in removed { + disconnectedMonitors[monitor.id] = DisconnectedMonitor( + activeWorkspace: monitor.activeWorkspace + ) + } + if monitors.isEmpty, let firstMonitorID = monitorIDs.first { + if removed.isEmpty { + attachMonitor( + firstMonitorID, + previousViewports: previousViewports, + nextViewports: nextViewports + ) + } else { + let trailing = makeOrdinaryWorkspace( + kind: .trailing, + affinity: firstMonitorID, + affinityPosition: 0 + ) + monitors.append( + Monitor( + id: firstMonitorID, + workspaces: [trailing], + activeWorkspace: trailing.id + ) + ) + } + } guard let fallbackIndex = monitors.indices.first else { return } let fallbackWidth = nextViewports[monitors[fallbackIndex].id]?.width for var monitor in removed { @@ -88,45 +214,265 @@ public struct RuntimeState: Equatable, Sendable { by: fallbackWidth / max(previousWidth, 1) ) } - for workspace in monitor.workspaces { - guard - let target = monitors[fallbackIndex].workspaces.firstIndex( - where: { $0.id == workspace.id } - ) - else { - continue - } - let targetWorkspace = monitors[fallbackIndex].workspaces[target] - let highestSuspendedColumnIndex = - (Array(suspendedTiledPlacements.values) - + Array(nativeFullscreenTiledPlacements.values)).compactMap { placement in - guard placement.monitorID == monitors[fallbackIndex].id, - placement.workspaceID == workspace.id, - !targetWorkspace.columns.contains(where: { column in - column.windows.contains(where: placement.column.windows.contains) - }) - else { return nil } - return placement.columnIndex - }.max() ?? -1 - let columnOffset = max( - targetWorkspace.columns.count, - highestSuspendedColumnIndex + 1 - ) + for var workspace in monitor.workspaces { + if workspace.kind == .trailing && workspace.isEmpty { continue } + if workspace.affinity == nil { workspace.affinity = monitor.id } migrateSuspendedPlacements( from: monitor.id, to: monitors[fallbackIndex].id, workspaceID: workspace.id, - columnOffset: columnOffset, + columnOffset: 0, scale: migrationScale ) - monitors[fallbackIndex].workspaces[target].columns.append(contentsOf: workspace.columns) - monitors[fallbackIndex].workspaces[target].floatingWindows.append( - contentsOf: workspace.floatingWindows.filter { - !monitors[fallbackIndex].workspaces[target].floatingWindows.contains($0) - } + insertBeforeTrailing(workspace, in: fallbackIndex) + for windowID in workspace.columns.flatMap(\.windows) + workspace.floatingWindows { + windows[windowID]?.monitorID = monitors[fallbackIndex].id + } + } + } + monitors.sort { + (monitorIDs.firstIndex(of: $0.id) ?? .max) + < (monitorIDs.firstIndex(of: $1.id) ?? .max) + } + for monitorID in monitorIDs where !monitors.contains(where: { $0.id == monitorID }) { + attachMonitor( + monitorID, + previousViewports: previousViewports, + nextViewports: nextViewports + ) + } + monitors.sort { + (monitorIDs.firstIndex(of: $0.id) ?? .max) + < (monitorIDs.firstIndex(of: $1.id) ?? .max) + } + redistributeConfiguredNamedWorkspaces() + maintainWorkspaceLifecycle() + } + + public mutating func maintainWorkspaceLifecycle() { + for monitorIndex in monitors.indices { + for workspaceIndex in monitors[monitorIndex].workspaces.indices + where monitors[monitorIndex].workspaces[workspaceIndex].kind == .trailing + && !monitors[monitorIndex].workspaces[workspaceIndex].isEmpty + { + monitors[monitorIndex].workspaces[workspaceIndex].kind = .ordinary + } + let activeWorkspace = monitors[monitorIndex].activeWorkspace + monitors[monitorIndex].workspaces.removeAll { + $0.kind == .ordinary && $0.isEmpty && $0.id != activeWorkspace + } + let trailingIndices = monitors[monitorIndex].workspaces.indices.filter { + monitors[monitorIndex].workspaces[$0].kind == .trailing + } + if trailingIndices.isEmpty { + monitors[monitorIndex].workspaces.append( + makeOrdinaryWorkspace( + kind: .trailing, + affinity: monitors[monitorIndex].id, + affinityPosition: monitors[monitorIndex].workspaces.count + ) + ) + } else { + let kept = trailingIndices.last! + let trailing = monitors[monitorIndex].workspaces[kept] + monitors[monitorIndex].workspaces.remove(at: kept) + monitors[monitorIndex].workspaces.removeAll { $0.kind == .trailing } + monitors[monitorIndex].workspaces.append(trailing) + } + if !monitors[monitorIndex].workspaces.contains(where: { + $0.id == monitors[monitorIndex].activeWorkspace + }) { + monitors[monitorIndex].activeWorkspace = monitors[monitorIndex].workspaces.last!.id + } + updateAffinityPositions(on: monitorIndex) + } + } + + public func workspaceLocation( + for workspaceID: WorkspaceID + ) -> (monitorIndex: Int, workspaceIndex: Int)? { + for monitorIndex in monitors.indices { + if let workspaceIndex = monitors[monitorIndex].workspaces.firstIndex(where: { + $0.id == workspaceID + }) { + return (monitorIndex, workspaceIndex) + } + } + return nil + } + + public func resolveWorkspaceTarget( + _ target: WorkspaceTarget, + on monitorIndex: Int + ) -> (monitorIndex: Int, workspaceIndex: Int)? { + switch target { + case .named(let name): + return workspaceLocation(for: WorkspaceID(rawValue: name)) + case .position(let position): + guard position > 0, + monitors.indices.contains(monitorIndex), + !monitors[monitorIndex].workspaces.isEmpty + else { return nil } + return (monitorIndex, min(position - 1, monitors[monitorIndex].workspaces.count - 1)) + case .relative(let direction): + guard monitors.indices.contains(monitorIndex), + let active = monitors[monitorIndex].workspaces.firstIndex(where: { + $0.id == monitors[monitorIndex].activeWorkspace + }) + else { return nil } + let targetIndex: Int + switch direction { + case .up: + targetIndex = max(active - 1, 0) + case .down: + targetIndex = min(active + 1, monitors[monitorIndex].workspaces.count - 1) + default: + return nil + } + return (monitorIndex, targetIndex) + } + } + + public mutating func refreshAffinityPositions(on monitorIndex: Int) { + guard monitors.indices.contains(monitorIndex) else { return } + updateAffinityPositions(on: monitorIndex) + } + + private mutating func makeOrdinaryWorkspace( + kind: WorkspaceKind, + affinity: MonitorID, + affinityPosition: Int + ) -> Workspace { + let id = WorkspaceID( + rawValue: "\(WorkspaceID.dynamicPrefix)\(nextOrdinaryWorkspaceNumber)" + ) + nextOrdinaryWorkspaceNumber &+= 1 + return Workspace( + id: id, + kind: kind, + affinity: affinity, + affinityPosition: affinityPosition + ) + } + + private mutating func insertBeforeTrailing(_ workspace: Workspace, in monitorIndex: Int) { + let index = + monitors[monitorIndex].workspaces.firstIndex(where: { + $0.kind == .trailing + }) ?? monitors[monitorIndex].workspaces.count + monitors[monitorIndex].workspaces.insert(workspace, at: index) + } + + private mutating func restoreAffinedWorkspaces( + to monitorID: MonitorID, + previousViewports: [MonitorID: Rect], + nextViewports: [MonitorID: Rect] + ) { + guard let targetIndex = monitors.firstIndex(where: { $0.id == monitorID }) else { return } + var returning: [(Workspace, MonitorID)] = [] + for sourceIndex in monitors.indices.reversed() where sourceIndex != targetIndex { + for workspaceIndex in monitors[sourceIndex].workspaces.indices.reversed() { + let workspace = monitors[sourceIndex].workspaces[workspaceIndex] + guard workspace.affinity == monitorID, workspace.kind != .trailing else { continue } + returning.append((workspace, monitors[sourceIndex].id)) + monitors[sourceIndex].workspaces.remove(at: workspaceIndex) + } + } + for (var workspace, sourceID) in returning.sorted(by: { + $0.0.affinityPosition < $1.0.affinityPosition + }) { + let scale = previousViewports[sourceID].flatMap { source in + nextViewports[monitorID].map { $0.width / max(source.width, 1) } + } + if let scale { scalePixelWidths(in: &workspace, by: scale) } + insertBeforeTrailing(workspace, in: targetIndex) + for windowID in workspace.columns.flatMap(\.windows) + workspace.floatingWindows { + windows[windowID]?.monitorID = monitorID + } + migrateSuspendedPlacements( + from: sourceID, + to: monitorID, + workspaceID: workspace.id, + columnOffset: 0, + scale: scale + ) + } + } + + private mutating func redistributeConfiguredNamedWorkspaces() { + for workspaceID in workspaceNames { + guard let location = workspaceLocation(for: workspaceID) else { continue } + let affinity = monitors[location.monitorIndex].workspaces[location.workspaceIndex].affinity + let targetIndex: Int + if let affinity { + guard let connected = monitors.firstIndex(where: { $0.id == affinity }) else { + continue + } + targetIndex = connected + } else { + let targetPosition = workspaceMonitorPositions[workspaceID] ?? 1 + guard monitors.indices.contains(targetPosition - 1) else { continue } + targetIndex = targetPosition - 1 + } + if location.monitorIndex != targetIndex { + let wasActive = monitors[location.monitorIndex].activeWorkspace == workspaceID + let workspace = monitors[location.monitorIndex].workspaces.remove( + at: location.workspaceIndex + ) + insertBeforeTrailing(workspace, in: targetIndex) + if wasActive { monitors[targetIndex].activeWorkspace = workspaceID } + } + if let final = workspaceLocation(for: workspaceID) { + monitors[final.monitorIndex].workspaces[final.workspaceIndex].affinity = + monitors[targetIndex].id + } + } + } + + private mutating func reconcileConfiguredWorkspaces() { + let configured = Set(workspaceNames) + for monitorIndex in monitors.indices { + for workspaceIndex in monitors[monitorIndex].workspaces.indices.reversed() { + let workspace = monitors[monitorIndex].workspaces[workspaceIndex] + guard workspace.kind == .named, !configured.contains(workspace.id) else { continue } + if workspace.isEmpty && workspace.id != monitors[monitorIndex].activeWorkspace { + monitors[monitorIndex].workspaces.remove(at: workspaceIndex) + } else { + monitors[monitorIndex].workspaces[workspaceIndex].kind = .ordinary + monitors[monitorIndex].workspaces[workspaceIndex].name = nil + } + } + } + if let primaryIndex = monitors.indices.first { + for workspaceID in workspaceNames { + if let location = workspaceLocation(for: workspaceID) { + monitors[location.monitorIndex].workspaces[location.workspaceIndex].kind = .named + monitors[location.monitorIndex].workspaces[location.workspaceIndex].name = + workspaceID.rawValue + continue + } + let workspace = Workspace( + id: workspaceID, + kind: .named, + affinity: workspaceMonitorPositions[workspaceID] == nil + || workspaceMonitorPositions[workspaceID] == 1 + ? monitors[primaryIndex].id + : nil, + affinityPosition: monitors[primaryIndex].workspaces.count ) + insertBeforeTrailing(workspace, in: primaryIndex) } } + redistributeConfiguredNamedWorkspaces() + maintainWorkspaceLifecycle() + } + + private mutating func updateAffinityPositions(on monitorIndex: Int) { + let monitorID = monitors[monitorIndex].id + for workspaceIndex in monitors[monitorIndex].workspaces.indices + where monitors[monitorIndex].workspaces[workspaceIndex].affinity == monitorID { + monitors[monitorIndex].workspaces[workspaceIndex].affinityPosition = workspaceIndex + } } private mutating func scaleSuspendedPlacements( @@ -270,7 +616,7 @@ public struct RuntimeState: Equatable, Sendable { } } -public struct SuspendedTiledPlacement: Equatable, Sendable { +public struct SuspendedTiledPlacement: Equatable, Codable, Sendable { public let monitorID: MonitorID public let workspaceID: WorkspaceID public let columnIndex: Int @@ -339,6 +685,13 @@ private func scalePixelWidths(in monitor: inout Monitor, by scale: Double) { } } +private func scalePixelWidths(in workspace: inout Workspace, by scale: Double) { + guard scale.isFinite, scale > 0, abs(scale - 1) >= 0.001 else { return } + for columnIndex in workspace.columns.indices { + scalePixelWidths(in: &workspace.columns[columnIndex], by: scale) + } +} + func scalePixelWidths(in column: inout Column, by scale: Double) { guard scale.isFinite, scale > 0, abs(scale - 1) >= 0.001 else { return } scalePixelWidth(&column.width, by: scale) @@ -366,9 +719,9 @@ extension LayoutSettings { centerFocusedColumn: config.layout.centerFocusedColumn == .always ? .always : .never, innerHorizontalGap: config.layout.gaps / 2, innerVerticalGap: config.layout.gaps / 2, - outerTopGap: config.layout.outerTopGap ?? config.layout.gaps, + outerTopGap: max(config.layout.outerTopGap ?? config.layout.gaps, borderPadding), outerRightGap: max(config.layout.outerRightGap ?? config.layout.gaps, borderPadding), - outerBottomGap: config.layout.outerBottomGap ?? config.layout.gaps, + outerBottomGap: max(config.layout.outerBottomGap ?? config.layout.gaps, borderPadding), outerLeftGap: max(config.layout.outerLeftGap ?? config.layout.gaps, borderPadding), horizontalViewportPadding: borderPadding ) diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index efdd50f..f15a080 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -34,23 +34,26 @@ public func discoverWindow( let effectivePlacement = window.floatingOrigin == .automatic ? nil : placement let transientLocation = transientPlacementLocation(for: window, state: state) let followFocusIntent = decision.followFocus && isNativelyFocused + let ruleLocation = decision.workspace.flatMap { state.workspaceLocation(for: $0) } + let placementLocation = effectivePlacement.flatMap { + state.workspaceLocation(for: $0.workspaceID) + } let preferredMonitorID = effectivePlacement?.monitorID.flatMap { preferred in state.monitors.contains(where: { $0.id == preferred }) ? preferred : nil } let monitorID = transientLocation?.monitorID + ?? ruleLocation.map { state.monitors[$0.monitorIndex].id } + ?? placementLocation.map { state.monitors[$0.monitorIndex].id } ?? preferredMonitorID ?? window.monitorID ?? state.monitors[0].id let monitorIndex = state.monitors.firstIndex(where: { $0.id == monitorID }) ?? 0 - let preferredWorkspaceID = effectivePlacement?.workspaceID let workspaceID = transientLocation?.workspaceID ?? decision.workspace - ?? preferredWorkspaceID.flatMap { preferred in - state.monitors[monitorIndex].workspaces.contains(where: { $0.id == preferred }) - ? preferred - : nil + ?? placementLocation.map { + state.monitors[$0.monitorIndex].workspaces[$0.workspaceIndex].id } ?? state.monitors[monitorIndex].activeWorkspace guard @@ -92,6 +95,7 @@ public func discoverWindow( state.monitors[monitorIndex].activeWorkspace = workspaceID } state.windows[window.id] = window + state.maintainWorkspaceLifecycle() } public func transientPlacementLocation( @@ -266,6 +270,7 @@ public func reconcileWindows( if relocatedInPass == false { break } } reconcileNativeFullscreenWindows(nativeFullscreenWindowIDs, state: &state) + state.maintainWorkspaceLifecycle() return relocatedTransientIDs } diff --git a/Tests/DefiConfigTests/ConfigTests.swift b/Tests/DefiConfigTests/ConfigTests.swift index 36dafbd..f9b723f 100644 --- a/Tests/DefiConfigTests/ConfigTests.swift +++ b/Tests/DefiConfigTests/ConfigTests.swift @@ -8,12 +8,14 @@ struct ConfigTests { func `Empty config uses defaults`() throws { let config = try Config.decode(Data()) - #expect(config.workspaces.names == (1...9).map(String.init)) + #expect(config.workspaces.names.isEmpty) + #expect(config.workspaces.defaultName == nil) #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.overview.windowCornerRadius == 12) #expect(config.decorations.borders.enabled) #expect(config.decorations.borders.width == 4) #expect(config.decorations.borders.color == "#FFC099FF") @@ -24,11 +26,11 @@ struct ConfigTests { #expect(config.keys["alt-backslash"] == "toggle-floating") #expect(config.keys["alt-shift-backslash"] == "activate-floating") #expect(config.keys["alt-period"] == "focus-floating next") - #expect(config.keys["alt-shift-1"] == "move-window-to-workspace 1") - #expect(config.keys["alt-shift-h"] == "move-column-to-monitor left") - #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-up"] == "focus-workspace up") + #expect(config.keys["alt-j"] == "focus-window down") + #expect(config.keys["alt-shift-1"] == "move-column-to-workspace-position 1") + #expect(config.keys["ctrl-cmd-left"] == "focus-monitor left") + #expect(config.keys["ctrl-cmd-shift-down"] == "move-column-to-monitor down") #expect(config.keys["alt-o"] == "toggle-overview") } @@ -40,12 +42,14 @@ struct ConfigTests { [overview] zoom = 0.25 window_previews = true + window_corner_radius = 18 """.utf8 ) ) #expect(config.overview.zoom == 0.25) #expect(config.overview.windowPreviews) + #expect(config.overview.windowCornerRadius == 18) } @Test @@ -62,6 +66,20 @@ struct ConfigTests { } } + @Test(arguments: [-1.0, 65.0]) + func `Rejects invalid overview window corner radius`(radius: Double) { + let data = Data( + """ + [overview] + window_corner_radius = \(radius) + """.utf8 + ) + + #expect(throws: ConfigError.invalidValue("overview.window_corner_radius")) { + try Config.decode(data) + } + } + @Test func `Decodes border configuration`() throws { let config = try Config.decode( @@ -154,6 +172,8 @@ struct ConfigTests { #expect(config.animation.durationMS == 120) #expect(config.workspaces.defaultName == "dev") #expect(config.keys["hyper-1"] == "workspace dev") + #expect(config.keys["hyper-shift-1"] == "move-column-to-workspace-name dev") + #expect(config.keys["hyper-3"] == "focus-workspace-position 3") #expect(config.keys["hyper-minus"] == "cycle-width previous") #expect(config.keys["hyper-equal"] == "cycle-width next") #expect(config.keys["hyper-f"] == "maximize-column") @@ -165,6 +185,20 @@ struct ConfigTests { #expect(config.rules.count == 1) } + @Test + func `Rejects workspace names reserved for dynamic identity`() { + let data = Data( + """ + [workspaces] + names = ["__defi_dynamic_1"] + """.utf8 + ) + + #expect(throws: ConfigError.invalidWorkspaces) { + try Config.decode(data) + } + } + @Test func `Accepts diagnostic marker binding`() throws { let config = try Config.decode( @@ -241,6 +275,7 @@ struct ConfigTests { let config = try Config.load(from: repository.appending(path: "defi.example.toml")) #expect(config.workspaces.names.first == "dev") + #expect(config.workspaces.monitors["dev-secondary"] == 2) #expect(config.keys["hyper-left"] == "focus-column left") #expect(config.rules.count == 9) } diff --git a/Tests/DefiCoreTests/AnimationTests.swift b/Tests/DefiCoreTests/AnimationTests.swift index b815da5..e9385f8 100644 --- a/Tests/DefiCoreTests/AnimationTests.swift +++ b/Tests/DefiCoreTests/AnimationTests.swift @@ -106,30 +106,6 @@ struct AnimationTests { ) == 0) } - @Test - func `Display linked spring sampling uses elapsed time and never rolls back`() { - let first = springProgressSample( - elapsed: 1.0 / 120, - duration: 0.08 - ) - let delayed = springProgressSample( - elapsed: 0.027, - duration: 0.08, - minimumProgress: first.progress - ) - let staleTimestamp = springProgressSample( - elapsed: 0.020, - duration: 0.08, - minimumProgress: delayed.progress - ) - - #expect(first.progress > 0) - #expect(delayed.progress > first.progress) - #expect(staleTimestamp.progress == delayed.progress) - #expect(staleTimestamp.velocity == 0) - #expect(staleTimestamp.progress <= 1) - } - @Test func `Adaptive frame limit avoids multiplying slow AX calls`() { #expect( @@ -150,96 +126,12 @@ struct AnimationTests { refreshRateHz: 120, availableIntermediateFrames: 4 ) == 0) - } - - @Test - func `Spring progress anticipates AX completion latency`() { #expect( - anticipatedSpringProgressIndex( - predictedFrameLatency: 0.002, - refreshRateHz: 120, - availableIntermediateFrames: 4 - ) == 0) - #expect( - anticipatedSpringProgressIndex( - predictedFrameLatency: 0.018, - refreshRateHz: 120, - availableIntermediateFrames: 4 - ) == 2) - #expect( - anticipatedSpringProgressIndex( + adaptiveIntermediateFrameLimit( predictedFrameLatency: 0.030, refreshRateHz: 120, - availableIntermediateFrames: 4 - ) == 3) - #expect( - anticipatedSpringProgressIndex( - predictedFrameLatency: 0.051, - refreshRateHz: 120, - availableIntermediateFrames: 4, - maximumIndex: 1 - ) == 1) - } - - @Test - func `Completed AX frame schedules next write after display interval`() { - #expect( - nextCompletedFrameDispatchDeadline( - completedAt: 10, - refreshRateHz: 120 - ).isApproximatelyEqual( - to: 10 + 1.0 / 120, - absoluteTolerance: 0.000_001 - ) - ) - #expect( - nextCompletedFrameDispatchDeadline( - completedAt: 10, - refreshRateHz: 60 - ).isApproximatelyEqual( - to: 10 + 1.0 / 60, - absoluteTolerance: 0.000_001 - ) - ) - } - - @Test - func `Slow completed frame skips intermediate that cannot fit budget`() { - #expect( - shouldEmitAnotherIntermediateFrame( - elapsed: 0.029, - predictedFrameLatency: 0.024, - budget: 0.06, - completedIntermediateFrames: 1 - )) - #expect( - shouldEmitAnotherIntermediateFrame( - elapsed: 0.029, - predictedFrameLatency: 0.032, - budget: 0.06, - completedIntermediateFrames: 1 - ) == false) - #expect( - shouldEmitAnotherIntermediateFrame( - elapsed: 0.059, - predictedFrameLatency: 0.5, - budget: 0.06, - completedIntermediateFrames: 0 - )) - } - - @Test - func `Slow completed sample stops further AX frames`() { - #expect( - completedFrameSupportsAnotherSample( - duration: 0.006, - refreshRateHz: 120 - )) - #expect( - completedFrameSupportsAnotherSample( - duration: 0.020, - refreshRateHz: 120 - ) == false) + availableIntermediateFrames: 22 + ) == 5) } @Test @@ -257,29 +149,6 @@ struct AnimationTests { ) == 0) } - @Test - func `Slow intermediate frame does not leave animation frozen until deadline`() { - #expect( - finalFrameDispatchDeadline( - nominalDeadline: 10.08, - nextDisplayDeadline: 10.025, - previousFrameWasSlow: true - ) == 10.08) - #expect( - finalFrameDispatchDeadline( - nominalDeadline: 10.08, - nextDisplayDeadline: 10.025, - previousFrameWasSlow: false - ) == 10.08) - #expect( - finalFrameDispatchDeadline( - nominalDeadline: 10.075, - nextDisplayDeadline: 10.12, - previousFrameWasSlow: false, - hardDeadline: 10.08 - ) == 10.08) - } - @Test func `Displayed frame rebase uses median and rejects outliers`() { #expect( diff --git a/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift b/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift index ad259d2..5dfeeee 100644 --- a/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift +++ b/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift @@ -1,10 +1,144 @@ +import DefiConfig import DefiCore import DefiModel +import DefiRuntime import Testing @testable import DefiDaemon struct DaemonCommandPolicyTests { + @Test + func backgroundSnapshotWaitsForTheCurrentCommandAnimation() { + #expect( + desktopSnapshotWaitsForCommandAnimation( + animationPending: true, + latestCommandInputTimestamp: 10, + mouseFocusIntentTimestamp: nil, + keyboardFocusIntentTimestamp: nil + )) + #expect( + desktopSnapshotWaitsForCommandAnimation( + animationPending: true, + latestCommandInputTimestamp: 10, + mouseFocusIntentTimestamp: 11, + keyboardFocusIntentTimestamp: nil + ) == false) + } + + @Test + func verticalWorkspaceTransitionUsesAPerceivableMinimumDuration() { + #expect(workspaceVerticalTransitionDuration(configuredDurationMS: 0) == 0) + #expect(workspaceVerticalTransitionDuration(configuredDurationMS: 35) == 0.18) + #expect(workspaceVerticalTransitionDuration(configuredDurationMS: 250) == 0.25) + } + + @Test + func verticalWorkspaceTransitionRejectsAOverlappingMonitorPath() { + let owner = Rect(x: 0, y: 0, width: 1_000, height: 800) + + #expect( + workspaceTransitionPathIsClear( + ownerFrame: owner, + otherMonitorFrames: [Rect(x: 1_000, y: 0, width: 1_000, height: 800)] + ) + ) + #expect( + !workspaceTransitionPathIsClear( + ownerFrame: owner, + otherMonitorFrames: [Rect(x: 0, y: 800, width: 1_000, height: 800)] + ) + ) + } + + @Test + func verticalWorkspaceTransitionRejectsAnUncoveredDisplayMargin() { + let physicalFrame = Rect(x: 0, y: 0, width: 1_512, height: 982) + + #expect( + workspaceVerticalTransitionCanAnimateWithoutReservedAreaLeak( + viewport: physicalFrame, + physicalFrame: physicalFrame + ) + ) + #expect( + workspaceVerticalTransitionCanAnimateWithoutReservedAreaLeak( + viewport: Rect(x: 0, y: 33, width: 1_512, height: 900), + physicalFrame: physicalFrame + ) == false + ) + } + + @Test + func verticalWorkspaceRibbonClearsThePhysicalMonitor() { + let physicalFrame = Rect(x: 0, y: 0, width: 1_512, height: 982) + let windowFrame = Rect(x: 4, y: 37, width: 1_204, height: 900) + + #expect( + windowFrame.y + + workspaceVerticalRibbonOffset( + relativePosition: 1, + physicalFrame: physicalFrame + ) >= physicalFrame.y + physicalFrame.height + ) + #expect( + windowFrame.y + windowFrame.height + + workspaceVerticalRibbonOffset( + relativePosition: -1, + physicalFrame: physicalFrame + ) <= physicalFrame.y + ) + } + + @Test + func inactiveWorkspaceOnlyJoinsTheRibbonWhileLeaving() { + let monitorID = MonitorID(rawValue: 1) + let outgoingWorkspaceID = WorkspaceID(rawValue: "dev") + let transition = WorkspaceVerticalTransition( + monitorID: monitorID, + outgoingWorkspaceID: outgoingWorkspaceID, + direction: 1 + ) + let physicalFrame = Rect(x: 0, y: 0, width: 1_512, height: 982) + + #expect( + outgoingWorkspaceVerticalRibbonOffset( + workspaceID: outgoingWorkspaceID, + monitorID: monitorID, + transition: transition, + physicalFrame: physicalFrame + ) == -982 + ) + #expect( + outgoingWorkspaceVerticalRibbonOffset( + workspaceID: WorkspaceID(rawValue: "web"), + monitorID: monitorID, + transition: transition, + physicalFrame: physicalFrame + ) == nil + ) + } + + @Test + func workspaceTransitionIntentUsesTheTargetMonitorOrder() throws { + let monitorID = MonitorID(rawValue: 1) + var state = RuntimeState( + config: Config(workspaces: WorkspacesConfig(names: ["dev", "web"])) + ) + state.attachMonitor(monitorID) + + let intent = try #require( + workspaceTransitionIntent( + targetWorkspaceID: WorkspaceID(rawValue: "web"), + state: state + ) + ) + + #expect(intent.monitorID == monitorID) + #expect(intent.outgoingWorkspaceID == WorkspaceID(rawValue: "dev")) + #expect(intent.incomingWorkspaceID == WorkspaceID(rawValue: "web")) + #expect(intent.direction == 1) + } + @Test func overviewIgnoresParkingFocusWithoutNewFocusInput() { #expect( @@ -38,6 +172,24 @@ struct DaemonCommandPolicyTests { ) } + @Test + func inFlightAnimationDoesNotTurnACommandIntoANoOp() { + #expect( + !commandValidationIsNoOp( + hasValidationState: false, + rebasesPendingFrame: true, + explicitlyFocusesFloating: false + ) + ) + #expect( + commandValidationIsNoOp( + hasValidationState: false, + rebasesPendingFrame: false, + explicitlyFocusesFloating: false + ) + ) + } + @Test func localLayoutSubmissionSkipsCachedMonitorAssignments() { let included = MonitorID(rawValue: 1) diff --git a/Tests/DefiDaemonTests/WorkspaceTopologyStoreTests.swift b/Tests/DefiDaemonTests/WorkspaceTopologyStoreTests.swift new file mode 100644 index 0000000..9d65599 --- /dev/null +++ b/Tests/DefiDaemonTests/WorkspaceTopologyStoreTests.swift @@ -0,0 +1,25 @@ +import DefiConfig +import DefiModel +import DefiRuntime +import Foundation +import Testing + +@testable import DefiDaemon + +struct WorkspaceTopologyStoreTests { + @Test + func `Store restores only the current login session`() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let store = WorkspaceTopologyStore(url: directory.appending(path: "topology.json")) + var state = RuntimeState( + config: Config(workspaces: WorkspacesConfig(names: ["dev"])) + ) + state.attachMonitor(MonitorID(rawValue: 1)) + + try store.save(state.topology, sessionID: "session-a") + + #expect(try store.load(sessionID: "session-a") == state.topology) + #expect(try store.load(sessionID: "session-b") == nil) + } +} diff --git a/Tests/DefiIPCTests/WorkspaceStateTests.swift b/Tests/DefiIPCTests/WorkspaceStateTests.swift index ebd00f7..1c80dbf 100644 --- a/Tests/DefiIPCTests/WorkspaceStateTests.swift +++ b/Tests/DefiIPCTests/WorkspaceStateTests.swift @@ -46,7 +46,7 @@ struct WorkspaceStateTests { focusedMonitorID: secondMonitor ) - #expect(snapshot.version == 1) + #expect(snapshot.version == 2) #expect(snapshot.monitors.map(\.id) == [20, 10]) #expect(snapshot.monitors.map(\.display) == [1, 2]) #expect(snapshot.monitors[0].focused) @@ -54,6 +54,9 @@ struct WorkspaceStateTests { snapshot.monitors[0].workspaces.first(where: { $0.name == "web" }) ) #expect(webSnapshot.active) + #expect(webSnapshot.id == "web") + #expect(webSnapshot.position == 2) + #expect(webSnapshot.kind == .named) #expect(webSnapshot.occupied) #expect(webSnapshot.windowCount == 1) #expect(webSnapshot.occupied) diff --git a/Tests/DefiMacOSTests/DesktopE2ETests.swift b/Tests/DefiMacOSTests/DesktopE2ETests.swift index b653840..8360e6e 100644 --- a/Tests/DefiMacOSTests/DesktopE2ETests.swift +++ b/Tests/DefiMacOSTests/DesktopE2ETests.swift @@ -212,7 +212,6 @@ final class DesktopE2ETests: XCTestCase { platform.successfulSizeWriteCount - sizeWrites, 1 ) - XCTAssertTrue(platform.frameCoordinatorTrace.contains("i=final")) } func testUnhiddenOnePixelStripAnchorConvergesWithRealWindowFrame() throws { @@ -346,8 +345,6 @@ final class DesktopE2ETests: XCTestCase { XCTAssertEqual(actual?.x ?? 0, target.x, accuracy: 2) XCTAssertGreaterThanOrEqual(performance.animationFrames, 2) XCTAssertLessThanOrEqual(performance.animationFrames, maximumAnimationFrames) - XCTAssertTrue(platform.frameCoordinatorTrace.contains("reentry=1")) - XCTAssertFalse(platform.frameCoordinatorTrace.contains("stage-reentry")) } func testPostAnimationCommitLagDoesNotTriggerUnanimatedCorrection() throws { @@ -475,6 +472,7 @@ final class DesktopE2ETests: XCTestCase { ) let writesBeforeDesktopSync = platform.successfulPositionWriteCount + platform.requestFrameRefresh(for: window.id) let delayedSnapshot = platform.snapshot(config: Config()) platform.apply( [FrameAssignment(windowID: window.id, frame: target)], @@ -849,7 +847,7 @@ final class DesktopE2ETests: XCTestCase { until: { platform.cursorWarpPerformance.applied == 1 }, timeout: 1 ), - "cursor did not warp after the target frame committed" + "cursor did not warp after the target frame committed; performance=\(platform.cursorWarpPerformance) trace=\(platform.frameCoordinatorTrace)" ) } diff --git a/Tests/DefiMacOSTests/FrameCommitTests.swift b/Tests/DefiMacOSTests/FrameCommitTests.swift index 859f05d..7e440e0 100644 --- a/Tests/DefiMacOSTests/FrameCommitTests.swift +++ b/Tests/DefiMacOSTests/FrameCommitTests.swift @@ -17,6 +17,61 @@ struct FrameCommitTests { observedAt: nil ) + @Test + func `Animation lane keeps only its latest pending sample`() { + var lane = LatestAnimationSampleState() + + #expect(lane.submit(1).startsDrain) + #expect(lane.submit(2).startsDrain == false) + let latest = lane.submit(3) + #expect(latest.startsDrain == false) + #expect(latest.displaced == 2) + #expect(lane.takeNext() == 3) + #expect(lane.takeNext() == nil) + #expect(lane.isRunning == false) + } + + @Test + func `Workspace animation accepts an adaptively sampled AX lane`() { + let coordinator = AXFrameCoordinator() + coordinator.predictedProcessLatencyMS[42] = 12 + coordinator.predictedProcessLatencyMS[43] = 90 + + #expect( + coordinator.animationSupportsIntermediateFrames( + processIDs: [42], + animationDuration: 0.18, + refreshRateHz: 120 + )) + #expect( + coordinator.animationSupportsIntermediateFrames( + processIDs: [43], + animationDuration: 0.18, + refreshRateHz: 120 + ) == false) + } + + @Test + func `Vertical reentry ignores cross-axis drift and leaving windows`() { + let candidateStart = CGPoint(x: 8, y: 20) + let candidateTarget = Rect(x: 10, y: -880, width: 800, height: 700) + + #expect( + reentryTransitionDelta( + reentryStart: CGPoint(x: 1_600, y: 20), + reentryTarget: Rect(x: 10, y: 20, width: 800, height: 700), + candidateStart: candidateStart, + candidateTarget: candidateTarget + ) == CGPoint(x: 0, y: -900)) + #expect( + reentryTransitionDelta( + reentryStart: candidateStart, + reentryTarget: candidateTarget, + candidateStart: candidateStart, + candidateTarget: candidateTarget + ) == nil) + } + @Test func `Reverse retarget uses last completed position during observation lag`() { let staleObserved = Rect(x: 900, y: 40, width: 800, height: 700) @@ -70,31 +125,6 @@ struct FrameCommitTests { )[windowID] == fresh) } - @Test - func `Display link activation rejects older generation and stale request`() { - #expect( - displayLinkActivationIsCurrent( - generation: 4, - latestGeneration: 4, - requestID: 8, - latestRequestID: 8 - )) - #expect( - displayLinkActivationIsCurrent( - generation: 3, - latestGeneration: 4, - requestID: 7, - latestRequestID: 8 - ) == false) - #expect( - displayLinkActivationIsCurrent( - generation: 4, - latestGeneration: 4, - requestID: 7, - latestRequestID: 8 - ) == false) - } - @Test func `Deferred parking keeps coordinator busy until invalidated`() { let coordinator = AXFrameCoordinator() @@ -1047,6 +1077,31 @@ struct FrameCommitTests { ) == false) } + @Test + func `Final workspace writes defer enhanced UI restoration`() { + #expect( + defersEnhancedUIRestore( + stagesVisibleBeforeParking: true, + isIntermediate: false, + enhancedUIWasEnabled: true, + positionChanged: true + )) + #expect( + defersEnhancedUIRestore( + stagesVisibleBeforeParking: true, + isIntermediate: true, + enhancedUIWasEnabled: true, + positionChanged: true + ) == false) + #expect( + defersEnhancedUIRestore( + stagesVisibleBeforeParking: false, + isIntermediate: false, + enhancedUIWasEnabled: true, + positionChanged: true + ) == false) + } + @Test func `Deferred focus only applies to current selection`() { let target = WindowID(rawValue: 1) @@ -1284,4 +1339,20 @@ struct FrameCommitTests { progress: 0.25 ) == Rect(x: 85, y: 35, width: 700, height: 725)) } + + @Test + func `Matching vertical reentry start skips staging`() { + #expect( + reentryStartRequiresStaging( + observed: CGPoint(x: 4, y: 899), + planned: CGPoint(x: 4, y: 899) + ) == false + ) + #expect( + reentryStartRequiresStaging( + observed: CGPoint(x: 1_511, y: 37), + planned: CGPoint(x: 4, y: 899) + ) + ) + } } diff --git a/Tests/DefiMacOSTests/OverviewPreviewTests.swift b/Tests/DefiMacOSTests/OverviewPreviewTests.swift index c3f1723..0218353 100644 --- a/Tests/DefiMacOSTests/OverviewPreviewTests.swift +++ b/Tests/DefiMacOSTests/OverviewPreviewTests.swift @@ -34,7 +34,8 @@ struct OverviewPreviewTests { windowID: WindowID(rawValue: UInt64($0)), expectedAppID: "app", width: 100, - height: 80 + height: 80, + blurFadeHeight: 40 ) } @@ -56,7 +57,8 @@ struct OverviewPreviewTests { windowID: WindowID(rawValue: 1), expectedAppID: "app", width: 100, - height: 80 + height: 80, + blurFadeHeight: 40 ) let result = OverviewPreviewCaptureResult(request: request, image: nil) @@ -139,9 +141,50 @@ struct OverviewPreviewTests { } @Test - func `Progressive blur stays close to the title band`() { - let fadeHeight = overviewPreviewBlurFadeHeight(imageHeight: 512) - #expect(fadeHeight >= 120 && fadeHeight <= 145) + func `Progressive blur keeps a soft tail below the compact title band`() { + let titleBandHeight = overviewWindowTitleBandHeight(iconSize: 24) + let fadeHeight = overviewPreviewBlurFadeHeight( + titleBandHeight: titleBandHeight, + imageScale: 1, + imageHeight: 900 + ) + + #expect(titleBandHeight == 44) + #expect(fadeHeight == titleBandHeight + 20) + #expect( + overviewTitleScrimAlpha( + progress: titleBandHeight / fadeHeight, + opacity: 1 + ) > 0 + ) + } + + @Test + func `Overview title row is left aligned and vertically centered`() { + let card = CGRect(x: 100, y: 50, width: 400, height: 200) + let blurHeight = overviewWindowTitleBandHeight(iconSize: 20) + let layout = overviewWindowTitleLayout( + cardFrame: card, + iconSize: 20, + titleSize: CGSize(width: 100, height: 16), + blurHeight: blurHeight + ) + + #expect(layout.iconFrame.midY == layout.titleFrame.midY) + #expect(layout.iconFrame.minX == card.minX + 10) + #expect(layout.iconFrame.minY == card.minY + 10) + #expect(layout.titleFrame.minX - layout.iconFrame.maxX == 8) + let topPadding = layout.titleFrame.minY - card.minY + let bottomPadding = card.minY + blurHeight - layout.titleFrame.maxY + #expect(abs(topPadding - bottomPadding) < 0.001) + } + + @Test + func `Title scrim has a soft transparent tail`() { + #expect(overviewTitleScrimAlpha(progress: 0, opacity: 1) == 0.48) + #expect(overviewTitleScrimAlpha(progress: 0.75, opacity: 1) < 0.04) + #expect(overviewTitleScrimAlpha(progress: 0.97, opacity: 1) < 0.001) + #expect(overviewTitleScrimAlpha(progress: 1, opacity: 1) == 0) } @Test @@ -164,7 +207,9 @@ struct OverviewPreviewTests { 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)) + let blurred = try #require( + progressivelyBlurredOverviewPreview(image, fadeHeight: 24) + ) #expect(blurred.width == image.width) #expect(blurred.height == image.height) diff --git a/Tests/DefiMacOSTests/PlatformEventTests.swift b/Tests/DefiMacOSTests/PlatformEventTests.swift index 03a351b..3b1263f 100644 --- a/Tests/DefiMacOSTests/PlatformEventTests.swift +++ b/Tests/DefiMacOSTests/PlatformEventTests.swift @@ -15,22 +15,6 @@ private final class TestAXElement: @unchecked Sendable { } struct PlatformEventTests { - @Test - func animationDisplayBarrierKeepsEveryRequestedAvailableDisplay() { - #expect( - resolvedAnimationDisplayIDs( - requested: [1, 2], - available: [1, 2, 3] - ) == [1, 2] - ) - #expect( - resolvedAnimationDisplayIDs( - requested: [9], - available: [3] - ) == [3] - ) - } - @Test func preparedAXRelationshipsResolveTransientOwners() { let ownerID = WindowID(rawValue: 1) diff --git a/Tests/DefiMacOSTests/WindowBorderTests.swift b/Tests/DefiMacOSTests/WindowBorderTests.swift index 4ab7280..e526d7e 100644 --- a/Tests/DefiMacOSTests/WindowBorderTests.swift +++ b/Tests/DefiMacOSTests/WindowBorderTests.swift @@ -158,10 +158,12 @@ struct WindowBorderTests { @Test func overviewReusesConfiguredWindowBorderPolicy() { - let active = overviewWindowBorderAppearance(isSelected: true, style: style) + let active = overviewWindowBorderAppearance(isSelected: true, style: style, scale: 0.5) #expect(active?.color == style.activeColor) - #expect(active?.width == style.width) - #expect(overviewWindowBorderAppearance(isSelected: false, style: style) == nil) + #expect(active?.width == style.width * 0.5) + #expect( + overviewWindowBorderAppearance(isSelected: false, style: style, scale: 0.5) == nil + ) let inactiveStyle = WindowBorderStyle( enabled: true, @@ -173,10 +175,11 @@ struct WindowBorderTests { ) let inactive = overviewWindowBorderAppearance( isSelected: false, - style: inactiveStyle + style: inactiveStyle, + scale: 0.5 ) #expect(inactive?.color == inactiveStyle.inactiveColor) - #expect(inactive?.width == inactiveStyle.width) + #expect(inactive?.width == inactiveStyle.width * 0.5) } @Test diff --git a/Tests/DefiModelTests/ModelTests.swift b/Tests/DefiModelTests/ModelTests.swift index af64749..e94b698 100644 --- a/Tests/DefiModelTests/ModelTests.swift +++ b/Tests/DefiModelTests/ModelTests.swift @@ -23,6 +23,8 @@ struct ModelTests { #expect(workspace.focusedColumn == 0) #expect(workspace.columns.isEmpty) #expect(workspace.floatingWindows.isEmpty) + #expect(workspace.kind == .named) + #expect(workspace.name == "1") } @Test(arguments: [ @@ -39,6 +41,12 @@ struct ModelTests { #expect(testCase.command.movesWindowBetweenWorkspaces == testCase.movesWindow) } + @Test + func `Sending a column does not follow it`() { + #expect(Command.moveColumnToWorkspace(.named("web"), follow: true).followsWindowMove) + #expect(!Command.moveColumnToWorkspace(.named("web"), follow: false).followsWindowMove) + } + @Test(arguments: [ ("focus-column left", Command.focusColumn(.left)), ("focus-column first", Command.focusColumn(.first)), @@ -49,6 +57,27 @@ struct ModelTests { ("move-window-to-monitor up", Command.moveWindowToMonitor(.up)), ("focus-floating next", Command.focusFloating(.next)), ("workspace sim", Command.switchWorkspace(WorkspaceID(rawValue: "sim"))), + ("focus-workspace down", Command.focusWorkspace(.relative(.down))), + ("focus-workspace-position 4", Command.focusWorkspace(.position(4))), + ( + "move-column-to-workspace up", + Command.moveColumnToWorkspace(.relative(.up), follow: true) + ), + ( + "move-column-to-workspace-name up", + Command.moveColumnToWorkspace(.named("up"), follow: true) + ), + ( + "move-window-to-workspace down", + Command.moveWindowToWorkspaceTarget(.relative(.down), follow: true) + ), + ( + "send-window-to-workspace-position 3", + Command.moveWindowToWorkspaceTarget(.position(3), follow: false) + ), + ("reorder-workspace down", Command.reorderWorkspace(.down)), + ("move-workspace-to-monitor right", Command.moveWorkspaceToMonitor(.right)), + ("focus-monitor left", Command.focusMonitor(.left)), ("maximize-column", Command.maximizeColumn), ("toggle-floating", Command.toggleFloating), ("activate-floating", Command.activateFloating), diff --git a/Tests/DefiRuntimeTests/DynamicWorkspaceTests.swift b/Tests/DefiRuntimeTests/DynamicWorkspaceTests.swift new file mode 100644 index 0000000..8ede214 --- /dev/null +++ b/Tests/DefiRuntimeTests/DynamicWorkspaceTests.swift @@ -0,0 +1,258 @@ +import DefiConfig +import DefiModel +import Testing + +@testable import DefiRuntime + +struct DynamicWorkspaceTests { + private let primary = MonitorID(rawValue: 1) + private let secondary = MonitorID(rawValue: 2) + + @Test + func `Named workspaces are global and every monitor gets one trailing workspace`() { + var state = RuntimeState( + config: Config( + workspaces: WorkspacesConfig( + names: ["dev", "web", "chat"], + monitors: ["chat": 2] + ) + ) + ) + + state.attachMonitor(primary) + state.attachMonitor(secondary) + + #expect(state.monitors[0].workspaces.compactMap(\.name) == ["dev", "web"]) + #expect(state.monitors[1].workspaces.compactMap(\.name) == ["chat"]) + #expect( + state.monitors.allSatisfy { monitor in + monitor.workspaces.filter { $0.kind == .trailing }.count == 1 + && monitor.workspaces.last?.kind == .trailing + }) + #expect(Set(state.monitors.flatMap(\.workspaces).map(\.id)).count == 5) + } + + @Test + func `Populated trailing workspace becomes ordinary and is replaced`() throws { + var state = RuntimeState(config: Config()) + state.attachMonitor(primary) + let originalTrailing = state.monitors[0].activeWorkspace + let window = makeWindow(1, monitorID: primary) + + try discoverWindow(window, decision: RuleDecision(), state: &state) + + #expect(state.monitors[0].workspaces.count == 2) + #expect(state.monitors[0].workspaces[0].id == originalTrailing) + #expect(state.monitors[0].workspaces[0].kind == .ordinary) + #expect(state.monitors[0].workspaces[1].kind == .trailing) + #expect(state.monitors[0].activeWorkspace == originalTrailing) + } + + @Test + func `Empty ordinary workspace disappears only after becoming inactive`() throws { + var state = RuntimeState(config: Config()) + state.attachMonitor(primary) + let window = makeWindow(1, monitorID: primary) + try discoverWindow(window, decision: RuleDecision(), state: &state) + + _ = reconcileWindows([], config: Config(), state: &state) + #expect(state.monitors[0].workspaces.map(\.kind) == [.ordinary, .trailing]) + + try reduce(.focusWorkspace(.relative(.down)), on: primary, state: &state) + + #expect(state.monitors[0].workspaces.map(\.kind) == [.trailing]) + #expect(state.monitors[0].activeWorkspace == state.monitors[0].workspaces[0].id) + } + + @Test + func `Workspace positions clamp to trailing and vertical navigation does not wrap`() throws { + var state = RuntimeState( + config: Config(workspaces: WorkspacesConfig(names: ["dev", "web"])) + ) + state.attachMonitor(primary) + + #expect(state.resolveWorkspaceTarget(.position(0), on: 0) == nil) + + try reduce(.focusWorkspace(.position(99)), on: primary, state: &state) + #expect(state.monitors[0].workspaces.last?.id == state.monitors[0].activeWorkspace) + + let trailing = state.monitors[0].activeWorkspace + try reduce(.focusWorkspace(.relative(.down)), on: primary, state: &state) + #expect(state.monitors[0].activeWorkspace == trailing) + + try reduce(.focusWorkspace(.relative(.up)), on: primary, state: &state) + #expect(state.monitors[0].activeWorkspace == WorkspaceID(rawValue: "web")) + } + + @Test + func `Named rule routes a window to the workspace owner`() throws { + let config = Config( + workspaces: WorkspacesConfig(names: ["dev", "chat"], monitors: ["chat": 2]) + ) + var state = RuntimeState(config: config) + state.attachMonitor(primary) + state.attachMonitor(secondary) + let window = makeWindow(1, monitorID: primary) + + try discoverWindow( + window, + decision: RuleDecision( + workspace: WorkspaceID(rawValue: "chat"), + followFocus: true + ), + isNativelyFocused: true, + state: &state + ) + + #expect(state.location(containing: window.id)?.monitorID == secondary) + #expect(state.location(containing: window.id)?.workspaceID == WorkspaceID(rawValue: "chat")) + #expect(state.monitors[1].activeWorkspace == WorkspaceID(rawValue: "chat")) + } + + @Test + func `Disconnected workspaces return only to the same monitor identity`() throws { + let config = Config( + workspaces: WorkspacesConfig(names: ["dev", "chat"], monitors: ["chat": 2]) + ) + var state = RuntimeState(config: config) + state.attachMonitor(primary) + state.attachMonitor(secondary) + let window = makeWindow(1, monitorID: secondary) + try discoverWindow( + window, + decision: RuleDecision(workspace: WorkspaceID(rawValue: "chat")), + state: &state + ) + + state.retainMonitors([primary]) + + #expect(state.workspaceLocation(for: WorkspaceID(rawValue: "chat"))?.monitorIndex == 0) + #expect(state.location(containing: window.id)?.monitorID == primary) + + state.retainMonitors([primary, secondary]) + + #expect(state.workspaceLocation(for: WorkspaceID(rawValue: "chat"))?.monitorIndex == 1) + #expect(state.location(containing: window.id)?.monitorID == secondary) + } + + @Test + func `Moving a column to trailing follows it and creates the next trailing workspace`() throws { + var state = RuntimeState( + config: Config(workspaces: WorkspacesConfig(names: ["dev"])) + ) + state.attachMonitor(primary) + let window = makeWindow(1, monitorID: primary) + try discoverWindow( + window, + decision: RuleDecision(workspace: WorkspaceID(rawValue: "dev")), + state: &state + ) + + try reduce( + .moveColumnToWorkspace(.relative(.down), follow: true), + on: primary, + state: &state + ) + + #expect(state.location(containing: window.id)?.workspaceID == state.monitors[0].activeWorkspace) + #expect(state.monitors[0].workspaces.map(\.kind) == [.named, .ordinary, .trailing]) + } + + @Test + func `Moving a column to a remote named workspace follows its owner`() throws { + let config = Config( + workspaces: WorkspacesConfig(names: ["dev", "chat"], monitors: ["chat": 2]) + ) + var state = RuntimeState(config: config) + state.attachMonitor(primary) + state.attachMonitor(secondary) + let window = makeWindow(1, monitorID: primary) + try discoverWindow( + window, + decision: RuleDecision(workspace: WorkspaceID(rawValue: "dev")), + state: &state + ) + + try reduce( + .moveColumnToWorkspace(.named("chat"), follow: true), + on: primary, + state: &state + ) + + #expect(state.location(containing: window.id)?.monitorID == secondary) + #expect(state.location(containing: window.id)?.workspaceID == WorkspaceID(rawValue: "chat")) + #expect(state.monitors[1].activeWorkspace == WorkspaceID(rawValue: "chat")) + } + + @Test + func `Replacing every display identity preserves globally unique workspaces`() { + let replacement = MonitorID(rawValue: 3) + var state = RuntimeState( + config: Config(workspaces: WorkspacesConfig(names: ["dev", "web"])) + ) + state.attachMonitor(primary) + let originalNamedIDs = Set( + state.monitors.flatMap(\.workspaces).filter { $0.kind == .named }.map(\.id) + ) + + state.retainMonitors([replacement]) + + let replacementIDs = state.monitors.flatMap(\.workspaces).map(\.id) + #expect(Set(replacementIDs).isSuperset(of: originalNamedIDs)) + #expect(Set(replacementIDs).count == replacementIDs.count) + #expect(state.disconnectedMonitors[primary] != nil) + } + + @Test + func `Readding a removed configured name restores its named identity`() { + var original = RuntimeState( + config: Config(workspaces: WorkspacesConfig(names: ["dev"])) + ) + original.attachMonitor(primary) + original.monitors[0].workspaces[0].columns = [ + Column(window: WindowID(rawValue: 1), width: .fraction(0.8)) + ] + + let removed = RuntimeState(config: Config(), topology: original.topology) + #expect(removed.monitors[0].workspaces[0].kind == .ordinary) + + let restored = RuntimeState( + config: Config(workspaces: WorkspacesConfig(names: ["dev"])), + topology: removed.topology + ) + #expect(restored.monitors[0].workspaces[0].kind == .named) + #expect(restored.monitors[0].workspaces[0].name == "dev") + } + + @Test + func `Topology restore keeps workspace identity columns widths focus and scroll`() throws { + let config = Config(workspaces: WorkspacesConfig(names: ["dev"])) + var state = RuntimeState(config: config) + state.attachMonitor(primary) + let window = makeWindow(1, monitorID: primary) + try discoverWindow( + window, + decision: RuleDecision(workspace: WorkspaceID(rawValue: "dev")), + state: &state + ) + state.monitors[0].workspaces[0].columns[0].width = .pixels(777) + state.monitors[0].workspaces[0].scrollOffset = 42 + state.monitors[0].workspaces[0].targetScrollOffset = 84 + + let restored = RuntimeState(config: config, topology: state.topology) + + #expect(restored.monitors == state.monitors) + #expect(restored.windows == state.windows) + #expect(restored.location(containing: window.id)?.workspaceID == WorkspaceID(rawValue: "dev")) + } + + private func makeWindow(_ id: UInt64, monitorID: MonitorID) -> Window { + Window( + id: WindowID(rawValue: id), + appID: "app", + title: "Window", + frame: Rect(x: 0, y: 0, width: 500, height: 700), + monitorID: monitorID + ) + } +} diff --git a/Tests/DefiRuntimeTests/FloatingWindowTests.swift b/Tests/DefiRuntimeTests/FloatingWindowTests.swift index 65c754b..29ed04a 100644 --- a/Tests/DefiRuntimeTests/FloatingWindowTests.swift +++ b/Tests/DefiRuntimeTests/FloatingWindowTests.swift @@ -657,7 +657,12 @@ struct FloatingWindowTests { let externalMonitorID = MonitorID(rawValue: 2) let tools = WorkspaceID(rawValue: "tools") var state = RuntimeState( - config: Config(workspaces: WorkspacesConfig(names: ["dev", tools.rawValue])) + config: Config( + workspaces: WorkspacesConfig( + names: ["dev", tools.rawValue], + monitors: [tools.rawValue: 2] + ) + ) ) state.attachMonitor(monitorID) state.attachMonitor(externalMonitorID) @@ -676,8 +681,8 @@ struct FloatingWindowTests { #expect(state.monitors[0].workspaces[0].floatingWindows.isEmpty) #expect(state.monitors[0].workspaces[0].focusedLayer == .tiled) - #expect(state.monitors[1].workspaces[1].floatingWindows == [floater.id]) - #expect(state.monitors[1].workspaces[1].focusedLayer == .floating) + #expect(state.monitors[1].workspaces[0].floatingWindows == [floater.id]) + #expect(state.monitors[1].workspaces[0].focusedLayer == .floating) #expect(state.selectedWindowID(on: externalMonitorID) == floater.id) #expect(state.windows[floater.id]?.monitorID == externalMonitorID) #expect(state.suspendedTiledPlacements[floater.id] == nil) diff --git a/Tests/DefiRuntimeTests/LayoutGapRuntimeTests.swift b/Tests/DefiRuntimeTests/LayoutGapRuntimeTests.swift index f0518ab..3684730 100644 --- a/Tests/DefiRuntimeTests/LayoutGapRuntimeTests.swift +++ b/Tests/DefiRuntimeTests/LayoutGapRuntimeTests.swift @@ -12,6 +12,9 @@ struct LayoutGapRuntimeTests { gaps: 8, outerTopGap: 1, outerBottomGap: 0 + ), + decorations: DecorationsConfig( + borders: BordersConfig(placement: "inside") ) ) @@ -25,6 +28,29 @@ struct LayoutGapRuntimeTests { #expect(state.layout.outerLeftGap == 8) } + @Test + func outsideBorderReservesEveryViewportEdge() { + let config = Config( + layout: LayoutConfig( + gaps: 4, + outerTopGap: 1, + outerRightGap: 2, + outerBottomGap: 0, + outerLeftGap: 3 + ), + decorations: DecorationsConfig( + borders: BordersConfig(width: 4, placement: "outside") + ) + ) + + let layout = RuntimeState(config: config).layout + + #expect(layout.outerTopGap == 4) + #expect(layout.outerRightGap == 4) + #expect(layout.outerBottomGap == 4) + #expect(layout.outerLeftGap == 4) + } + @Test func outsideBorderStaysInsideViewportAtScrolledEdges() { let config = Config( diff --git a/Tests/DefiRuntimeTests/MouseReorderingTests.swift b/Tests/DefiRuntimeTests/MouseReorderingTests.swift index a8c3b70..3008efc 100644 --- a/Tests/DefiRuntimeTests/MouseReorderingTests.swift +++ b/Tests/DefiRuntimeTests/MouseReorderingTests.swift @@ -457,6 +457,17 @@ struct MouseReorderingTests { commandQuietPeriodElapsed: false, nativeFocusSyncPending: true )) + #expect( + desktopSynchronizationIsReady( + scrollAnimationActive: false, + animatedWritesPending: true, + mouseGestureSyncPending: false, + needsDesktopSync: true, + periodicSyncDue: false, + commandQuietPeriodElapsed: false, + nativeFocusSyncPending: true, + nativeFocusHasNewerHumanIntent: false + ) == false) } @Test diff --git a/Tests/DefiRuntimeTests/OverviewRuntimeTests.swift b/Tests/DefiRuntimeTests/OverviewRuntimeTests.swift index 241adfa..f035434 100644 --- a/Tests/DefiRuntimeTests/OverviewRuntimeTests.swift +++ b/Tests/DefiRuntimeTests/OverviewRuntimeTests.swift @@ -9,6 +9,7 @@ struct OverviewRuntimeTests { let secondMonitor = MonitorID(rawValue: 2) let firstWorkspace = WorkspaceID(rawValue: "1") let secondWorkspace = WorkspaceID(rawValue: "2") + let remoteWorkspace = WorkspaceID(rawValue: "remote") @Test func `Moves a tiled window to an exact stack atomically`() throws { @@ -54,7 +55,8 @@ struct OverviewRuntimeTests { outerLeftGap: 0 ) ) - #expect(layout.frames.map(\.frame) == [ + #expect( + layout.frames.map(\.frame) == [ Rect(x: 0, y: 0, width: 500, height: 400), Rect(x: 0, y: 400, width: 500, height: 400), ]) @@ -84,7 +86,8 @@ struct OverviewRuntimeTests { state: &state ) - #expect(state.monitors[0].workspaces[0].columns.map(\.windows) == [ + #expect( + state.monitors[0].workspaces[0].columns.map(\.windows) == [ [moving], [neighbor], ]) } @@ -155,7 +158,7 @@ struct OverviewRuntimeTests { intent(for: moving), target: .floating( monitorID: secondMonitor, - workspaceID: secondWorkspace, + workspaceID: remoteWorkspace, relativeFrame: Rect(x: 0.25, y: 0.25, width: 0.3, height: 0.25) ), viewports: [ @@ -167,7 +170,7 @@ struct OverviewRuntimeTests { #expect(state.windows[moving]?.floating == true) #expect(state.windows[moving]?.monitorID == secondMonitor) - #expect(state.monitors[1].workspaces[1].floatingWindows == [moving]) + #expect(state.monitors[1].workspaces[0].floatingWindows == [moving]) #expect( result.floatingFrameUpdates[moving] == Rect(x: 1_500, y: 300, width: 600, height: 300) @@ -193,7 +196,7 @@ struct OverviewRuntimeTests { intent(for: moving), target: .floating( monitorID: secondMonitor, - workspaceID: secondWorkspace, + workspaceID: remoteWorkspace, relativeFrame: Rect(x: 0.9, y: -0.2, width: 0.3, height: 0.25) ), viewports: [ @@ -266,7 +269,11 @@ struct OverviewRuntimeTests { private func makeState() -> RuntimeState { var state = RuntimeState( config: Config( - workspaces: WorkspacesConfig(names: ["1", "2"], defaultName: "1") + workspaces: WorkspacesConfig( + names: ["1", "2", "remote"], + defaultName: "1", + monitors: ["remote": 2] + ) ) ) state.attachMonitor(firstMonitor) diff --git a/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift b/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift index 1d6e876..9710fe2 100644 --- a/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift +++ b/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift @@ -245,7 +245,7 @@ struct PlacementPreferencesTests { } @Test - func `Recording shared workspace across monitors omits monitor`() throws { + func `Recording global workspace keeps its owner monitor`() throws { let config = Config(workspaces: WorkspacesConfig(names: ["dev", "web"])) let externalMonitorID = MonitorID(rawValue: 2) var state = RuntimeState(config: config) @@ -280,7 +280,10 @@ struct PlacementPreferencesTests { #expect( preferences.applications["com.example.chat"] - == WindowPlacementPreference(workspaceID: WorkspaceID(rawValue: "web"))) + == WindowPlacementPreference( + workspaceID: WorkspaceID(rawValue: "web"), + monitorID: monitorID + )) } @Test diff --git a/Tests/DefiRuntimeTests/RuntimeMonitorTests.swift b/Tests/DefiRuntimeTests/RuntimeMonitorTests.swift index d986cfb..1c9447a 100644 --- a/Tests/DefiRuntimeTests/RuntimeMonitorTests.swift +++ b/Tests/DefiRuntimeTests/RuntimeMonitorTests.swift @@ -82,18 +82,21 @@ struct RuntimeMonitorTests { ) #expect(state.monitors.count == 1) - #expect(state.monitors[0].workspaces[0].columns[0].width == .pixels(600)) + let migrated = state.monitors[0].workspaces.first { + $0.columns.contains(where: { $0.windows.contains(WindowID(rawValue: 9)) }) + } + #expect(migrated?.columns[0].width == .pixels(600)) } @Test func `Disconnected monitor migrates and scales suspended placement`() { let externalID = MonitorID(rawValue: 2) - let workspaceID = WorkspaceID(rawValue: "dev") let modalID = WindowID(rawValue: 10) - let config = Config(workspaces: WorkspacesConfig(names: [workspaceID.rawValue])) + let config = Config(workspaces: WorkspacesConfig(names: ["dev"])) var state = RuntimeState(config: config) state.attachMonitor(monitorID) state.attachMonitor(externalID) + let workspaceID = state.monitors[1].workspaces[0].id state.monitors[0].workspaces[0].columns = [ Column(window: WindowID(rawValue: 1), width: .fraction(0.5)) ] @@ -129,7 +132,7 @@ struct RuntimeMonitorTests { == SuspendedTiledPlacement( monitorID: monitorID, workspaceID: workspaceID, - columnIndex: 2, + columnIndex: 1, windowIndex: 0, column: Column( windows: [modalID], @@ -140,87 +143,6 @@ struct RuntimeMonitorTests { )) } - @Test - func `Disconnected monitor places migrated suspension after target suspensions`() { - let externalID = MonitorID(rawValue: 2) - let workspaceID = WorkspaceID(rawValue: "dev") - let targetModalID = WindowID(rawValue: 8) - let sourceModalID = WindowID(rawValue: 10) - let config = Config(workspaces: WorkspacesConfig(names: [workspaceID.rawValue])) - var state = RuntimeState(config: config) - state.attachMonitor(monitorID) - state.attachMonitor(externalID) - state.monitors[0].workspaces[0].columns = [ - Column(window: WindowID(rawValue: 1), width: .fraction(0.5)) - ] - state.suspendedTiledPlacements[targetModalID] = SuspendedTiledPlacement( - monitorID: monitorID, - workspaceID: workspaceID, - columnIndex: 1, - windowIndex: 0, - column: Column(window: targetModalID, width: .pixels(600)) - ) - state.suspendedTiledPlacements[sourceModalID] = SuspendedTiledPlacement( - monitorID: externalID, - workspaceID: workspaceID, - columnIndex: 1, - windowIndex: 0, - column: Column(window: sourceModalID, width: .pixels(600)) - ) - - state.retainMonitors( - [monitorID], - previousViewports: [ - monitorID: Rect(x: 0, y: 0, width: 1_500, height: 900), - externalID: Rect(x: 1_500, y: 0, width: 3_000, height: 1_600), - ], - nextViewports: [ - monitorID: Rect(x: 0, y: 0, width: 1_500, height: 900) - ] - ) - - #expect(state.suspendedTiledPlacements[sourceModalID]?.columnIndex == 3) - } - - @Test - func `Disconnected monitor places migration after highest suspended column`() { - let externalID = MonitorID(rawValue: 2) - let workspaceID = WorkspaceID(rawValue: "dev") - let targetModalID = WindowID(rawValue: 8) - let sourceModalID = WindowID(rawValue: 10) - let config = Config(workspaces: WorkspacesConfig(names: [workspaceID.rawValue])) - var state = RuntimeState(config: config) - state.attachMonitor(monitorID) - state.attachMonitor(externalID) - state.suspendedTiledPlacements[targetModalID] = SuspendedTiledPlacement( - monitorID: monitorID, - workspaceID: workspaceID, - columnIndex: 2, - windowIndex: 0, - column: Column(window: targetModalID, width: .pixels(600)) - ) - state.suspendedTiledPlacements[sourceModalID] = SuspendedTiledPlacement( - monitorID: externalID, - workspaceID: workspaceID, - columnIndex: 1, - windowIndex: 0, - column: Column(window: sourceModalID, width: .pixels(600)) - ) - - state.retainMonitors( - [monitorID], - previousViewports: [ - monitorID: Rect(x: 0, y: 0, width: 1_500, height: 900), - externalID: Rect(x: 1_500, y: 0, width: 3_000, height: 1_600), - ], - nextViewports: [ - monitorID: Rect(x: 0, y: 0, width: 1_500, height: 900) - ] - ) - - #expect(state.suspendedTiledPlacements[sourceModalID]?.columnIndex == 4) - } - @Test func `Rebound focus monitor requires migrated window to remain selected`() { let externalID = MonitorID(rawValue: 2) @@ -228,6 +150,7 @@ struct RuntimeMonitorTests { var state = RuntimeState(config: config) state.attachMonitor(monitorID) state.attachMonitor(externalID) + let externalWorkspaceID = state.monitors[1].workspaces[0].id state.monitors[0].workspaces[0].columns = [ Column(window: WindowID(rawValue: 1), width: .fraction(0.5)) ] @@ -249,38 +172,39 @@ struct RuntimeMonitorTests { #expect( state.reboundFocusMonitorID( for: WindowID(rawValue: 9), - requestedWorkspaceID: WorkspaceID(rawValue: "dev") + requestedWorkspaceID: externalWorkspaceID ) == nil) - state.monitors[0].workspaces[0].focusedColumn = 1 + let migratedIndex = state.monitors[0].workspaces.firstIndex(where: { + $0.id == externalWorkspaceID + })! + state.monitors[0].activeWorkspace = externalWorkspaceID + state.monitors[0].workspaces[migratedIndex].focusedColumn = 0 #expect( state.reboundFocusMonitorID( for: WindowID(rawValue: 9), - requestedWorkspaceID: WorkspaceID(rawValue: "dev") + requestedWorkspaceID: externalWorkspaceID ) == monitorID) } @Test - func `Each monitor owns independent nine workspaces`() throws { + func `Each monitor owns a distinct trailing workspace`() throws { let externalID = MonitorID(rawValue: 2) var state = RuntimeState(config: Config()) state.attachMonitor(monitorID) state.attachMonitor(externalID) - #expect(state.monitors[0].workspaces.map(\.id.rawValue) == (1...9).map(String.init)) - #expect(state.monitors[1].workspaces.map(\.id.rawValue) == (1...9).map(String.init)) + #expect(state.monitors[0].workspaces.map(\.kind) == [.trailing]) + #expect(state.monitors[1].workspaces.map(\.kind) == [.trailing]) + #expect(state.monitors[0].workspaces[0].id != state.monitors[1].workspaces[0].id) try reduce( - .switchWorkspace(WorkspaceID(rawValue: "5")), + .focusWorkspace(.position(5)), on: externalID, state: &state ) - #expect( - state.monitors.first(where: { $0.id == monitorID })?.activeWorkspace - == WorkspaceID(rawValue: "1")) - #expect( - state.monitors.first(where: { $0.id == externalID })?.activeWorkspace - == WorkspaceID(rawValue: "5")) + #expect(state.monitors[0].activeWorkspace == state.monitors[0].workspaces[0].id) + #expect(state.monitors[1].activeWorkspace == state.monitors[1].workspaces[0].id) } @Test @@ -693,6 +617,7 @@ struct MonitorMoveFocusTests { var state = RuntimeState(config: config) state.attachMonitor(sourceID) state.attachMonitor(targetID) + let targetWorkspaceID = state.monitors[1].activeWorkspace state.monitors[0].workspaces[0].columns = [ Column(window: ownerID, width: .pixels(500)) ] @@ -744,7 +669,7 @@ struct MonitorMoveFocusTests { let placement = try #require(state.suspendedTiledPlacements[transientID]) #expect(placement.monitorID == targetID) - #expect(placement.workspaceID == workspaceID) + #expect(placement.workspaceID == targetWorkspaceID) #expect(placement.columnIndex == 0) #expect(placement.column.width == .pixels(1_000)) diff --git a/Tests/DefiRuntimeTests/TransientReconciliationTests.swift b/Tests/DefiRuntimeTests/TransientReconciliationTests.swift index 155e210..baaf1e3 100644 --- a/Tests/DefiRuntimeTests/TransientReconciliationTests.swift +++ b/Tests/DefiRuntimeTests/TransientReconciliationTests.swift @@ -132,7 +132,12 @@ struct TransientReconciliationTests { let sourceMonitor = MonitorID(rawValue: 1) let targetMonitor = MonitorID(rawValue: 2) let web = WorkspaceID(rawValue: "web") - let config = Config(workspaces: WorkspacesConfig(names: ["dev", web.rawValue])) + let config = Config( + workspaces: WorkspacesConfig( + names: ["dev", web.rawValue], + monitors: [web.rawValue: 2] + ) + ) var state = RuntimeState(config: config) state.attachMonitor(sourceMonitor) state.attachMonitor(targetMonitor) @@ -287,7 +292,12 @@ struct TransientReconciliationTests { func delayedOwnershipRelocatesTransientToOwnerWorkspace() throws { let firstMonitor = MonitorID(rawValue: 1) let secondMonitor = MonitorID(rawValue: 2) - let config = Config(workspaces: WorkspacesConfig(names: ["dev", "web"])) + let config = Config( + workspaces: WorkspacesConfig( + names: ["dev", "web"], + monitors: ["web": 2] + ) + ) var state = RuntimeState(config: config) state.attachMonitor(firstMonitor) state.attachMonitor(secondMonitor) @@ -335,7 +345,8 @@ struct TransientReconciliationTests { #expect(relocated == [transient.id]) #expect(location.monitorID == secondMonitor) #expect(location.workspaceID == WorkspaceID(rawValue: "web")) - #expect(state.monitors.allSatisfy { $0.activeWorkspace.rawValue == "dev" }) + #expect(state.monitors[0].activeWorkspace.rawValue == "dev") + #expect(state.monitors[1].workspaces.last?.id == state.monitors[1].activeWorkspace) var selectedTransient = Window( id: WindowID(rawValue: 4), diff --git a/contrib/sketchybar/plugins/defi_workspace.sh b/contrib/sketchybar/plugins/defi_workspace.sh index d20c7c1..6d953c8 100755 --- a/contrib/sketchybar/plugins/defi_workspace.sh +++ b/contrib/sketchybar/plugins/defi_workspace.sh @@ -26,11 +26,12 @@ fi entry=$(printf '%s' "$state" | jq -c \ --argjson display "$display" \ --arg workspace "$workspace" \ - '.monitors[] | select(.display == $display) | .workspaces[] | select(.name == $workspace)') + '.monitors[] | select(.display == $display) | .workspaces[] | select(.id == $workspace)') [ -n "$entry" ] || exit 0 active=$(printf '%s' "$entry" | jq -r '.active') count=$(printf '%s' "$entry" | jq -r '.windowCount') +label=$(printf '%s' "$entry" | jq -r '.name // if .kind == "trailing" then "+" else (.position | tostring) end') if [ "$active" = true ]; then background=on @@ -45,5 +46,6 @@ else fi "$SKETCHYBAR_BIN" --set "$NAME" \ + icon="$label" \ background.drawing="$background" \ icon.color="$icon_color" diff --git a/contrib/sketchybar/plugins/defi_workspace_reconcile.sh b/contrib/sketchybar/plugins/defi_workspace_reconcile.sh index 91a67c2..8beeaa0 100755 --- a/contrib/sketchybar/plugins/defi_workspace_reconcile.sh +++ b/contrib/sketchybar/plugins/defi_workspace_reconcile.sh @@ -25,7 +25,7 @@ fi next_file="$STATE_FILE.next.$$" trap 'rm -f "$next_file"' EXIT printf '%s' "$state" \ - | jq -r '.monitors[] | .display as $display | .workspaces[] | "defi.\($display).\(.name)"' \ + | jq -r '.monitors[] | .display as $display | .workspaces[] | "defi.\($display).\(.id)"' \ > "$next_file" if [ -f "$STATE_FILE" ]; then diff --git a/defi.example.toml b/defi.example.toml index e14f735..7d938a9 100644 --- a/defi.example.toml +++ b/defi.example.toml @@ -4,7 +4,9 @@ default_key_modifier = "hyper" gaps = 4 [workspaces] -names = ["dev", "web", "dev-secondary", "tools", "misc", "6", "7", "8", "9"] +names = ["dev", "web", "dev-secondary", "tools"] +default = "dev" +monitors = { dev-secondary = 2 } [modifier_combinations] hyper = "Alt + Cmd + Ctrl" diff --git a/docs/adr/0004-adopt-dynamic-workspaces.md b/docs/adr/0004-adopt-dynamic-workspaces.md new file mode 100644 index 0000000..457c3b0 --- /dev/null +++ b/docs/adr/0004-adopt-dynamic-workspaces.md @@ -0,0 +1,34 @@ +--- +status: accepted +--- + +# Adopt globally unique dynamic workspaces + +Defi replaces the configured workspace copies on every monitor with globally +unique workspaces owned by one monitor at a time. Ordinary workspaces are +dynamic: every monitor keeps one trailing empty workspace, populated trailing +workspaces become ordinary, and empty inactive ordinary workspaces disappear. +Named workspaces persist, are the only valid application-rule destinations, and +remain directly addressable regardless of their current position. This matches +Niri's workspace model while retaining Defi's per-monitor active workspace and +scrolling strip. + +Each workspace has monitor affinity distinct from its current owner. Display +loss temporarily migrates affected workspaces, and a confident reconnection +returns them; explicit monitor moves update affinity, while ambiguous matches +leave workspaces on the fallback monitor. Defi preserves the full workspace +topology across daemon restarts within one macOS session, then rebuilds named +workspaces from configuration for a new session. + +Positions are one-based, non-wrapping, and resolve past the current stack to the +trailing workspace. Vertical workspace transitions use public Accessibility +position writes and animate only when the whole transition is safe and +refresh-budget compliant; otherwise every participating window switches +immediately. The empty trailing workspace remains focusable without a Defi-owned +focus sink, accepting that macOS may keep sending ordinary keys to the previously +focused parked application until a new native focus intent occurs. + +The TOML `workspaces.names` list now declares only persistent named workspaces, +with optional default and monitor affinity. Built-in defaults declare none. +Removing a configured name converts an occupied workspace to ordinary and drops +it only when empty. Runtime naming is deferred. diff --git a/docs/plans/overview.md b/docs/plans/overview.md index 43c0157..76db506 100644 --- a/docs/plans/overview.md +++ b/docs/plans/overview.md @@ -2,6 +2,10 @@ Status: implemented and validated on 2026-08-24. +ADR 0004 supersedes this plan's static workspace and vertical keyboard +navigation assumptions. The plan otherwise records the implemented Overview +baseline at the validation date above. + 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.