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..161e45f9 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, @@ -380,6 +381,7 @@ extension Daemon { frontmostProcessID: snapshot.frontmostProcessID, displays: latestMonitors.map { ($0.id, $0.name) }, appliesOpenFocus: hasSynchronizedDesktop, + now: snapshotCompletedAt, state: &state ) var forcedOpeningFocusWindowID: WindowID? @@ -389,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, @@ -397,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, @@ -421,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/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..fa421b95 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,20 @@ 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 && !fullscreenWindowIDs.contains($0.key) + } 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 +288,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 +313,117 @@ 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 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( + 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 } + let columnIndex = workspace.columns.firstIndex { $0.windows.contains(windowID) } + return LateTitleRulePlacement( + workspaceID: location.workspaceID, + floating: workspace.floatingWindows.contains(windowID), + columnIndex: columnIndex, + column: columnIndex.map { workspace.columns[$0] }, + floatingFrame: workspace.floatingWindows.contains(windowID) ? state.windows[windowID]?.frame : nil + ) +} + +/// 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 + if decision.workspace == nil, decision.monitor == nil { + decision.workspace = candidate.placement.workspaceID + } + 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 + ) + // 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 +} + private func adjustNativeFullscreenPlacements( afterRemoving windowID: WindowID, state: inout RuntimeState diff --git a/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift b/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift index 04e86856..0c264416 100644 --- a/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift +++ b/Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift @@ -108,4 +108,85 @@ 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: [ + "[[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(rules: 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) + """.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: "dialog", 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/DefiRuntimeTests/WindowLifecycleTests.swift b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift index 82fcc6b3..15830e36 100644 --- a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift +++ b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift @@ -34,6 +34,153 @@ 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, 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, userCommand: Command?, titleChangedTwice: Bool + ) throws { + var (config, state, window) = lateTitleFixture() + 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([editor, window("New Tab")], config: config, now: 10.05, state: &state) + } + let placement = state.monitors[0].workspaces.first { $0.id == WorkspaceID(rawValue: "dev") } + + reconcileWindows([editor, window("Web Inspector")], config: config, now: 10 + titleAt, state: &state) + + #expect(state.location(containing: window("").id)?.workspaceID == WorkspaceID(rawValue: "dev")) + #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() + 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)]) + 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)