Skip to content

Commit e9082de

Browse files
committed
Avoid persisting transitional always-hidden layout state
1 parent 9a62a34 commit e9082de

4 files changed

Lines changed: 96 additions & 13 deletions

File tree

Thaw/MenuBar/ControlItem/ControlItem.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ final class ControlItem {
4848
}
4949

5050
/// A hiding state for a control item.
51-
enum HidingState {
51+
enum HidingState: Equatable {
5252
case showSection
5353
case hideSection
5454
}
@@ -117,7 +117,14 @@ final class ControlItem {
117117
}
118118

119119
/// The control item's hiding state (`@Published`).
120-
@Published var state = HidingState.hideSection
120+
@Published var state = HidingState.hideSection {
121+
didSet {
122+
guard oldValue != state, isSectionDivider else {
123+
return
124+
}
125+
appState?.itemManager.recordSectionDividerTransition()
126+
}
127+
}
121128

122129
/// The control item's window (`@Published`).
123130
@Published private(set) var window: NSWindow?
@@ -506,6 +513,9 @@ final class ControlItem {
506513
return
507514
}
508515
statusItem.isVisible = true
516+
if isSectionDivider {
517+
appState?.itemManager.recordSectionDividerTransition()
518+
}
509519
}
510520

511521
/// Removes the control item from the menu bar.
@@ -523,6 +533,9 @@ final class ControlItem {
523533
if !isSectionDivider {
524534
ControlItemDefaults[.preferredPosition, autosaveName] = cached
525535
}
536+
if isSectionDivider {
537+
appState?.itemManager.recordSectionDividerTransition()
538+
}
526539
}
527540

528541
/// Updates the status item's visibility without clearing its preferred position.

Thaw/MenuBar/MenuBarItems/LayoutSolver.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -868,13 +868,15 @@ enum LayoutSolver {
868868
isResettingLayout: Bool,
869869
isInStartupSettling: Bool,
870870
isApplyingProfileLayout: Bool,
871-
temporarilyShownItemContextsIsEmpty: Bool
871+
temporarilyShownItemContextsIsEmpty: Bool,
872+
hasRecentSectionDividerTransition: Bool
872873
) -> Bool {
873874
!isRestoringItemOrder &&
874875
!isResettingLayout &&
875876
!isInStartupSettling &&
876877
!isApplyingProfileLayout &&
877-
temporarilyShownItemContextsIsEmpty
878+
temporarilyShownItemContextsIsEmpty &&
879+
!hasRecentSectionDividerTransition
878880
}
879881

