From 36ba87558c30e71ea15df9f01602266c5af117b9 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 12:07:03 +0100 Subject: [PATCH 1/6] fix(rules): apply title rules when the title arrives just after opening A window discovered untitled is placed again, once, on its first new title within a second of opening, unless the user moved, resized, or floated it first. The window keeps the focus it already has. --- CONFIGURATION.md | 11 ++- .../DaemonDesktopSynchronization.swift | 1 + Sources/DefiRuntime/RuntimeState.swift | 2 + .../DefiRuntime/WindowReconciliation.swift | 98 ++++++++++++++++++- .../WindowLifecycleTests.swift | 58 +++++++++++ Tests/Fixtures/DesktopFocusFixture.swift | 10 +- 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 612e05e4..44578f6b 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -316,9 +316,14 @@ include_initial_width_in_cycle = true ``` Prefer `app_id` alone when it is enough. Titles follow the system language, -some apps set them shortly after opening a window, and rules apply only when Defi -first discovers a window, so a title set later never matches. `title` has no -wildcards; use `title_regex` for anchors or alternatives, such as +and some apps set them just after opening a window. Defi checks the rules again +once, on the first new title within a second of opening, and places the window +as if it had opened with that title: `workspace`, `monitor`, `window`, +`initial_column_width`, and `intrinsic_size` apply, while the window keeps the +focus it already has, so `open_focused` and `follow_focus` do not. It skips this +if you moved, resized, or floated the window first. A title that changes later, +such as a browser tab's, never moves a window. `title` has no wildcards; use +`title_regex` for anchors or alternatives, such as `title_regex = "^(Settings|Preferences)$"`.
diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index 87d28d3f..fede9f87 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -380,6 +380,7 @@ extension Daemon { frontmostProcessID: snapshot.frontmostProcessID, displays: latestMonitors.map { ($0.id, $0.name) }, appliesOpenFocus: hasSynchronizedDesktop, + now: snapshotCompletedAt, state: &state ) var forcedOpeningFocusWindowID: WindowID? diff --git a/Sources/DefiRuntime/RuntimeState.swift b/Sources/DefiRuntime/RuntimeState.swift index ddc831ee..70ebf07e 100644 --- a/Sources/DefiRuntime/RuntimeState.swift +++ b/Sources/DefiRuntime/RuntimeState.swift @@ -71,6 +71,8 @@ public struct RuntimeState: Equatable, Sendable { public var reservedEdgesByMonitor: [MonitorID: ReservedEdges] /// Managed windows by latest committed focus, the current one first. public var recentWindowIDs: [WindowID] = [] + /// Windows opened moments ago whose rules may still match a title the app sets late. + public var lateTitleRuleCandidates: [WindowID: LateTitleRuleCandidate] = [:] public init(config: Config) { let names = config.workspaces.names.map(WorkspaceID.init(rawValue:)) diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index 0e60472e..1bacce94 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -1,7 +1,7 @@ import DefiConfig import DefiCore import DefiModel -import Darwin +import Foundation public func discoverWindow( _ original: Window, @@ -170,9 +170,11 @@ public func reconcileWindows( frontmostProcessID: pid_t? = nil, displays: [(id: MonitorID, name: String?)] = [], appliesOpenFocus: Bool = true, + now: TimeInterval = 0, state: inout RuntimeState ) -> Set { var relocatedTransientIDs = Set() + var openedWindowIDs: [WindowID] = [] let discoveredIDs = Set(discovered.map(\.id)) let fullscreenWindowIDs = nativeFullscreenWindowIDs.subtracting( explicitlyRemovedWindowIDs @@ -201,11 +203,19 @@ public func reconcileWindows( state.pendingNativeFullscreenWidthResetWindowIDs.remove(existingID) state.suspendedTiledPlacements[existingID] = nil } + state.lateTitleRuleCandidates = state.lateTitleRuleCandidates.filter { + state.windows[$0.key] != nil && now <= $0.value.expiresAt + } for window in discovered { if state.windows[window.id] == nil { var decision = config.decision(for: window) - if !appliesOpenFocus { decision.openFocused = nil } + // Windows already open when Defi starts carry their final titles. + if appliesOpenFocus { + openedWindowIDs.append(window.id) + } else { + decision.openFocused = nil + } try? discoverWindow( window, decision: decision, @@ -277,6 +287,11 @@ public func reconcileWindows( } } state.windows[window.id] = updated + if applyLateTitleRules( + observed: window, config: config, displays: displays, state: &state + ) { + relocatedTransientIDs.insert(window.id) + } } } // ponytail: bounded convergence scan; owner-ordering can replace it if deep chains become common. @@ -297,9 +312,88 @@ public func reconcileWindows( reconcileNativeFullscreenWindows(fullscreenWindowIDs, state: &state) state.pendingNativeFullscreenWidthResetWindowIDs.subtract(explicitlyRemovedWindowIDs) state.maintainWorkspaceLifecycle() + for windowID in openedWindowIDs { + guard let window = state.windows[windowID], + let placement = lateTitleRulePlacement(of: windowID, state: state) + else { continue } + state.lateTitleRuleCandidates[windowID] = LateTitleRuleCandidate( + title: window.title, + decision: config.decision(for: window), + placement: placement, + expiresAt: now + lateTitleRuleGrace + ) + } return relocatedTransientIDs } +/// How long after opening a window's first new title may still apply its rules, +/// so a title that changes during use, such as a browser tab's, never moves it. +public let lateTitleRuleGrace: TimeInterval = 1 + +public struct LateTitleRuleCandidate: Equatable, Sendable { + var title: String + var decision: RuleDecision + var placement: LateTitleRulePlacement + var expiresAt: TimeInterval +} + +struct LateTitleRulePlacement: Equatable, Sendable { + var workspaceID: WorkspaceID + var floating: Bool + var column: Column? +} + +private func lateTitleRulePlacement( + of windowID: WindowID, state: RuntimeState +) -> LateTitleRulePlacement? { + guard let location = state.location(containing: windowID), + let workspace = state.monitors.first(where: { $0.id == location.monitorID })? + .workspaces.first(where: { $0.id == location.workspaceID }) + else { return nil } + return LateTitleRulePlacement( + workspaceID: location.workspaceID, + floating: workspace.floatingWindows.contains(windowID), + column: workspace.columns.first { $0.windows.contains(windowID) } + ) +} + +/// Places a window again as if it had opened with its first new title, once, unless +/// the user moved, resized, or toggled it since it opened. +private func applyLateTitleRules( + observed window: Window, + config: Config, + displays: [(id: MonitorID, name: String?)], + state: inout RuntimeState +) -> Bool { + guard let candidate = state.lateTitleRuleCandidates[window.id], + !window.title.isEmpty, window.title != candidate.title + else { return false } + state.lateTitleRuleCandidates[window.id] = nil + var decision = config.decision(for: window) + func placementRules(_ decision: RuleDecision) -> RuleDecision { + var rules = decision + rules.openFocused = nil + rules.followFocus = false + rules.includeInitialWidthInCycle = false + return rules + } + guard placementRules(decision) != placementRules(candidate.decision), + lateTitleRulePlacement(of: window.id, state: state) == candidate.placement, + let monitorID = state.monitorID(containing: window.id) + else { return false } + // Focus was settled at opening: a focused window follows, any other moves quietly. + decision.openFocused = state.selectedWindowID(on: monitorID) == window.id + removeWindowFromEveryWorkspace(window.id, state: &state) + state.windows[window.id] = nil + try? discoverWindow( + window, + decision: decision, + ruleMonitorID: decision.monitor.flatMap { ruleMonitorID($0, displays: displays) }, + state: &state + ) + return true +} + private func adjustNativeFullscreenPlacements( afterRemoving windowID: WindowID, state: inout RuntimeState diff --git a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift index 82fcc6b3..2f911c05 100644 --- a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift +++ b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift @@ -34,6 +34,64 @@ struct WindowLifecycleTests { #expect(state.monitors[0].workspaces[1].columns[0].windows == [window.id]) } + private func lateTitleFixture() -> (Config, RuntimeState, (String) -> Window) { + let config = Config( + workspaces: WorkspacesConfig(names: ["dev", "web"]), + rules: [Rule(title: "Inspector", workspace: "web", window: .forceFloating)] + ) + var state = RuntimeState(config: config) + state.attachMonitor(monitorID) + reconcileWindows([], config: config, appliesOpenFocus: false, state: &state) + let window = { (title: String) in + Window( + id: WindowID(rawValue: 1), appID: "browser", title: title, + frame: Rect(x: 0, y: 0, width: 600, height: 800), processID: 7, monitorID: self.monitorID + ) + } + return (config, state, window) + } + + @Test + func `A title set right after opening applies its rules`() throws { + var (config, state, window) = lateTitleFixture() + reconcileWindows([window("")], config: config, frontmostProcessID: 7, state: &state) + #expect(state.location(containing: window("").id)?.workspaceID == WorkspaceID(rawValue: "dev")) + + reconcileWindows([window("Web Inspector")], config: config, frontmostProcessID: 7, state: &state) + + let web = WorkspaceID(rawValue: "web") + #expect(state.location(containing: window("").id)?.workspaceID == web) + #expect(state.windows[window("").id]?.floating == true) + #expect(state.monitors[0].activeWorkspace == web) + } + + @Test(arguments: [ + // Too late: the title arrives after the grace. + (lateTitleRuleGrace + 0.1, false, false), + // The user floated the window before its title arrived. + (0.1, true, false), + (0.1, false, true), + ]) + func `A late title never overrides the user or a window in use`( + titleAt: Double, userToggledFloating: Bool, titleChangedTwice: Bool + ) throws { + var (config, state, window) = lateTitleFixture() + reconcileWindows([window("")], config: config, frontmostProcessID: 7, now: 10, state: &state) + if userToggledFloating { + try reduce(.toggleFloating, on: monitorID, state: &state) + } + if titleChangedTwice { + // A first title that matches nothing uses up the one re-evaluation. + reconcileWindows([window("New Tab")], config: config, now: 10.05, state: &state) + } + let floating = state.windows[window("").id]?.floating + + reconcileWindows([window("Web Inspector")], config: config, now: 10 + titleAt, state: &state) + + #expect(state.location(containing: window("").id)?.workspaceID == WorkspaceID(rawValue: "dev")) + #expect(state.windows[window("").id]?.floating == floating) + } + @Test func `Rule monitor and open focus place a new window`() throws { let second = MonitorID(rawValue: 2) diff --git a/Tests/Fixtures/DesktopFocusFixture.swift b/Tests/Fixtures/DesktopFocusFixture.swift index 8628af40..419b6bfe 100644 --- a/Tests/Fixtures/DesktopFocusFixture.swift +++ b/Tests/Fixtures/DesktopFocusFixture.swift @@ -18,7 +18,13 @@ private final class DesktopFocusFixtureDelegate: NSObject, NSApplicationDelegate styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) - window.title = CommandLine.arguments[1] + // `late` opens untitled and sets the title afterwards, as some apps do. + let title = CommandLine.arguments[1] + if CommandLine.arguments.dropFirst(2).first == "late" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { window.title = title } + } else { + window.title = title + } window.isReleasedWhenClosed = false window.level = .normal window.hidesOnDeactivate = false @@ -50,7 +56,7 @@ private final class DesktopFocusFixtureDelegate: NSObject, NSApplicationDelegate @main private struct DesktopFocusFixture { @MainActor static func main() { - guard CommandLine.arguments.count == 2 else { exit(2) } + guard (2...3).contains(CommandLine.arguments.count) else { exit(2) } let application = NSApplication.shared let delegate = DesktopFocusFixtureDelegate() application.setActivationPolicy(.regular) From 78ff5c7fc1bbad07eabc313700e9b57eead2b338 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 18:26:08 +0100 Subject: [PATCH 2/6] fix(rules): place late-titled floating windows on the rule's display A late title rule re-placed the window in the runtime, but the daemon only ran its opening steps for new windows, so a window made floating on another display kept its frame on the first one. Late-ruled windows now go through the same placement step, without the opening focus rules: their focus was settled when they opened. The test fixture takes a 'title ' line on stdin instead of a 'late' argument, so a scenario can set the title at any time. --- .../DaemonDesktopSynchronization.swift | 10 ++- .../DaemonDesktopSynchronizationTests.swift | 78 +++++++++++++++++++ Tests/Fixtures/DesktopFocusFixture.swift | 15 ++-- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index fede9f87..527c7e63 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -368,6 +368,7 @@ extension Daemon { }) == true { invalidateSubmittedCommandFocus() } + let lateTitleRuleCandidateIDs = Set(state.lateTitleRuleCandidates.keys) let relocatedTransientIDs = reconcileWindows( snapshot.windows, config: config, @@ -390,7 +391,10 @@ extension Daemon { state.windows[$0.key] != nil && snapshotCompletedAt - $0.value <= unfocusedOpeningGrace } // Discovery order, as `reconcileWindows` selects: the last forced window it kept selected wins. - for windowID in snapshot.windows.map(\.id) where !previouslyManagedWindowIDs.contains(windowID) { + // A late title rule places a window as an opening would, but its focus was settled when it opened. + let lateTitleRuledWindowIDs = lateTitleRuleCandidateIDs.intersection(relocatedTransientIDs) + for windowID in snapshot.windows.map(\.id) + where !previouslyManagedWindowIDs.contains(windowID) || lateTitleRuledWindowIDs.contains(windowID) { guard let window = state.windows[windowID] else { continue } let decision = config.decision(for: window) if decision.monitor != nil, window.floating, let observedMonitorID = window.monitorID, @@ -398,7 +402,9 @@ extension Daemon { { ruleRelocatedFloatingMonitorIDs[windowID] = observedMonitorID } - guard hasSynchronizedDesktop, let openFocused = decision.openFocused else { continue } + guard hasSynchronizedDesktop, !lateTitleRuledWindowIDs.contains(windowID), + let openFocused = decision.openFocused + else { continue } if !openFocused { unfocusedOpenings[windowID] = snapshotCompletedAt } else if snapshot.focusedWindowID != windowID, diff --git a/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift b/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift index 04e86856..f05015ff 100644 --- a/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift +++ b/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift @@ -108,4 +108,82 @@ struct DaemonDesktopSynchronizationTests { let releasedLock = try DaemonInstanceLock(url: lockURL) withExtendedLifetime(releasedLock) {} } + + /// A window whose title arrives just after it opens ends where it would have opened with that title. + @Test(arguments: ["workspace = \"web\"", "window = \"force-floating\"\nmonitor = \"Second\""]) + @NavigationActor + func lateTitleRuleMovesTheWindowAsAnOpeningWould(rule: String) async throws { + struct Outcome: Equatable { + var monitorID: MonitorID? + var workspaceID: WorkspaceID? + var activeWorkspaceID: WorkspaceID? + var selected: Bool + var activeMonitorID: MonitorID? + var floatingFrame: Rect? + } + let menuBar = await MainActor.run { MenuBarState(accessibilityTrusted: { false }) } + func open(late: Bool) throws -> Outcome { + let directory = URL(filePath: "/tmp/defi-late-title-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let configURL = directory.appending(path: "config.toml") + try """ + [workspaces] + names = ["web"] + [animation] + enabled = false + [[rules]] + title = "Web Inspector" + \(rule) + """.write(to: configURL, atomically: true, encoding: .utf8) + let daemon = try Daemon( + options: DaemonOptions(arguments: [ + "--config", configURL.path, "--socket", directory.appending(path: "daemon.sock").path, + ]), + menuBar: menuBar, + instanceLockURL: directory.appending(path: "daemon.lock"), + topologyStore: WorkspaceTopologyStore(url: directory.appending(path: "topology.json")), + workspaceStatePublisher: { _ in }, + desktopSnapshotOverviewUpdater: { _ in } + ) + defer { daemon.flushPendingTopologyWrite() } + daemon.restorationInFlight = true + let first = MonitorSnapshot( + id: MonitorID(rawValue: 1), frame: Rect(x: 0, y: 0, width: 1_000, height: 700), name: "First") + let second = MonitorSnapshot( + id: MonitorID(rawValue: 2), frame: Rect(x: 1_000, y: 0, width: 1_000, height: 700), name: "Second") + let editor = Window(id: WindowID(rawValue: 1), appID: "app", title: "Editor", + frame: Rect(x: 0, y: 0, width: 800, height: 700), monitorID: first.id) + var opened = Window(id: WindowID(rawValue: 2), appID: "app", title: late ? "" : "Web Inspector", + frame: Rect(x: 100, y: 100, width: 400, height: 300), monitorID: first.id) + func apply(_ windows: [Window]) { + daemon.applyDesktopSnapshot( + DesktopSnapshot(monitors: [first, second], windows: windows, focusedWindowID: windows.last?.id, + nativeFocusChanged: true), + nativeFocusWasPending: false, forceFullWindowRefresh: false, forceWindowListRefresh: false, + forceApplicationInventoryRefresh: false, targetedWindowRetryRefresh: false, + consumePeriodicWindowRefresh: false + ) + } + apply([editor]) + apply([editor, opened]) + if late { + opened.title = "Web Inspector" + apply([editor, opened]) + } + let location = daemon.state.location(containing: opened.id) + return Outcome( + monitorID: location?.monitorID, + workspaceID: location?.workspaceID, + activeWorkspaceID: daemon.state.monitors.first { $0.id == location?.monitorID }?.activeWorkspace, + selected: location.map { daemon.state.selectedWindowID(on: $0.monitorID) == opened.id } ?? false, + activeMonitorID: daemon.activeMonitorID, + floatingFrame: daemon.floatingWindowFrames[opened.id] + ) + } + let immediate = try open(late: false) + #expect(immediate.workspaceID == WorkspaceID(rawValue: "web") || immediate.monitorID == MonitorID(rawValue: 2)) + let late = try open(late: true) + #expect(late == immediate) + } } diff --git a/Tests/Fixtures/DesktopFocusFixture.swift b/Tests/Fixtures/DesktopFocusFixture.swift index 419b6bfe..809cba20 100644 --- a/Tests/Fixtures/DesktopFocusFixture.swift +++ b/Tests/Fixtures/DesktopFocusFixture.swift @@ -18,13 +18,7 @@ private final class DesktopFocusFixtureDelegate: NSObject, NSApplicationDelegate styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) - // `late` opens untitled and sets the title afterwards, as some apps do. - let title = CommandLine.arguments[1] - if CommandLine.arguments.dropFirst(2).first == "late" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { window.title = title } - } else { - window.title = title - } + window.title = CommandLine.arguments[1] window.isReleasedWhenClosed = false window.level = .normal window.hidesOnDeactivate = false @@ -40,6 +34,11 @@ private final class DesktopFocusFixtureDelegate: NSObject, NSApplicationDelegate // Cooperative activation: only the active app can let the test runner activate. DispatchQueue.global().async { while let line = readLine() { + // Some apps set their title after the window opens. + if line.hasPrefix("title ") { + DispatchQueue.main.async { window.title = String(line.dropFirst(6)) } + continue + } guard line.hasPrefix("yield "), let pid = pid_t(line.dropFirst(6)) else { continue } DispatchQueue.main.async { NSRunningApplication(processIdentifier: pid).map { NSApplication.shared.yieldActivation(to: $0) } @@ -56,7 +55,7 @@ private final class DesktopFocusFixtureDelegate: NSObject, NSApplicationDelegate @main private struct DesktopFocusFixture { @MainActor static func main() { - guard (2...3).contains(CommandLine.arguments.count) else { exit(2) } + guard CommandLine.arguments.count == 2 else { exit(2) } let application = NSApplication.shared let delegate = DesktopFocusFixtureDelegate() application.setActivationPolicy(.regular) From 1cf2061e51adc4406797fd00e23cb0e37e74af63 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 18:28:34 +0100 Subject: [PATCH 3/6] fix(rules): keep a late title rule from undoing a column move Moving a column left or right leaves its Column value unchanged, so the late title check missed it and could send the window back to the rule's workspace. The recorded placement now includes the column's index. --- .../DefiRuntime/WindowReconciliation.swift | 5 ++- .../WindowLifecycleTests.swift | 32 +++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index 1bacce94..ab3cd605 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -340,6 +340,7 @@ public struct LateTitleRuleCandidate: Equatable, Sendable { struct LateTitleRulePlacement: Equatable, Sendable { var workspaceID: WorkspaceID var floating: Bool + var columnIndex: Int? var column: Column? } @@ -350,10 +351,12 @@ private func lateTitleRulePlacement( let workspace = state.monitors.first(where: { $0.id == location.monitorID })? .workspaces.first(where: { $0.id == location.workspaceID }) else { return nil } + let columnIndex = workspace.columns.firstIndex { $0.windows.contains(windowID) } return LateTitleRulePlacement( workspaceID: location.workspaceID, floating: workspace.floatingWindows.contains(windowID), - column: workspace.columns.first { $0.windows.contains(windowID) } + columnIndex: columnIndex, + column: columnIndex.map { workspace.columns[$0] } ) } diff --git a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift index 2f911c05..0de4a44a 100644 --- a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift +++ b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift @@ -67,29 +67,35 @@ struct WindowLifecycleTests { @Test(arguments: [ // Too late: the title arrives after the grace. - (lateTitleRuleGrace + 0.1, false, false), - // The user floated the window before its title arrived. - (0.1, true, false), - (0.1, false, true), - ]) + (lateTitleRuleGrace + 0.1, nil, false), + // The user floated or moved the window before its title arrived. + (0.1, Command.toggleFloating, false), + (0.1, Command.moveColumn(.left), false), + (0.1, nil, true), + ] as [(Double, Command?, Bool)]) func `A late title never overrides the user or a window in use`( - titleAt: Double, userToggledFloating: Bool, titleChangedTwice: Bool + titleAt: Double, userCommand: Command?, titleChangedTwice: Bool ) throws { var (config, state, window) = lateTitleFixture() - reconcileWindows([window("")], config: config, frontmostProcessID: 7, now: 10, state: &state) - if userToggledFloating { - try reduce(.toggleFloating, on: monitorID, state: &state) + let editor = Window( + id: WindowID(rawValue: 2), appID: "editor", title: "Code", + frame: Rect(x: 0, y: 0, width: 600, height: 800), monitorID: monitorID + ) + reconcileWindows([editor], config: config, appliesOpenFocus: false, state: &state) + reconcileWindows([editor, window("")], config: config, frontmostProcessID: 7, now: 10, state: &state) + if let userCommand { + try reduce(userCommand, on: monitorID, state: &state) } if titleChangedTwice { // A first title that matches nothing uses up the one re-evaluation. - reconcileWindows([window("New Tab")], config: config, now: 10.05, state: &state) + reconcileWindows([editor, window("New Tab")], config: config, now: 10.05, state: &state) } - let floating = state.windows[window("").id]?.floating + let placement = state.monitors[0].workspaces.first { $0.id == WorkspaceID(rawValue: "dev") } - reconcileWindows([window("Web Inspector")], config: config, now: 10 + titleAt, state: &state) + reconcileWindows([editor, window("Web Inspector")], config: config, now: 10 + titleAt, state: &state) #expect(state.location(containing: window("").id)?.workspaceID == WorkspaceID(rawValue: "dev")) - #expect(state.windows[window("").id]?.floating == floating) + #expect(state.monitors[0].workspaces.first { $0.id == WorkspaceID(rawValue: "dev") } == placement) } @Test From 347ba60d2f3c5f6e638527adde17a03b9e9f7bee Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 19:23:56 +0100 Subject: [PATCH 4/6] fix(rules): keep a late-ruled window's column and rebase its frame once A late rule that kept a focused window on its workspace reinserted it after its right neighbour; the column now goes back to its index. A window already floating when a late monitor rule moved it was rebased twice and landed at the display's edge; the rule rebase now owns it. The fixture's title command, used by no test, is dropped. --- .../DaemonDesktopSynchronization.swift | 2 ++ .../DefiRuntime/WindowReconciliation.swift | 20 +++++++++++++++ .../DaemonDesktopSynchronizationTests.swift | 15 ++++++----- .../WindowLifecycleTests.swift | 25 +++++++++++++++++++ Tests/Fixtures/DesktopFocusFixture.swift | 5 ---- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index 527c7e63..161e45f9 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -428,6 +428,8 @@ extension Daemon { ), windows: state.windows ).intersection(relocatedTransientIDs) + // A rule that sent the window to another display rebases it once, below. + .subtracting(ruleRelocatedFloatingMonitorIDs.keys) if let previousSelectedWindowID, let reboundMonitorID = state.reboundFocusMonitorID(for: previousSelectedWindowID), reboundMonitorID != previousActiveMonitorID diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index ab3cd605..1a4f0c98 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -394,6 +394,26 @@ private func applyLateTitleRules( ruleMonitorID: decision.monitor.flatMap { ruleMonitorID($0, displays: displays) }, state: &state ) + // A rule that keeps the window on its workspace leaves its column where it was. + if let oldIndex = candidate.placement.columnIndex, + let location = state.location(containing: window.id), + location.workspaceID == candidate.placement.workspaceID, + let monitorIndex = state.monitors.firstIndex(where: { $0.id == location.monitorID }), + let workspaceIndex = state.monitors[monitorIndex].workspaces.firstIndex( + where: { $0.id == location.workspaceID } + ) + { + var workspace = state.monitors[monitorIndex].workspaces[workspaceIndex] + if let newIndex = workspace.columns.firstIndex(where: { $0.windows.contains(window.id) }), + newIndex != oldIndex, oldIndex < workspace.columns.count + { + let focusedWindow = workspace.columns[workspace.focusedColumn].windows + workspace.columns.insert(workspace.columns.remove(at: newIndex), at: oldIndex) + workspace.focusedColumn = workspace.columns.firstIndex { $0.windows == focusedWindow } ?? oldIndex + repairWorkspaceScroll(&workspace, settings: state.layout) + state.monitors[monitorIndex].workspaces[workspaceIndex] = workspace + } + } return true } diff --git a/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift b/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift index f05015ff..0c264416 100644 --- a/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift +++ b/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift @@ -110,9 +110,14 @@ struct DaemonDesktopSynchronizationTests { } /// A window whose title arrives just after it opens ends where it would have opened with that title. - @Test(arguments: ["workspace = \"web\"", "window = \"force-floating\"\nmonitor = \"Second\""]) + @Test(arguments: [ + "[[rules]]\ntitle = \"Web Inspector\"\nworkspace = \"web\"", + "[[rules]]\ntitle = \"Web Inspector\"\nwindow = \"force-floating\"\nmonitor = \"Second\"", + // Already floating when it opens. + "[[rules]]\napp_id = \"dialog\"\nwindow = \"force-floating\"\n[[rules]]\ntitle = \"Web Inspector\"\nmonitor = \"Second\"", + ]) @NavigationActor - func lateTitleRuleMovesTheWindowAsAnOpeningWould(rule: String) async throws { + func lateTitleRuleMovesTheWindowAsAnOpeningWould(rules: String) async throws { struct Outcome: Equatable { var monitorID: MonitorID? var workspaceID: WorkspaceID? @@ -132,9 +137,7 @@ struct DaemonDesktopSynchronizationTests { names = ["web"] [animation] enabled = false - [[rules]] - title = "Web Inspector" - \(rule) + \(rules) """.write(to: configURL, atomically: true, encoding: .utf8) let daemon = try Daemon( options: DaemonOptions(arguments: [ @@ -154,7 +157,7 @@ struct DaemonDesktopSynchronizationTests { id: MonitorID(rawValue: 2), frame: Rect(x: 1_000, y: 0, width: 1_000, height: 700), name: "Second") let editor = Window(id: WindowID(rawValue: 1), appID: "app", title: "Editor", frame: Rect(x: 0, y: 0, width: 800, height: 700), monitorID: first.id) - var opened = Window(id: WindowID(rawValue: 2), appID: "app", title: late ? "" : "Web Inspector", + var opened = Window(id: WindowID(rawValue: 2), appID: "dialog", title: late ? "" : "Web Inspector", frame: Rect(x: 100, y: 100, width: 400, height: 300), monitorID: first.id) func apply(_ windows: [Window]) { daemon.applyDesktopSnapshot( diff --git a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift index 0de4a44a..ffb5d6ce 100644 --- a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift +++ b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift @@ -98,6 +98,31 @@ struct WindowLifecycleTests { #expect(state.monitors[0].workspaces.first { $0.id == WorkspaceID(rawValue: "dev") } == placement) } + @Test + func `A late title rule on the same workspace keeps the window's column`() throws { + let config = Config(rules: [Rule(title: "Inspector", initialColumnWidth: 0.5)]) + var state = RuntimeState(config: config) + state.attachMonitor(monitorID) + let window = { (id: UInt64, title: String) in + Window( + id: WindowID(rawValue: id), appID: "browser", title: title, + frame: Rect(x: 0, y: 0, width: 600, height: 800), processID: 7, monitorID: self.monitorID + ) + } + let left = window(1, "Left"), right = window(2, "Right") + reconcileWindows([left, right], config: config, appliesOpenFocus: false, state: &state) + focusWindow(left.id, state: &state) + reconcileWindows([left, right, window(3, "")], config: config, frontmostProcessID: 7, state: &state) + #expect(state.monitors[0].workspaces[0].columns.map(\.windows) == [[left.id], [WindowID(rawValue: 3)], [right.id]]) + + reconcileWindows([left, right, window(3, "Web Inspector")], config: config, state: &state) + + let workspace = state.monitors[0].workspaces[0] + #expect(workspace.columns.map(\.windows) == [[left.id], [WindowID(rawValue: 3)], [right.id]]) + #expect(workspace.columns[1].width == .fraction(0.5)) + #expect(state.selectedWindowID(on: monitorID) == WindowID(rawValue: 3)) + } + @Test func `Rule monitor and open focus place a new window`() throws { let second = MonitorID(rawValue: 2) diff --git a/Tests/Fixtures/DesktopFocusFixture.swift b/Tests/Fixtures/DesktopFocusFixture.swift index 809cba20..8628af40 100644 --- a/Tests/Fixtures/DesktopFocusFixture.swift +++ b/Tests/Fixtures/DesktopFocusFixture.swift @@ -34,11 +34,6 @@ private final class DesktopFocusFixtureDelegate: NSObject, NSApplicationDelegate // Cooperative activation: only the active app can let the test runner activate. DispatchQueue.global().async { while let line = readLine() { - // Some apps set their title after the window opens. - if line.hasPrefix("title ") { - DispatchQueue.main.async { window.title = String(line.dropFirst(6)) } - continue - } guard line.hasPrefix("yield "), let pid = pid_t(line.dropFirst(6)) else { continue } DispatchQueue.main.async { NSRunningApplication(processIdentifier: pid).map { NSApplication.shared.yieldActivation(to: $0) } From d3f0a99b2b0448e740b54672da299ff4909ab78e Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 19:26:26 +0100 Subject: [PATCH 5/6] fix(rules): end a late title candidate when its window goes fullscreen Native fullscreen saves the window's tiled placement and restores it on exit. A late rule applied meanwhile left the window on two workspaces. --- Sources/DefiRuntime/WindowReconciliation.swift | 3 ++- .../DefiRuntimeTests/WindowLifecycleTests.swift | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index 1a4f0c98..ed016c52 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -203,8 +203,9 @@ public func reconcileWindows( state.pendingNativeFullscreenWidthResetWindowIDs.remove(existingID) state.suspendedTiledPlacements[existingID] = nil } + // Native fullscreen saves a placement that a late rule would not update, so it ends the candidate. state.lateTitleRuleCandidates = state.lateTitleRuleCandidates.filter { - state.windows[$0.key] != nil && now <= $0.value.expiresAt + state.windows[$0.key] != nil && now <= $0.value.expiresAt && !fullscreenWindowIDs.contains($0.key) } for window in discovered { diff --git a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift index ffb5d6ce..f8b6fbde 100644 --- a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift +++ b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift @@ -98,6 +98,23 @@ struct WindowLifecycleTests { #expect(state.monitors[0].workspaces.first { $0.id == WorkspaceID(rawValue: "dev") } == placement) } + @Test + func `A late title leaves a native fullscreen window where it is`() { + var (config, state, window) = lateTitleFixture() + reconcileWindows([window("")], config: config, frontmostProcessID: 7, state: &state) + reconcileWindows([window("")], config: config, nativeFullscreenWindowIDs: [window("").id], state: &state) + + reconcileWindows( + [window("Web Inspector")], config: config, nativeFullscreenWindowIDs: [window("").id], state: &state + ) + reconcileWindows([window("Web Inspector")], config: config, state: &state) + + let workspaces = state.monitors[0].workspaces.filter { + $0.columns.contains { $0.windows.contains(window("").id) } || $0.floatingWindows.contains(window("").id) + } + #expect(workspaces.map(\.id) == [WorkspaceID(rawValue: "dev")]) + } + @Test func `A late title rule on the same workspace keeps the window's column`() throws { let config = Config(rules: [Rule(title: "Inspector", initialColumnWidth: 0.5)]) From d37b5dd3712a423602ef98cddf77635f6c5266b2 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 19:33:40 +0100 Subject: [PATCH 6/6] fix(rules): keep a late rule from moving a window the user moved A late rule without a workspace or display put the window on the monitor's active workspace, so switching workspaces during the grace carried it along; it now stays on its own workspace. A floating window's frame is now part of the recorded placement, so moving or resizing it cancels the late rule. --- .../DefiRuntime/WindowReconciliation.swift | 8 +++- .../WindowLifecycleTests.swift | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index ed016c52..fa421b95 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -343,6 +343,8 @@ struct LateTitleRulePlacement: Equatable, Sendable { var floating: Bool var columnIndex: Int? var column: Column? + /// A floating window's frame, so a move or resize by the user counts as a change. + var floatingFrame: Rect? } private func lateTitleRulePlacement( @@ -357,7 +359,8 @@ private func lateTitleRulePlacement( workspaceID: location.workspaceID, floating: workspace.floatingWindows.contains(windowID), columnIndex: columnIndex, - column: columnIndex.map { workspace.columns[$0] } + column: columnIndex.map { workspace.columns[$0] }, + floatingFrame: workspace.floatingWindows.contains(windowID) ? state.windows[windowID]?.frame : nil ) } @@ -387,6 +390,9 @@ private func applyLateTitleRules( else { return false } // Focus was settled at opening: a focused window follows, any other moves quietly. decision.openFocused = state.selectedWindowID(on: monitorID) == window.id + if decision.workspace == nil, decision.monitor == nil { + decision.workspace = candidate.placement.workspaceID + } removeWindowFromEveryWorkspace(window.id, state: &state) state.windows[window.id] = nil try? discoverWindow( diff --git a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift index f8b6fbde..15830e36 100644 --- a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift +++ b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift @@ -98,6 +98,47 @@ struct WindowLifecycleTests { #expect(state.monitors[0].workspaces.first { $0.id == WorkspaceID(rawValue: "dev") } == placement) } + @Test + func `A late rule without a destination keeps the window on its workspace`() throws { + let config = Config(rules: [Rule(title: "Inspector", initialColumnWidth: 0.5)]) + var state = RuntimeState(config: config) + state.attachMonitor(monitorID) + let window = { (title: String) in + Window( + id: WindowID(rawValue: 1), appID: "browser", title: title, + frame: Rect(x: 0, y: 0, width: 600, height: 800), processID: 7, monitorID: self.monitorID + ) + } + reconcileWindows([], config: config, appliesOpenFocus: false, state: &state) + reconcileWindows([window("")], config: config, frontmostProcessID: 7, state: &state) + let opened = try #require(state.location(containing: window("").id)?.workspaceID) + try reduce(.focusWorkspace(.relative(.down)), on: monitorID, state: &state) + #expect(state.monitors[0].activeWorkspace != opened) + + reconcileWindows([window("Web Inspector")], config: config, state: &state) + + #expect(state.location(containing: window("").id)?.workspaceID == opened) + #expect(state.monitors[0].workspaces.flatMap(\.columns).first { $0.windows == [window("").id] }?.width == .fraction(0.5)) + } + + @Test + func `A late title never moves a floating window the user moved`() { + var (config, state, window) = lateTitleFixture() + var dialog = window("") + dialog.floating = true + reconcileWindows([dialog], config: config, frontmostProcessID: 7, now: 10, state: &state) + #expect(state.location(containing: dialog.id)?.workspaceID == WorkspaceID(rawValue: "dev")) + dialog.frame.x += 200 + reconcileWindows( + [dialog], config: config, externallyChangedWindowIDs: [dialog.id], now: 10.1, state: &state + ) + dialog.title = "Web Inspector" + + reconcileWindows([dialog], config: config, now: 10.2, state: &state) + + #expect(state.location(containing: dialog.id)?.workspaceID == WorkspaceID(rawValue: "dev")) + } + @Test func `A late title leaves a native fullscreen window where it is`() { var (config, state, window) = lateTitleFixture()