Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion Thaw/MenuBar/IceBar/IceBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ private struct IceBarContentView: View {
}

private var itemSpacing: CGFloat {
let offset = displaySettings.configuration(for: screen.displayID).itemSpacingOffset
let offset = appState.settings.general.itemSpacingOffset
return max(0, CGFloat(offset).rounded())
}

Expand Down
6 changes: 3 additions & 3 deletions Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -924,9 +924,9 @@ final class MenuBarItemManager: ObservableObject {

// Boot race: a cold (performSetup) or expected-set settling must
// not be torn down by a transient preflight that the boot path
// also kicks off (DisplaySettingsManager.applyActiveDisplaySpacing,
// ProfileManager.layoutTask). Preserve the merged expected set so
// a later non-preflight call still has it; otherwise return.
// also kicks off (ProfileManager.layoutTask). Preserve the merged
// expected set so a later non-preflight call still has it; otherwise
// return.
if let existing = settlingKind,
incomingKind == .preflight,
existing == .cold || existing == .expectedSet
Expand Down
6 changes: 3 additions & 3 deletions Thaw/MenuBar/Spacing/MenuBarItemSpacingManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ final class MenuBarItemSpacingManager {
/// immediately, even though the first call is still mid-relaunch
/// wave. With the semaphore the second caller queues behind the
/// first; by the time it runs, the first call has completed and
/// applyActiveDisplaySpacing has already started a settling
/// period, so a subsequent applyProfileLayout correctly waits
/// for items to stabilize before moving them.
/// the spacing caller has already started a settling period, so a
/// subsequent applyProfileLayout correctly waits for items to
/// stabilize before moving them.
private let applyOffsetSemaphore = SimpleSemaphore(value: 1)

/// Runs a command with the given arguments.
Expand Down
45 changes: 7 additions & 38 deletions Thaw/Settings/Models/DisplayIceBarConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,13 @@ struct DisplayIceBarConfiguration: Codable, Equatable {
/// Valid range is 2 through 10.
let gridColumns: Int

/// The menu bar item spacing offset to apply when this display is the
/// active menu bar display. Range is -16 to +16. The OS reads
/// NSStatusItemSpacing as a single system-wide value, so this is the
/// value that gets written + relaunched whenever this display becomes
/// (or remains) the active menu bar display.
let itemSpacingOffset: Double

/// Default configuration (disabled, dynamic location, horizontal layout).
static let defaultConfiguration = DisplayIceBarConfiguration(
useIceBar: false,
iceBarLocation: .dynamic,
alwaysShowHiddenItems: false,
iceBarLayout: .horizontal,
gridColumns: 4,
itemSpacingOffset: 0
gridColumns: 4
)

/// Returns a new configuration with the `useIceBar` flag replaced.
Expand All @@ -53,8 +45,7 @@ struct DisplayIceBarConfiguration: Codable, Equatable {
iceBarLocation: iceBarLocation,
alwaysShowHiddenItems: alwaysShowHiddenItems,
iceBarLayout: iceBarLayout,
gridColumns: gridColumns,
itemSpacingOffset: itemSpacingOffset
gridColumns: gridColumns
)
}

Expand All @@ -65,8 +56,7 @@ struct DisplayIceBarConfiguration: Codable, Equatable {
iceBarLocation: value,
alwaysShowHiddenItems: alwaysShowHiddenItems,
iceBarLayout: iceBarLayout,
gridColumns: gridColumns,
itemSpacingOffset: itemSpacingOffset
gridColumns: gridColumns
)
}

Expand All @@ -77,8 +67,7 @@ struct DisplayIceBarConfiguration: Codable, Equatable {
iceBarLocation: iceBarLocation,
alwaysShowHiddenItems: value,
iceBarLayout: iceBarLayout,
gridColumns: gridColumns,
itemSpacingOffset: itemSpacingOffset
gridColumns: gridColumns
)
}

Expand All @@ -89,8 +78,7 @@ struct DisplayIceBarConfiguration: Codable, Equatable {
iceBarLocation: iceBarLocation,
alwaysShowHiddenItems: alwaysShowHiddenItems,
iceBarLayout: value,
gridColumns: gridColumns,
itemSpacingOffset: itemSpacingOffset
gridColumns: gridColumns
)
}