880882
// MARK: - Pending rehide identifiers

Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ final class MenuBarItemManager: ObservableObject {
117117
/// giving macOS time to settle menu bar item positions.
118118
static let uiSettleDelay: Duration = .milliseconds(300)
119119

120+
/// Grace period after a hidden/always-hidden divider geometry change
121+
/// during which cache snapshots are transitional and must not be
122+
/// promoted into persisted layout state.
123+
static let sectionDividerTransitionSettleDelay: Duration = .milliseconds(750)
124+
120125
/// The current cache of menu bar items.
121126
@Published private(set) var itemCache = ItemCache(displayID: nil)
122127

@@ -142,6 +147,9 @@ final class MenuBarItemManager: ObservableObject {
142147

143148
/// Timestamp of the most recent menu bar item move operation.
144149
private var lastMoveOperationTimestamp: ContinuousClock.Instant?
150+
/// Timestamp of the most recent hidden/always-hidden divider state or
151+
/// visibility change.
152+
private var lastSectionDividerTransitionTimestamp: ContinuousClock.Instant?
145153

146154
/// Cached timeouts for move operations.
147155
private var moveOperationTimeouts = [MenuBarItemTag: Duration]()
@@ -197,6 +205,7 @@ final class MenuBarItemManager: ObservableObject {
197205
// - isRestoringItemOrder (+ isRestoringItemOrderTimestamp)
198206
// - isApplyingProfileLayout
199207
// - suppressNextNewLeftmostItemRelocation
208+
// - lastSectionDividerTransitionTimestamp
200209
//
201210
// 2. Startup settling. Gates restore and saves during the cold-boot
202211
// or post-permission-grant window when many apps appear at once:
@@ -1329,11 +1338,28 @@ final class MenuBarItemManager: ObservableObject {
13291338
return timestamp.duration(to: .now) <= duration
13301339
}
13311340

1341+
/// Returns a Boolean value that indicates whether a hidden/always-hidden
1342+
/// divider changed state recently enough that section classification may
1343+
/// still reflect a transitional layout.
1344+
func sectionDividerTransitionOccurred(within duration: Duration) -> Bool {
1345+
guard let timestamp = lastSectionDividerTransitionTimestamp else {
1346+
return false
1347+
}
1348+
return timestamp.duration(to: .now) <= duration
1349+
}
1350+
13321351
/// Records that a move operation occurred outside of Thaw's own `move()` function
13331352
/// (e.g. the user cmd+dragged an item directly on the menu bar).
13341353
func recordExternalMoveOperation() {
13351354
lastMoveOperationTimestamp = .now
13361355
}
1356+
1357+
/// Records that the hidden/always-hidden divider geometry changed
1358+
/// (show/hide, style/width change, or add/remove), so the next cache
1359+
/// snapshot should not be treated as stable saved layout state.
1360+
func recordSectionDividerTransition() {
1361+
lastSectionDividerTransitionTimestamp = .now
1362+
}
13371363
}
13381364

13391365
// MARK: - Cache Gate
@@ -1755,12 +1781,16 @@ extension MenuBarItemManager {
17551781
isRestoringItemOrderTimestamp = nil
17561782
}
17571783

1784+
let hasRecentSectionDividerTransition = sectionDividerTransitionOccurred(
1785+
within: Self.sectionDividerTransitionSettleDelay
1786+
)
17581787
if LayoutSolver.shouldPersistSavedOrder(
17591788
isRestoringItemOrder: isRestoringItemOrder,
17601789
isResettingLayout: isResettingLayout,
17611790
isInStartupSettling: isInStartupSettling,
17621791
isApplyingProfileLayout: isApplyingProfileLayout,
1763-
temporarilyShownItemContextsIsEmpty: temporarilyShownItemContexts.isEmpty
1792+
temporarilyShownItemContextsIsEmpty: temporarilyShownItemContexts.isEmpty,
1793+
hasRecentSectionDividerTransition: hasRecentSectionDividerTransition
17641794
) {
17651795
// Don't persist if any items are in a transient blocked state (x=-1).
17661796
// Wait for the next cache cycle when bounds are reliable.
@@ -1777,6 +1807,10 @@ extension MenuBarItemManager {
17771807
"Skipping saveSectionOrder; blocked items detected (x=-1), will retry on next cache tick"
17781808
)
17791809
}
1810+
} else if hasRecentSectionDividerTransition {
1811+
MenuBarItemManager.diagLog.debug(
1812+
"Skipping saveSectionOrder; section divider transition grace period active"
1813+
)
17801814
}
17811815
MenuBarItemManager.diagLog.debug("Updated menu bar item cache: visible=\(context.cache[.visible].count), hidden=\(context.cache[.hidden].count), alwaysHidden=\(context.cache[.alwaysHidden].count)")
17821816
}
@@ -6358,6 +6392,10 @@ extension MenuBarItemManager {
63586392
MenuBarItemManager.diagLog.debug("applySavedLayout: skipping, profile apply in flight")
63596393
return false
63606394
}
6395+
guard !sectionDividerTransitionOccurred(within: Self.sectionDividerTransitionSettleDelay) else {
6396+
MenuBarItemManager.diagLog.debug("applySavedLayout: skipping, section divider transition grace period active")
6397+
return false
6398+
}
63616399
// 5 s cooldown after a recent move (same value the legacy
63626400
// restoreItemsToSavedSections used) prevents cascading
63636401
// re-applies when many apps relaunch in quick succession.

ThawTests/ShouldPersistSavedOrderTests.swift

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ final class ShouldPersistSavedOrderTests: XCTestCase {
2626
isResettingLayout: false,
2727
isInStartupSettling: false,
2828
isApplyingProfileLayout: false,
29-
temporarilyShownItemContextsIsEmpty: true
29+
temporarilyShownItemContextsIsEmpty: true,
30+
hasRecentSectionDividerTransition: false
3031
))
3132
}
3233

