diff --git a/Sources/BetterTileCore/AmbientLayoutReconciler.swift b/Sources/BetterTileCore/AmbientLayoutReconciler.swift index 5f870f1..0a5c001 100644 --- a/Sources/BetterTileCore/AmbientLayoutReconciler.swift +++ b/Sources/BetterTileCore/AmbientLayoutReconciler.swift @@ -43,8 +43,9 @@ public enum AmbientLayoutTransition: Sendable { /// The single-window latch fired in any layout mode. case placeSingleWindow(session: LayoutSession, placement: Placement) /// Apply the derived Bento placements. A work area requests authoritative - /// settlement after a successful commit and deliberately remains absent - /// from `session.lastWorkArea` until that settlement succeeds. + /// settlement after a successful commit. It waits in + /// `session.pendingWorkArea` and reaches `session.lastWorkArea` only when + /// that settlement succeeds. case applyLayout(session: LayoutSession, placements: [Placement], settleWorkArea: BTRect?) public var session: LayoutSession { @@ -88,9 +89,9 @@ public struct AmbientLayoutReconciler: Sendable { var session = original let before = session.bentoState let frames = Dictionary(uniqueKeysWithValues: observation.windows.map { ($0.id, $0.frame) }) - let workAreaChanged = session.lastWorkArea.map { - !$0.approximatelyEquals(observation.display.visibleFrame, tolerance: 0.5) - } ?? false + let workAreaChanged = [session.lastWorkArea, session.pendingWorkArea].contains { area in + area.map { !$0.approximatelyEquals(observation.display.visibleFrame, tolerance: 0.5) } ?? false + } var shouldApply = false if session.mode == .bento, !session.automaticWritesSuspended { @@ -170,7 +171,9 @@ public struct AmbientLayoutReconciler: Sendable { : nil session.lastObservedFrames = frames - if settleWorkArea == nil { + if let settleWorkArea { + session.pendingWorkArea = settleWorkArea + } else { session.lastWorkArea = observation.display.visibleFrame } diff --git a/Sources/BetterTileCore/LayoutSessions.swift b/Sources/BetterTileCore/LayoutSessions.swift index 2672d39..ddd5c1e 100644 --- a/Sources/BetterTileCore/LayoutSessions.swift +++ b/Sources/BetterTileCore/LayoutSessions.swift @@ -56,7 +56,13 @@ public struct LayoutSession: Hashable, Sendable { public var excludedFocusWindowIDs: Set public var bentoReinsertionAnchors: [WindowID: BentoReinsertionAnchor] public var lastObservedFrames: [WindowID: BTRect] - public var lastWorkArea: BTRect? + /// The work area the layout was last confirmed for. + public var lastWorkArea: BTRect? { + didSet { pendingWorkArea = nil } + } + /// The work area of a layout written but not yet confirmed. The windows + /// already moved for it, so changing back to `lastWorkArea` is a change. + public var pendingWorkArea: BTRect? public var isBentoInitialized: Bool public var hasAppliedSingleWindowPlacement: Bool /// Corroborates a window's disappearance before its pane is given up. diff --git a/Tests/BetterTileCoreTests/AmbientLayoutReconcilerTests.swift b/Tests/BetterTileCoreTests/AmbientLayoutReconcilerTests.swift index 505b0ef..b1eeeb9 100644 --- a/Tests/BetterTileCoreTests/AmbientLayoutReconcilerTests.swift +++ b/Tests/BetterTileCoreTests/AmbientLayoutReconcilerTests.swift @@ -616,3 +616,43 @@ func aTemporarilyMissingBentoPeerDoesNotMaximizeTheRemainingWindow(knownMember: } #expect(stableSession == thirdSession) } + +/// The work area changes and changes back before the first layout settles, +/// as when a display or Dock change is undone quickly. The windows already +/// moved for the first change, so the change back must lay them out again. +@Test func aWorkAreaThatChangesBackBeforeSettlingIsAppliedAgain() throws { + let windows = [ambientLeft, ambientRight] + let smaller = BTRect(x: 0, y: 0, width: 1_200, height: 720) + let session = LayoutSession( + displayID: ambientDisplayID, + mode: .bento, + bentoState: horizontalPaneState(windows), + windowIDs: Set(windows.map(\.id)), + bentoInsertionOrder: windows.map(\.id), + lastWorkArea: ambientBounds + ) + let reconciler = ambientReconciler() + let shrink = reconciler.transition( + session: session, + observation: ambientObservation( + windows: windows, + display: DisplaySnapshot(id: ambientDisplayID, frame: ambientBounds, visibleFrame: smaller) + ) + ) + guard case let .applyLayout(shrunk, placements, _) = shrink else { + Issue.record("A smaller work area must apply the layout") + return + } + let moved = windows.map { window in + var window = window + window.frame = placements.first { $0.windowID == window.id }?.frame ?? window.frame + return window + } + + let back = reconciler.transition(session: shrunk, observation: ambientObservation(windows: moved)) + guard case let .applyLayout(_, restored, _) = back else { + Issue.record("The work area changed back, so the windows must be laid out for it again") + return + } + #expect(restored.allSatisfy { $0.frame.maxY == ambientBounds.maxY }) +}