From de8d77c586874c041317f157bbc040c9fdc4e92f Mon Sep 17 00:00:00 2001 From: LMC-Karma Date: Thu, 15 Oct 2026 14:57:21 -0700 Subject: [PATCH] Lay out Bento again when the work area changes back before settling A work-area change waits for its settlement before Bento records the new area. If the area changed back before that, Bento compared it with the old recorded area, saw no change, and left the windows sized for the area in between. Repair was the only way out. Keep the area of a written but unconfirmed layout in pendingWorkArea. A change from either the confirmed or the pending area now lays the windows out again. A failed settlement still leaves the change to retry, and confirming any work area clears the pending one. Co-Authored-By: Claude Opus 5.5 --- .../AmbientLayoutReconciler.swift | 15 ++++--- Sources/BetterTileCore/LayoutSessions.swift | 8 +++- .../AmbientLayoutReconcilerTests.swift | 40 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/Sources/BetterTileCore/AmbientLayoutReconciler.swift b/Sources/BetterTileCore/AmbientLayoutReconciler.swift index 5f870f13..0a5c001a 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 2672d393..ddd5c1eb 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 505b0ef9..b1eeeb90 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 }) +}