diff --git a/mac/Sources/TokenomicsMenubar/MinuteClock.swift b/mac/Sources/TokenomicsMenubar/MinuteClock.swift new file mode 100644 index 0000000..6352bfc --- /dev/null +++ b/mac/Sources/TokenomicsMenubar/MinuteClock.swift @@ -0,0 +1,25 @@ +import Combine +import Foundation + +/// A single refresh source shared by the status item and its popover. +/// `TimelineView` inside a `MenuBarExtra` label causes continuous status-item rendering on macOS. +@MainActor +public final class MinuteClock: ObservableObject { + @Published public private(set) var now: Date + + private var cancellable: AnyCancellable? + + public convenience init(now: Date = .now) { + let updates = Timer.publish(every: 60, tolerance: 1, on: .main, in: .common) + .autoconnect() + .eraseToAnyPublisher() + self.init(now: now, updates: updates) + } + + init(now: Date, updates: AnyPublisher) { + self.now = now + cancellable = updates.sink { [weak self] date in + self?.now = date + } + } +} diff --git a/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift b/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift index 916806e..e37b5ee 100644 --- a/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift +++ b/mac/Sources/TokenomicsMenubar/TokenomicsMenubarApp.swift @@ -12,11 +12,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { struct TokenomicsMenubar: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var coordinator: ConnectionCoordinator + @StateObject private var clock: MinuteClock private let settingsWindowController: SettingsWindowController init() { let prefs = PreferencesStore() _coordinator = StateObject(wrappedValue: ConnectionCoordinator(preferences: prefs)) + _clock = StateObject(wrappedValue: MinuteClock()) settingsWindowController = SettingsWindowController(preferences: prefs) } @@ -24,10 +26,11 @@ struct TokenomicsMenubar: App { MenuBarExtra { PopoverView( coordinator: coordinator, + clock: clock, settingsWindowController: settingsWindowController ) } label: { - MenuBarLabelView(coordinator: coordinator) + MenuBarLabelView(coordinator: coordinator, clock: clock) } .menuBarExtraStyle(.window) } diff --git a/mac/Sources/TokenomicsMenubar/Views.swift b/mac/Sources/TokenomicsMenubar/Views.swift index 19b9d92..27f2088 100644 --- a/mac/Sources/TokenomicsMenubar/Views.swift +++ b/mac/Sources/TokenomicsMenubar/Views.swift @@ -146,27 +146,27 @@ enum MenuBarPresentation { public struct MenuBarLabelView: View { @ObservedObject var coordinator: ConnectionCoordinator + @ObservedObject private var clock: MinuteClock @ObservedObject private var preferences: PreferencesStore - public init(coordinator: ConnectionCoordinator) { + public init(coordinator: ConnectionCoordinator, clock: MinuteClock) { self.coordinator = coordinator + self.clock = clock _preferences = ObservedObject(wrappedValue: coordinator.preferences) } public var body: some View { - TimelineView(.periodic(from: .now, by: 60)) { context in - HStack(spacing: 4) { - if let text = labelText(now: context.date) { - if coordinator.state.isUsable == false { Image(systemName: iconName).accessibilityHidden(true) } - Text(text) - } else { - Image(systemName: iconName).accessibilityHidden(true) - Text(stateLabel) - } + HStack(spacing: 4) { + if let text = labelText(now: clock.now) { + if coordinator.state.isUsable == false { Image(systemName: iconName).accessibilityHidden(true) } + Text(text) + } else { + Image(systemName: iconName).accessibilityHidden(true) + Text(stateLabel) } - .accessibilityLabel(accessibilityText(now: context.date)) - .help(accessibilityText(now: context.date)) } + .accessibilityLabel(accessibilityText(now: clock.now)) + .help(accessibilityText(now: clock.now)) .font(.system(size: 12, weight: .medium, design: .rounded)) .monospacedDigit() .foregroundStyle(labelColor) @@ -222,14 +222,17 @@ public struct MenuBarLabelView: View { public struct PopoverView: View { @ObservedObject var coordinator: ConnectionCoordinator + @ObservedObject private var clock: MinuteClock private let settingsWindowController: SettingsWindowController @State private var showDetails = false public init( coordinator: ConnectionCoordinator, + clock: MinuteClock, settingsWindowController: SettingsWindowController ) { self.coordinator = coordinator + self.clock = clock self.settingsWindowController = settingsWindowController } @@ -319,15 +322,13 @@ public struct PopoverView: View { private var quotaSection: some View { let windows = QuotaPresentation.orderedWindows(payload?.subscriptionWindows ?? []) if !windows.isEmpty { - TimelineView(.periodic(from: .now, by: 60)) { context in - if windows.count > 4 { - ScrollView { - QuotaCockpitView(windows: windows, now: context.date) - } - .frame(maxHeight: 180) - } else { - QuotaCockpitView(windows: windows, now: context.date) + if windows.count > 4 { + ScrollView { + QuotaCockpitView(windows: windows, now: clock.now) } + .frame(maxHeight: 180) + } else { + QuotaCockpitView(windows: windows, now: clock.now) } Divider() } diff --git a/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift b/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift index e9101e1..0135d89 100644 --- a/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift +++ b/mac/Tests/TokenomicsMenubarTests/TokenomicsMenubarTests.swift @@ -1,10 +1,30 @@ import AppKit +import Combine import Darwin import Foundation import SwiftUI import XCTest @testable import TokenomicsMenubar +@MainActor +final class MinuteClockTests: XCTestCase { + func testClockPublishesInjectedMinuteTicks() { + let start = Date(timeIntervalSince1970: 1_000) + let tick = Date(timeIntervalSince1970: 1_060) + let updates = PassthroughSubject() + let clock = MinuteClock(now: start, updates: updates.eraseToAnyPublisher()) + + var observed: [Date] = [] + let observation = clock.$now.sink { observed.append($0) } + defer { observation.cancel() } + + updates.send(tick) + + XCTAssertEqual(clock.now, tick) + XCTAssertEqual(observed, [start, tick]) + } +} + final class BudgetUsageLevelTests: XCTestCase { func testUsageLevelThresholds() { XCTAssertEqual(BudgetUsageLevel(amount: 79.99, limit: 100), .normal) @@ -121,6 +141,10 @@ final class SettingsWindowControllerTests: XCTestCase { let view = NSHostingView( rootView: PopoverView( coordinator: coordinator, + clock: MinuteClock( + now: Date(timeIntervalSince1970: 1_000), + updates: Empty().eraseToAnyPublisher() + ), settingsWindowController: settings ) )