Skip to content
Merged
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
28 changes: 17 additions & 11 deletions Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,21 @@ final class MenuBarItemImageCache: @unchecked Sendable {
/// the on-screen path the same way the offscreen one already is.
private var lastSCKRefreshAt: ContinuousClock.Instant?

/// Minimum spacing enforced between visible-section SCK captures.
/// Maximum icon refresh rate the UI may offer, in frames per second.
///
/// The tick rate comes from the user's `iconRefreshInterval` (the "Icon
/// refresh rate" slider, up to 30 fps), and the consumers that ask for
/// every section — item search, the layout pane, the hotkey list — turned
/// that into up to 30 composite captures per second for as long as their
/// panel stayed open, which is enough to pin a core. Icon animation does
/// not need more than this; the slider still controls everything below the
/// floor.
private static let minSCKRefreshInterval: Duration = .milliseconds(250)
/// The slider ceiling and the SCK capture floor are the same number so
/// they cannot drift apart: the UI never promises a rate the engine will
/// not deliver. 30 matches the historical slider top. Higher rates pin a
/// core while Search / Layout / Thaw Bar stay open (composite SCK +
/// per-item crop); leave the SkyLight offscreen floor at 1 s separately.
nonisolated static let maxIconRefreshRate: Double = 30

/// Minimum spacing enforced between visible-section SCK captures, in seconds.
/// Reciprocal of ``maxIconRefreshRate``.
nonisolated static let minIconRefreshInterval: TimeInterval = 1.0 / maxIconRefreshRate

/// Minimum spacing enforced between visible-section SCK captures.
private static let minSCKRefreshInterval: Duration = .seconds(minIconRefreshInterval)

/// Tracks whether the MenuBarLayoutSettingsPane is currently open.
/// Used to gate background cache prewarming so captures only occur while the
Expand Down Expand Up @@ -766,8 +771,9 @@ final class MenuBarItemImageCache: @unchecked Sendable {
try? await Task.sleep(for: .seconds(1))
continue
}
let ms = Int(interval * 1000)
try? await Task.sleep(for: .milliseconds(ms))
// Floor the sleep so a sub-millisecond stored interval cannot
// truncate to a zero-length sleep and spin the main actor.
try? await Task.sleep(for: .seconds(max(interval, Self.minIconRefreshInterval)))
guard !Task.isCancelled else { break }

let nav = appState.navigationState
Expand Down
25 changes: 24 additions & 1 deletion Thaw/Settings/Models/AdvancedSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,36 @@ final class AdvancedSettings {
}

/// The interval between icon image refreshes in panels (Thaw Bar, search, layout).
///
/// Always held on the discrete grid the "Icon refresh rate" slider can
/// express: `0` (Off) or `1/n` for `n` in `1...maxIconRefreshRate`. Writes
/// from the slider, URI, profiles, and Defaults load are all snapped here
/// so the UI and the live-refresh loop never disagree.
var iconRefreshInterval = Defaults.DefaultValue.iconRefreshInterval {
didSet {
guard oldValue != iconRefreshInterval else { return }
let normalized = Self.normalizedIconRefreshInterval(iconRefreshInterval)
let didNormalize = iconRefreshInterval != normalized
if didNormalize {
iconRefreshInterval = normalized
}
guard didNormalize || oldValue != iconRefreshInterval else { return }
Defaults.set(iconRefreshInterval, forKey: .iconRefreshInterval)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/// Snaps an icon-refresh interval onto the values the slider can express.
///
/// - `<= 0` stays Off (`0`).
/// - Otherwise snaps to `1 / clamp(round(1 / interval), 1, ceiling)`,
/// where the ceiling is ``MenuBarItemImageCache/maxIconRefreshRate``.
/// - Idempotent: already-on-grid values round-trip unchanged.
nonisolated static func normalizedIconRefreshInterval(_ interval: TimeInterval) -> TimeInterval {
guard interval > 0 else { return 0 }
let ceiling = MenuBarItemImageCache.maxIconRefreshRate
let fps = min(max((1.0 / interval).rounded(), 1), ceiling)
return 1.0 / fps
}

/// A Boolean value that indicates whether diagnostic logging to file is enabled.
var enableDiagnosticLogging = Defaults.DefaultValue.enableDiagnosticLogging {
didSet {
Expand Down
5 changes: 3 additions & 2 deletions Thaw/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,17 +158,18 @@ struct MenuBarLayoutSettingsPane: View {
}

private var iconRefreshInterval: some View {
let maxFPS = MenuBarItemImageCache.maxIconRefreshRate
let fpsBinding = Binding<Double>(
get: {
let interval = advancedSettings.iconRefreshInterval
return interval > 0 ? (1.0 / interval).rounded() : 0
return interval > 0 ? 1.0 / interval : 0
},
set: { advancedSettings.iconRefreshInterval = $0 > 0 ? 1.0 / $0 : 0 }
)
return LabeledContent {
IceSlider(
value: fpsBinding,
in: 0 ... 30,
in: 0 ... maxFPS,
step: 1
) {
Text(fpsBinding.wrappedValue > 0
Expand Down
21 changes: 14 additions & 7 deletions Thaw/Utilities/SettingsURIHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ enum SettingsURIHandler {
"rehideInterval": (1, 300),
"showOnHoverDelay": (0, 5),
"tooltipDelay": (0, 5),
"iconRefreshInterval": (0, 5),
"iconRefreshInterval": (0, 1),
]

// MARK: - Security
Expand Down Expand Up @@ -464,10 +464,17 @@ enum SettingsURIHandler {

// Validate and clamp to range
let (minVal, maxVal) = doubleRanges[key] ?? (0, Double.greatestFiniteMagnitude)
let clampedValue = Swift.max(minVal, Swift.min(doubleValue, maxVal))
var valueToStore = Swift.max(minVal, Swift.min(doubleValue, maxVal))

if clampedValue != doubleValue {
diagLog.debug("Settings URI: Clamped \(key) from \(doubleValue) to \(clampedValue) (range: \(minVal)-\(maxVal))")
if valueToStore != doubleValue {
diagLog.debug("Settings URI: Clamped \(key) from \(doubleValue) to \(valueToStore) (range: \(minVal)-\(maxVal))")
}

// Icon refresh intervals are a discrete fps grid. Snap before writing
// Defaults so persistence does not depend on an AdvancedSettings
// observer being alive to run didSet normalization.
if key == "iconRefreshInterval" {
valueToStore = AdvancedSettings.normalizedIconRefreshInterval(valueToStore)
}

// Get the Defaults.Key
Expand All @@ -477,12 +484,12 @@ enum SettingsURIHandler {
}

// Apply the setting
Defaults.set(clampedValue, forKey: defaultsKey)
Defaults.set(valueToStore, forKey: defaultsKey)

// Notify settings models that a value changed externally
postSettingsDidChangeNotification(key: key, doubleValue: clampedValue)
postSettingsDidChangeNotification(key: key, doubleValue: valueToStore)

diagLog.info("Settings URI: Set \(key) = \(clampedValue)")
diagLog.info("Settings URI: Set \(key) = \(valueToStore)")

return true
}
Expand Down
5 changes: 3 additions & 2 deletions ThawTests/Settings/Models/AdvancedSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ struct AdvancedSettingsTests {
#expect(settings.enableSecondaryContextMenuQuit)
#expect(settings.showOnHoverDelay == 1.5)
#expect(settings.tooltipDelay == 2.5)
#expect(settings.iconRefreshInterval == 3.5)
#expect(settings.iconRefreshInterval == 1.0)
#expect(Defaults.double(forKey: .iconRefreshInterval) == 1.0)
#expect(settings.showMenuBarTooltips)
#expect(!settings.useLCSSortingOnNotchedDisplays)
#expect(!settings.enableMenuBarItemOverflow)
Expand Down Expand Up @@ -311,7 +312,7 @@ struct AdvancedSettingsTests {
// synchronously by `propertyChangesArePersisted`.
#expect(settings.showOnHoverDelay == 1.75)
#expect(settings.tooltipDelay == 2.75)
#expect(settings.iconRefreshInterval == 3.75)
#expect(settings.iconRefreshInterval == 1.0)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// IconRefreshIntervalNormalizationTests.swift
// Project: Thaw
//
// Copyright (Thaw) © 2026 Toni Förster
// Licensed under the GNU GPLv3

import Foundation
import Testing
@testable import Thaw

/// Pins ``AdvancedSettings.normalizedIconRefreshInterval`` to the discrete
/// grid the "Icon refresh rate" slider can express: Off, or 1…30 fps.
///
/// Pure function; safe to run in parallel with the rest of the suite.
@Suite("Icon refresh interval normalization")
struct IconRefreshIntervalNormalizationTests {
@Test("Zero stays Off")
func zeroStaysOff() {
#expect(AdvancedSettings.normalizedIconRefreshInterval(0) == 0)
#expect(AdvancedSettings.normalizedIconRefreshInterval(-1) == 0)
}

@Test("A rate above the capture ceiling snaps down to it")
func ceilingClamp() {
let ceiling = MenuBarItemImageCache.maxIconRefreshRate
let floor = MenuBarItemImageCache.minIconRefreshInterval
#expect(AdvancedSettings.normalizedIconRefreshInterval(1.0 / 30.0) == floor)
#expect(AdvancedSettings.normalizedIconRefreshInterval(1.0 / 60.0) == floor)
#expect(AdvancedSettings.normalizedIconRefreshInterval(floor / 2) == floor)
#expect(AdvancedSettings.normalizedIconRefreshInterval(1.0 / ceiling) == floor)
}

@Test("Slow intervals snap to 1 fps rather than displaying as Off")
func slowValuesSnapToOneFPS() {
#expect(AdvancedSettings.normalizedIconRefreshInterval(3.0) == 1.0)
#expect(AdvancedSettings.normalizedIconRefreshInterval(5.0) == 1.0)
#expect(AdvancedSettings.normalizedIconRefreshInterval(2.5) == 1.0)
#expect(AdvancedSettings.normalizedIconRefreshInterval(3.5) == 1.0)
}

@Test("A sub-millisecond interval snaps to the floor instead of a zero sleep")
func subMillisecondSnapsToFloor() {
let floor = MenuBarItemImageCache.minIconRefreshInterval
#expect(AdvancedSettings.normalizedIconRefreshInterval(0.0001) == floor)
#expect(AdvancedSettings.normalizedIconRefreshInterval(0.0005) == floor)
}

@Test("Already-on-grid values are idempotent")
func idempotent() {
#expect(AdvancedSettings.normalizedIconRefreshInterval(0) == 0)
let ceiling = Int(MenuBarItemImageCache.maxIconRefreshRate)
for fps in 1 ... ceiling {
let interval = 1.0 / Double(fps)
let once = AdvancedSettings.normalizedIconRefreshInterval(interval)
let twice = AdvancedSettings.normalizedIconRefreshInterval(once)
#expect(once == twice)
#expect(abs(1.0 / once - Double(fps)) < 1e-9)
}
}

@Test("Every on-grid interval survives an interval → fps → interval round trip")
func roundTrip() {
let ceiling = Int(MenuBarItemImageCache.maxIconRefreshRate)
for fps in 0 ... ceiling {
let interval: TimeInterval = fps == 0 ? 0 : 1.0 / Double(fps)
let normalized = AdvancedSettings.normalizedIconRefreshInterval(interval)
let displayedFPS = normalized > 0 ? (1.0 / normalized).rounded() : 0
let restored: TimeInterval = displayedFPS > 0 ? 1.0 / displayedFPS : 0
#expect(AdvancedSettings.normalizedIconRefreshInterval(restored) == normalized)
#expect(Int(displayedFPS) == fps)
}
}
}
5 changes: 3 additions & 2 deletions ThawTests/Settings/Models/ProfileDecodingDefaultsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ struct ProfileSnapshotLiveSettingsTests {
#expect(snapshot.showOnHoverDelay == 0.75)
#expect(snapshot.tooltipDelay == 1.25)
#expect(snapshot.showMenuBarTooltips)
#expect(snapshot.iconRefreshInterval == 2.5)
#expect(snapshot.iconRefreshInterval == 1.0)
#expect(snapshot.useDoubleClickToShowAlwaysHiddenSection)
#expect(snapshot.useOptionClickToShowAlwaysHiddenSection)
#expect(snapshot.useLCSSortingOnNotchedDisplays == false)
Expand Down Expand Up @@ -327,7 +327,8 @@ struct ProfileSnapshotLiveSettingsTests {
#expect(settings.showOnHoverDelay == 0.75)
#expect(settings.tooltipDelay == 1.25)
#expect(settings.showMenuBarTooltips)
#expect(settings.iconRefreshInterval == 2.5)
#expect(settings.iconRefreshInterval == 1.0)
#expect(Defaults.double(forKey: .iconRefreshInterval) == 1.0)
#expect(settings.useDoubleClickToShowAlwaysHiddenSection)
#expect(settings.useOptionClickToShowAlwaysHiddenSection)
#expect(settings.useLCSSortingOnNotchedDisplays == false)
Expand Down
25 changes: 24 additions & 1 deletion ThawTests/Settings/URI/SettingsURIHandlerApplyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,30 @@ struct SettingsURIHandlerApplyTests {
#expect(Defaults.double(forKey: .tooltipDelay) == 0)

#expect(SettingsURIHandler.handleSet(key: "iconRefreshInterval", value: "2.5", sender: "test"))
#expect(Defaults.double(forKey: .iconRefreshInterval) == 2.5)
#expect(Defaults.double(forKey: .iconRefreshInterval) == 1.0)
}
}

@Test("iconRefreshInterval snaps onto the discrete fps grid before Defaults write")
func iconRefreshIntervalSnapsOntoFpsGrid() throws {
try withScratchDefaults { _ in
// 0.6 s → ~1.67 fps → nearest 2 fps → 0.5 s
#expect(SettingsURIHandler.handleSet(key: "iconRefreshInterval", value: "0.6", sender: "test"))
#expect(Defaults.double(forKey: .iconRefreshInterval) == 0.5)

// Above the 30 fps ceiling → floor interval
#expect(SettingsURIHandler.handleSet(key: "iconRefreshInterval", value: "0.01", sender: "test"))
#expect(
Defaults.double(forKey: .iconRefreshInterval)
== MenuBarItemImageCache.minIconRefreshInterval
)

// On-grid 10 fps survives
#expect(SettingsURIHandler.handleSet(key: "iconRefreshInterval", value: "0.1", sender: "test"))
#expect(Defaults.double(forKey: .iconRefreshInterval) == 0.1)

#expect(SettingsURIHandler.handleSet(key: "iconRefreshInterval", value: "0", sender: "test"))
#expect(Defaults.double(forKey: .iconRefreshInterval) == 0)
}
}

Expand Down
4 changes: 2 additions & 2 deletions docs/URI_SCHEMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ Thaw supports programmatic settings manipulation via the `thaw://` URL scheme wi
| `rehideInterval` | Double | 1-300 seconds | Time before auto-rehide (default: 15) |
| `showOnHoverDelay` | Double | 0-5 seconds | Delay before hover reveals items (default: 0.2) |
| `tooltipDelay` | Double | 0-5 seconds | Delay before showing tooltips (default: 0.5) |
| `iconRefreshInterval` | Double | 0.1-5 seconds | Interval between icon refreshes (default: 0.1) |
| `iconRefreshInterval` | Double | 0-1 seconds | Interval between icon refreshes in panels; `0` means Off; positive values snap to `1/n` seconds for integer `n` in 1–30 (default: 0.25 ≈ 4 fps) |

**Note:** Values outside the valid range are automatically clamped to the nearest boundary.
**Note:** Values outside the valid range are automatically clamped to the nearest boundary. `iconRefreshInterval` is additionally snapped onto the discrete fps grid above before it is stored.

#### Enum Settings

Expand Down