Expand All @@ -103,22 +91,7 @@ struct DisplayIceBarConfiguration: Codable, Equatable {
iceBarLocation: iceBarLocation,
alwaysShowHiddenItems: alwaysShowHiddenItems,
iceBarLayout: iceBarLayout,
gridColumns: Swift.max(2, Swift.min(value, 10)),
itemSpacingOffset: itemSpacingOffset
)
}

/// Returns a new configuration with the itemSpacingOffset replaced.
///
/// Values are clamped to the range -16 through 16.
func withItemSpacingOffset(_ value: Double) -> DisplayIceBarConfiguration {
DisplayIceBarConfiguration(
useIceBar: useIceBar,
iceBarLocation: iceBarLocation,
alwaysShowHiddenItems: alwaysShowHiddenItems,
iceBarLayout: iceBarLayout,
gridColumns: gridColumns,
itemSpacingOffset: Swift.max(-16, Swift.min(value, 16))
gridColumns: Swift.max(2, Swift.min(value, 10))
)
}

Expand All @@ -139,8 +112,7 @@ struct DisplayIceBarConfiguration: Codable, Equatable {
iceBarLocation: location,
alwaysShowHiddenItems: false,
iceBarLayout: .horizontal,
gridColumns: 4,
itemSpacingOffset: 0
gridColumns: 4
)
}
return configs
Expand All @@ -156,7 +128,6 @@ extension DisplayIceBarConfiguration {
case alwaysShowHiddenItems
case iceBarLayout
case gridColumns
case itemSpacingOffset
}

init(from decoder: Decoder) throws {
Expand All @@ -167,7 +138,5 @@ extension DisplayIceBarConfiguration {
self.iceBarLayout = try container.decodeIfPresent(IceBarLayout.self, forKey: .iceBarLayout) ?? .horizontal
let decodedGridColumns = try container.decodeIfPresent(Int.self, forKey: .gridColumns) ?? 4
self.gridColumns = Swift.max(2, Swift.min(decodedGridColumns, 10))
let decodedSpacing = try container.decodeIfPresent(Double.self, forKey: .itemSpacingOffset) ?? 0
self.itemSpacingOffset = Swift.max(-16, Swift.min(decodedSpacing, 16))
}
}
82 changes: 5 additions & 77 deletions Thaw/Settings/Models/DisplaySettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,8 @@ final class DisplaySettingsManager: ObservableObject {
/// JSON decoder for persistence.
private let decoder = JSONDecoder()

/// Reference to AppState for driving spacingManager and itemManager from
/// active-display configuration changes. Held weakly to avoid retain cycles.
private weak var appState: AppState?

/// Performs the initial setup of the manager.
func performSetup(with appState: AppState) {
self.appState = appState
func performSetup(with _: AppState) {
loadInitialState()
configureCancellables()
captureCurrentlyConnectedDisplays()
Expand Down Expand Up @@ -143,39 +138,17 @@ final class DisplaySettingsManager: ObservableObject {
}
.store(in: &c)

// Listen for display connect/disconnect to log changes, refresh the
// known-display cache, and re-derive the active display's spacing.
//
// Debounced because didChangeScreenParametersNotification fires
// repeatedly during a single user action: docking, lid close,
// monitor sleep/wake, KVM switch, Sidecar handshake, and external
// display flicker can each post several notifications within a
// few hundred milliseconds. Without the debounce, every flap
// could trigger a relaunch wave (the no-op guard catches the
// common case but does not cover oscillating values during the
// flap window). One second coalesces a single docking event into
// one apply.
// Listen for display connect/disconnect to refresh the known-display
// cache. Debounced because didChangeScreenParametersNotification fires
// repeatedly during docking, lid close, monitor sleep/wake, KVM
// switches, Sidecar handshakes, and external display flicker.
NotificationCenter.default
.publisher(for: NSApplication.didChangeScreenParametersNotification)
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
diagLog.info("Screen parameters changed — \(NSScreen.screens.count) screen(s) connected")
captureCurrentlyConnectedDisplays()
applyActiveDisplaySpacing(reason: "screenParametersChanged")
}
.store(in: &c)

// Whenever per-display configurations change (user edit, profile
// load), re-derive what the active display's spacing should be and
// apply it. The no-op guard inside applyOffset() makes this free
// when on-disk already matches.
$configurations
.dropFirst()
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.applyActiveDisplaySpacing(reason: "configurationsChanged")
}
.store(in: &c)

Expand All @@ -191,51 +164,6 @@ final class DisplaySettingsManager: ObservableObject {
cancellables = c
}

/// Reads the active display's spacing offset, syncs it into
/// spacingManager.offset, and triggers applyOffset. The no-op guard
/// inside applyOffset skips when on-disk values already match, so this
/// is safe to call on every configurations change. On a real relaunch
/// wave, kicks off a settling period so a subsequent applyProfileLayout
/// (e.g. from a profile switch) waits for items to re-attach before
/// moving them.
private func applyActiveDisplaySpacing(reason: String) {
guard let appState else { return }
let desired = Int(configurationForActiveDisplay().itemSpacingOffset.rounded())
appState.spacingManager.offset = desired
Task { [weak self] in
guard let self else { return }
// Preflight settling so intermediate late-arriver re-sorts and
// restore logic are suppressed while the wave runs. Cancelled
// below if applyOffset turns out to be a no-op.
appState.itemManager.startSettlingPeriod(reason: "spacingRelaunch:\(reason):preflight")
do {
let outcome = try await appState.spacingManager.applyOffset()
if outcome.didRelaunch {
appState.itemManager.startSettlingPeriod(
reason: "spacingRelaunch:\(reason)",
expectedBundleIDs: outcome.recoveredBundleIDs
)
// The relaunched apps reattach at OS-default positions.
// Drive the active profile's layout pass so they end up
// in the saved order. Auto-switch doesn't fire when the
// associated profile is unchanged, so without this call
// the post-settle path would only run cross-section
// restore and leave within-section ordering untouched.
appState.profileManager.reapplyActiveProfile()
} else {
appState.itemManager.cancelSettlingPeriod(
reason: "spacingRelaunch:\(reason):noOp"
)
}
} catch {
appState.itemManager.cancelSettlingPeriod(
reason: "spacingRelaunch:\(reason):error"
)
diagLog.error("applyActiveDisplaySpacing(\(reason)) failed: \(error)")
}
}
}

/// Handles per-display settings changed externally via Settings URI scheme.
private func handleExternalPerDisplaySettingsChange(_ notification: Notification) {
guard let key = notification.userInfo?["key"] as? String,
Expand Down
17 changes: 17 additions & 0 deletions Thaw/Settings/Models/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ final class GeneralSettings: ObservableObject {
@Published var showOnScroll = Defaults.DefaultValue.showOnScroll

// The offset to apply to the menu bar item spacing and padding.
@Published var itemSpacingOffset = Defaults.DefaultValue.itemSpacingOffset

/// A Boolean value that indicates whether the hidden section
/// should automatically rehide.
Expand Down Expand Up @@ -113,6 +114,7 @@ final class GeneralSettings: ObservableObject {
Defaults.ifPresent(key: .showOnDoubleClick, assign: &showOnDoubleClick)
Defaults.ifPresent(key: .showOnHover, assign: &showOnHover)
Defaults.ifPresent(key: .showOnScroll, assign: &showOnScroll)
Defaults.ifPresent(key: .itemSpacingOffset, assign: &itemSpacingOffset)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Defaults.ifPresent(key: .autoRehide, assign: &autoRehide)
Defaults.ifPresent(key: .rehideInterval, assign: &rehideInterval)

Expand Down Expand Up @@ -174,6 +176,21 @@ final class GeneralSettings: ObservableObject {
$showOnDoubleClick.persistToDefaults(key: .showOnDoubleClick, in: &c)
$showOnHover.persistToDefaults(key: .showOnHover, in: &c)
$showOnScroll.persistToDefaults(key: .showOnScroll, in: &c)

// itemSpacingOffset has side effect on appState - keep manual
$itemSpacingOffset
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak appState, weak self] offset in
let clamped = max(-16, min(16, offset))
if clamped != offset {
self?.diagLog.warning("itemSpacingOffset \(offset) clamped to \(clamped)")
}
Defaults.set(clamped, forKey: .itemSpacingOffset)
appState?.spacingManager.offset = Int(clamped.rounded())
}
.store(in: &c)

$autoRehide.persistToDefaults(key: .autoRehide, in: &c)
$rehideStrategy.persistToDefaults(key: .rehideStrategy, transform: \.rawValue, in: &c)
$rehideInterval.persistToDefaults(key: .rehideInterval, in: &c)
Expand Down
Loading