Skip to content
Closed
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
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
24 changes: 24 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,9 @@ final class GeneralSettings: ObservableObject {
Defaults.ifPresent(key: .showOnDoubleClick, assign: &showOnDoubleClick)
Defaults.ifPresent(key: .showOnHover, assign: &showOnHover)
Defaults.ifPresent(key: .showOnScroll, assign: &showOnScroll)
if let rawSpacing = Defaults.object(forKey: .itemSpacingOffset) as? NSNumber {
itemSpacingOffset = Self.clampItemSpacingOffset(rawSpacing.doubleValue)
}
Defaults.ifPresent(key: .autoRehide, assign: &autoRehide)
Defaults.ifPresent(key: .rehideInterval, assign: &rehideInterval)

Expand Down Expand Up @@ -174,6 +178,22 @@ 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 = Self.clampItemSpacingOffset(offset)
if clamped != offset {
self?.diagLog.warning("itemSpacingOffset \(offset) clamped to \(clamped)")
self?.itemSpacingOffset = 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 All @@ -190,6 +210,10 @@ final class GeneralSettings: ObservableObject {
cancellables = c
}

private static func clampItemSpacingOffset(_ value: Double) -> Double {
max(-16, min(16, value))
}

/// Handles settings changed externally via Settings URI scheme.
private func handleExternalSettingsChange(_ notification: Notification) {
guard let key = notification.userInfo?["key"] as? String else {
Expand Down
Loading