@@ -39,7 +40,8 @@ final class ShouldPersistSavedOrderTests: XCTestCase {
3940
isResettingLayout: false,
4041
isInStartupSettling: false,
4142
isApplyingProfileLayout: false,
42-
temporarilyShownItemContextsIsEmpty: true
43+
temporarilyShownItemContextsIsEmpty: true,
44+
hasRecentSectionDividerTransition: false
4345
))
4446
}
4547

@@ -51,7 +53,8 @@ final class ShouldPersistSavedOrderTests: XCTestCase {
5153
isResettingLayout: true,
5254
isInStartupSettling: false,
5355
isApplyingProfileLayout: false,
54-
temporarilyShownItemContextsIsEmpty: true
56+
temporarilyShownItemContextsIsEmpty: true,
57+
hasRecentSectionDividerTransition: false
5558
))
5659
}
5760

@@ -64,7 +67,8 @@ final class ShouldPersistSavedOrderTests: XCTestCase {
6467
isResettingLayout: false,
6568
isInStartupSettling: true,
6669
isApplyingProfileLayout: false,
67-
temporarilyShownItemContextsIsEmpty: true
70+
temporarilyShownItemContextsIsEmpty: true,
71+
hasRecentSectionDividerTransition: false
6872
))
6973
}
7074

@@ -78,7 +82,8 @@ final class ShouldPersistSavedOrderTests: XCTestCase {
7882
isResettingLayout: false,
7983
isInStartupSettling: false,
8084
isApplyingProfileLayout: true,
81-
temporarilyShownItemContextsIsEmpty: true
85+
temporarilyShownItemContextsIsEmpty: true,
86+
hasRecentSectionDividerTransition: false
8287
))
8388
}
8489

@@ -94,7 +99,22 @@ final class ShouldPersistSavedOrderTests: XCTestCase {
9499
isResettingLayout: false,
95100
isInStartupSettling: false,
96101
isApplyingProfileLayout: false,
97-
temporarilyShownItemContextsIsEmpty: false
102+
temporarilyShownItemContextsIsEmpty: false,
103+
hasRecentSectionDividerTransition: false
104+
))
105+
}
106+
107+
/// Divider show/hide transitions briefly move the hidden and
108+
/// always-hidden boundaries through item space. A cache snapshot in
109+
/// that window is transitional UI state, not user intent.
110+
func testRecentSectionDividerTransitionBlocks() {
111+
XCTAssertFalse(LayoutSolver.shouldPersistSavedOrder(
112+
isRestoringItemOrder: false,
113+
isResettingLayout: false,
114+
isInStartupSettling: false,
115+
isApplyingProfileLayout: false,
116+
temporarilyShownItemContextsIsEmpty: true,
117+
hasRecentSectionDividerTransition: true
98118
))
99119
}
100120

@@ -107,14 +127,24 @@ final class ShouldPersistSavedOrderTests: XCTestCase {
107127
isResettingLayout: true,
108128
isInStartupSettling: false,
109129
isApplyingProfileLayout: false,
110-
temporarilyShownItemContextsIsEmpty: true
130+
temporarilyShownItemContextsIsEmpty: true,
131+
hasRecentSectionDividerTransition: false
111132
))
112133
XCTAssertFalse(LayoutSolver.shouldPersistSavedOrder(
113134
isRestoringItemOrder: false,
114135
isResettingLayout: false,
115136
isInStartupSettling: true,
116137
isApplyingProfileLayout: true,
117-
temporarilyShownItemContextsIsEmpty: true
138+
temporarilyShownItemContextsIsEmpty: true,
139+
hasRecentSectionDividerTransition: false
140+
))
141+
XCTAssertFalse(LayoutSolver.shouldPersistSavedOrder(
142+
isRestoringItemOrder: false,
143+
isResettingLayout: false,
144+
isInStartupSettling: false,
145+
isApplyingProfileLayout: false,
146+
temporarilyShownItemContextsIsEmpty: false,
147+
hasRecentSectionDividerTransition: true
118148
))
119149
}
120150
}

0 commit comments

Comments
 (0)