Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)$"`.

<details>
Expand Down
13 changes: 11 additions & 2 deletions Sources/DefiDaemon/DaemonDesktopSynchronization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ extension Daemon {
}) == true {
invalidateSubmittedCommandFocus()
}
let lateTitleRuleCandidateIDs = Set(state.lateTitleRuleCandidates.keys)
let relocatedTransientIDs = reconcileWindows(
snapshot.windows,
config: config,
Expand All @@ -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?
Expand All @@ -389,15 +391,20 @@ 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,
state.monitorID(containing: windowID) != observedMonitorID
{
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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/DefiRuntime/RuntimeState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:))
Expand Down
128 changes: 126 additions & 2 deletions Sources/DefiRuntime/WindowReconciliation.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import DefiConfig
import DefiCore
import DefiModel
import Darwin
import Foundation

public func discoverWindow(
_ original: Window,
Expand Down Expand Up @@ -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<WindowID> {
var relocatedTransientIDs = Set<WindowID>()
var openedWindowIDs: [WindowID] = []
let discoveredIDs = Set(discovered.map(\.id))
let fullscreenWindowIDs = nativeFullscreenWindowIDs.subtracting(
explicitlyRemovedWindowIDs
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
}
}
// ponytail: bounded convergence scan; owner-ordering can replace it if deep chains become common.
Expand All @@ -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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
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,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
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
Expand Down
81 changes: 81 additions & 0 deletions Tests/DefiDaemonTests/DaemonDesktopSynchronizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading