From 6a646533ed40179228963a0ac88623676bc7c02b Mon Sep 17 00:00:00 2001 From: Chema Alfonso Date: Mon, 19 Oct 2026 11:23:45 +0200 Subject: [PATCH 1/2] Settings: choose whether the countdown shows the time left or the end time Adds a Countdown setting. The menu bar shows either the time left, as before, or the clock time the session ends at. The end time changes only with the session, so nothing redraws the status item each second, and the right-click menu gains a Time left line. A change in Settings reaches a running session at once. The time left stays the default, and a config from an earlier build keeps working. --- Sources/Insomnia/Core/SessionManager.swift | 18 ++- Sources/Insomnia/Core/SessionMath.swift | 38 +++++ Sources/Insomnia/Model/Config.swift | 12 ++ Sources/Insomnia/UI/MenuBarModel.swift | 15 +- Sources/Insomnia/UI/SettingsView.swift | 7 + .../Insomnia/UI/StatusItemController.swift | 32 +++- Sources/Insomnia/UI/StatusMenu.swift | 8 +- Tests/InsomniaTests/ConfigTests.swift | 27 ++++ Tests/InsomniaTests/SessionMathTests.swift | 42 +++++ .../StatusMenuWarningTests.swift | 12 ++ Tests/InsomniaTests/UIStatusTests.swift | 152 ++++++++++++++++++ docs/releasing.md | 5 + docs/spec.md | 24 ++- 13 files changed, 368 insertions(+), 24 deletions(-) diff --git a/Sources/Insomnia/Core/SessionManager.swift b/Sources/Insomnia/Core/SessionManager.swift index 76b52a21..f917e712 100644 --- a/Sources/Insomnia/Core/SessionManager.swift +++ b/Sources/Insomnia/Core/SessionManager.swift @@ -98,7 +98,8 @@ final class SessionManager { var config: Config /// Minute-granularity remaining time for the popover ("2h 14m"). private(set) var remainingText: String = "" - /// Live `H:MM:SS` countdown for the status item, updated at 1 Hz. + /// Countdown text for the status item: `H:MM:SS` updated at 1 Hz, or the + /// end time (`Config.countdownStyle`). private(set) var countdownText: String = "" /// Why the display or the keyboard backlight is left alone on lid close @@ -2876,6 +2877,13 @@ final class SessionManager { armCountdownTimer() } + /// Settings calls this when the countdown style changes, so a running + /// session reads the new one now and gets or loses its tick with it. + func countdownStyleChanged() { + refreshCountdown() + if !countdownPaused { armCountdownTimer() } + } + /// Recompute `remainingText` and `countdownText` from the injected clock. /// Each is only assigned when it changes, so observers are not woken every /// second for the minute-granularity text. @@ -2885,10 +2893,10 @@ final class SessionManager { countdownText = "" return } - let remaining = s.remaining(at: clock()) - let minute = SessionMath.formatRemaining(remaining) + let now = clock() + let minute = SessionMath.formatRemaining(s.remaining(at: now)) if remainingText != minute { remainingText = minute } - let second = SessionMath.formatCountdown(remaining: remaining, shape: s.countdownShape) + let second = SessionMath.countdownText(endsAt: s.endsAt, shape: s.countdownShape, style: config.countdownStyle, now: now) if countdownText != second { countdownText = second } } @@ -2945,6 +2953,8 @@ final class SessionManager { // lid is shut would otherwise leave lid-open arming a 1 Hz timer that // wakes the run loop forever to format an empty string. guard session != nil else { return } + // The end time changes only with the session, so it has no tick. + guard config.countdownStyle == .remaining else { return } let first = SessionMath.nextSecondBoundary(after: clock()) let timer = Timer(fire: first, interval: 1, repeats: true) { [weak self] _ in Task { @MainActor in self?.refreshCountdown() } diff --git a/Sources/Insomnia/Core/SessionMath.swift b/Sources/Insomnia/Core/SessionMath.swift index 4660b827..4e6d9f51 100644 --- a/Sources/Insomnia/Core/SessionMath.swift +++ b/Sources/Insomnia/Core/SessionMath.swift @@ -4,6 +4,8 @@ import Foundation enum SessionMath { /// Shortest session we accept. static let minimumDuration: TimeInterval = 60 + /// Calendar days ahead that a bare weekday still names the right day. + private static let weekdayReachDays = 6 /// Clamp a requested duration into `[minimumDuration, maxDuration]`. static func clamp(_ duration: TimeInterval, maxDuration: TimeInterval) -> TimeInterval { @@ -85,6 +87,42 @@ enum SessionMath { } } + /// The clock time a session ends at, in the system's 12 or 24 hour style. + /// Another day adds the weekday ("Sat 03:00"), and a week or more away the + /// date, since a bare weekday would then name the wrong day. + static func formatEndTime( + _ endsAt: Date, + now: Date, + calendar: Calendar = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent + ) -> String { + let style = Date.FormatStyle(locale: locale, calendar: calendar, timeZone: calendar.timeZone) + let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: calendar.startOfDay(for: endsAt)).day ?? 0 + switch days { + // The same style the "5 minutes left" notification prints the end in. + case ..<1: return endsAt.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: locale, calendar: calendar, timeZone: calendar.timeZone)) + case 1...weekdayReachDays: return endsAt.formatted(style.weekday(.abbreviated).hour().minute()) + default: return endsAt.formatted(style.day().month(.abbreviated).hour().minute()) + } + } + + /// What the menu bar countdown reads for a session that ends at `endsAt`. + static func countdownText( + endsAt: Date, + shape: CountdownShape, + style: CountdownStyle, + now: Date, + calendar: Calendar = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent + ) -> String { + switch style { + case .remaining: + formatCountdown(remaining: remaining(until: endsAt, at: now), shape: shape) + case .endTime: + formatEndTime(endsAt, now: now, calendar: calendar, locale: locale) + } + } + /// Next wall-clock minute boundary at or after `now` (for the redraw timer). static func nextMinuteBoundary(after now: Date) -> Date { let t = now.timeIntervalSinceReferenceDate diff --git a/Sources/Insomnia/Model/Config.swift b/Sources/Insomnia/Model/Config.swift index ba1f019f..1477682e 100644 --- a/Sources/Insomnia/Model/Config.swift +++ b/Sources/Insomnia/Model/Config.swift @@ -11,6 +11,8 @@ struct Config: Codable, Equatable, Sendable { /// The pill that is focused when the pills open, for a start and for an /// extend. var defaultFocusField: DurationInput.Field = .hours + /// What the menu bar countdown reads during a session. + var countdownStyle: CountdownStyle = .remaining /// Hard ceiling on a session, including extensions. 30 days. var maxDuration: TimeInterval = 30 * 24 * 3600 @@ -149,6 +151,8 @@ struct Config: Codable, Equatable, Sendable { // A value this build does not know falls back to hours; it must not // cost the user the rest of the config. defaultFocusField = (try? c.decodeIfPresent(DurationInput.Field.self, forKey: .defaultFocusField)) ?? d.defaultFocusField + // Same for a style this build does not know: back to the time left. + countdownStyle = (try? c.decodeIfPresent(CountdownStyle.self, forKey: .countdownStyle)) ?? d.countdownStyle maxDuration = try c.decodeIfPresent(TimeInterval.self, forKey: .maxDuration) ?? d.maxDuration freezeList = try c.decodeIfPresent([String].self, forKey: .freezeList) ?? d.freezeList freezeAllApps = try c.decodeIfPresent(Bool.self, forKey: .freezeAllApps) ?? d.freezeAllApps @@ -174,6 +178,14 @@ struct Config: Codable, Equatable, Sendable { } } +/// How the menu bar countdown reads during a session. +enum CountdownStyle: String, Codable, CaseIterable, Sendable { + case remaining + /// The clock time the session ends at. It changes only when the session + /// does, so nothing redraws the status item in between. + case endTime +} + // MARK: Lid-close update extension Config { diff --git a/Sources/Insomnia/UI/MenuBarModel.swift b/Sources/Insomnia/UI/MenuBarModel.swift index 9531a49f..6e7f6001 100644 --- a/Sources/Insomnia/UI/MenuBarModel.swift +++ b/Sources/Insomnia/UI/MenuBarModel.swift @@ -87,7 +87,8 @@ final class MenuBarModel { /// Countdown text shown while the manager is still starting the session. var pendingCountdown: String? /// The session a pending start will become; the controller ticks - /// `pendingCountdown` off it at 1 Hz while the phase is `.starting`. + /// `pendingCountdown` off it at 1 Hz while the phase is `.starting` and + /// the countdown shows the time left. var pendingProjection: StartProjection? // Animation triggers. Views run a bounce whenever one of these changes. @@ -107,18 +108,24 @@ final class MenuBarModel { struct StartProjection: Equatable, Sendable { let endsAt: Date let shape: CountdownShape + let style: CountdownStyle /// What the countdown reads at `now`. func countdown(at now: Date) -> String { - SessionMath.formatCountdown(remaining: SessionMath.remaining(until: endsAt, at: now), shape: shape) + SessionMath.countdownText(endsAt: endsAt, shape: shape, style: style, now: now) } } /// Project a start pressed at `now`. Pure, so it can be checked without /// a manager. - static func projectedStart(now: Date, duration: TimeInterval, maxDuration: TimeInterval) -> StartProjection { + static func projectedStart( + now: Date, + duration: TimeInterval, + maxDuration: TimeInterval, + style: CountdownStyle = .remaining + ) -> StartProjection { let session = SessionMath.newSession(now: now, duration: duration, maxDuration: maxDuration) - return StartProjection(endsAt: session.endsAt, shape: session.countdownShape) + return StartProjection(endsAt: session.endsAt, shape: session.countdownShape, style: style) } /// The countdown a start pressed at `now` reads at that moment. diff --git a/Sources/Insomnia/UI/SettingsView.swift b/Sources/Insomnia/UI/SettingsView.swift index 9e29cc36..6c64f06f 100644 --- a/Sources/Insomnia/UI/SettingsView.swift +++ b/Sources/Insomnia/UI/SettingsView.swift @@ -136,6 +136,13 @@ struct SettingsView: View { } } .help("The pill that is focused when the pills open") + Picker("Countdown", selection: bind(\.countdownStyle)) { + Text("Time left").tag(CountdownStyle.remaining) + Text("End time").tag(CountdownStyle.endTime) + } + .help("What the menu bar shows during a session") + // Apply it now if a session is running. + .onChange(of: manager.config.countdownStyle) { manager.countdownStyleChanged() } LabeledContent("Maximum session") { Text(chipLabel(for: manager.config.maxDuration)).foregroundStyle(.secondary) } diff --git a/Sources/Insomnia/UI/StatusItemController.swift b/Sources/Insomnia/UI/StatusItemController.swift index 3eaf440b..de5b9e6c 100644 --- a/Sources/Insomnia/UI/StatusItemController.swift +++ b/Sources/Insomnia/UI/StatusItemController.swift @@ -43,7 +43,7 @@ final class StatusItemController: NSObject { private var keyCatcher: KeyCatcherPanel? /// 1 Hz redraw of the projected countdown while a start is pending, so /// it does not sit frozen for the seconds the recovery agent and pmset - /// take and then jump on confirmation. + /// take and then jump on confirmation. The end time has nothing to tick. private var pendingTick: Timer? var pendingTickArmed: Bool { pendingTick != nil } /// Invalidates in-flight stagger steps when expand/collapse interleave. @@ -504,14 +504,24 @@ final class StatusItemController: NSObject { // manager catches up (pmset takes a moment). let projected = SessionMath.extended(s, by: duration, now: now, maxDuration: manager.config.maxDuration) model.pendingProjection = nil - model.pendingCountdown = SessionMath.formatCountdown(remaining: projected.remaining(at: now), shape: projected.countdownShape) + model.pendingCountdown = SessionMath.countdownText( + endsAt: projected.endsAt, + shape: projected.countdownShape, + style: manager.config.countdownStyle, + now: now + ) } else { // No session yet: the manager has to arm the recovery agent and // disable sleep first, and either can take seconds or refuse. Show // the countdown the session will read the moment it is confirmed, // ticking meanwhile; the phase stays pending, so nothing that acts // on a session (the end ring) is drawn until then. - let projection = MenuBarModel.projectedStart(now: now, duration: duration, maxDuration: manager.config.maxDuration) + let projection = MenuBarModel.projectedStart( + now: now, + duration: duration, + maxDuration: manager.config.maxDuration, + style: manager.config.countdownStyle + ) model.pendingProjection = projection model.pendingCountdown = projection.countdown(at: now) } @@ -521,7 +531,7 @@ final class StatusItemController: NSObject { // leave in one relayout and the countdown scales in. Enter during // the open stagger folds whatever is up. model.phase = mode == .extend && manager.isActive ? .running : .starting - if model.phase == .starting { armPendingTick() } else { stopPendingTick() } + if model.phase == .starting, manager.config.countdownStyle == .remaining { armPendingTick() } else { stopPendingTick() } withAnimation(Motion.base(reduceMotion: reduceMotion)) { model.focusVisible = false } @@ -582,9 +592,10 @@ final class StatusItemController: NSObject { /// 1 Hz redraw of the projected countdown, aligned to whole wall-clock /// seconds like the manager's live one. Runs only while the phase is - /// `.starting`; stopped on confirmation, refusal or reopening. The timer - /// runs on the real run loop, so it is scheduled from `Date()`; the text - /// it draws reads `clock`. + /// `.starting` and the countdown shows the time left; stopped on + /// confirmation, refusal or reopening. The timer runs on the real run + /// loop, so it is scheduled from `Date()`; the text it draws reads + /// `clock`. private func armPendingTick() { stopPendingTick() let first = SessionMath.nextSecondBoundary(after: Date()) @@ -648,6 +659,9 @@ final class StatusItemController: NSObject { // Same for the foreign-sleep line: re-read the bit now, and let the // next opening drop the line if sleep is enabled again. Task { await manager.recheckForeignSleep() } + // The "Time left" line reads a text that nothing ticks while the bar + // shows the end time. + manager.refreshCountdown() let menu = StatusMenu.menu( Self.menuItems(manager: manager, status: status), target: self, @@ -688,7 +702,9 @@ final class StatusItemController: NSObject { commandRunning: manager.commandWarning, foreignSleep: manager.foreignSleepWarning, outputsWaiting: manager.outputsWaitingForRestore, - lidSimulationBuild: LidSimulationBuild.isCompiledIn + lidSimulationBuild: LidSimulationBuild.isCompiledIn, + // The bar shows the time left itself unless it shows the end time. + remaining: manager.config.countdownStyle == .endTime ? manager.remainingText : nil ) } diff --git a/Sources/Insomnia/UI/StatusMenu.swift b/Sources/Insomnia/UI/StatusMenu.swift index 7f68934c..76f2ccd0 100644 --- a/Sources/Insomnia/UI/StatusMenu.swift +++ b/Sources/Insomnia/UI/StatusMenu.swift @@ -46,6 +46,8 @@ enum StatusMenu { /// `outputsWaiting` are output devices still muted from a lid close /// because they were not connected to get their volume back; each gets /// a line and an item to stop waiting for it. + /// `remaining` is the time left, offered while a session runs when the + /// bar shows the end time instead. static func items( sessionActive: Bool, sleepHeld: Bool, @@ -58,12 +60,16 @@ enum StatusMenu { commandRunning: String? = nil, foreignSleep: String? = nil, outputsWaiting: [SavedAudioOutput] = [], - lidSimulationBuild: Bool = false + lidSimulationBuild: Bool = false, + remaining: String? = nil ) -> [Item] { var out: [Item] = [] if let held = SleepHeldLine.line(sessionActive: sessionActive, sleepHeld: sleepHeld) { out.append(Item(title: held.text, kind: held.isWarning ? .warning : .info)) } + if sessionActive, let remaining = present(remaining) { + out.append(Item(title: "Time left: \(remaining)", kind: .info)) + } if let machine = present(machine) { out.append(Item(title: machine, kind: .info)) } diff --git a/Tests/InsomniaTests/ConfigTests.swift b/Tests/InsomniaTests/ConfigTests.swift index 17820e56..c1f27c45 100644 --- a/Tests/InsomniaTests/ConfigTests.swift +++ b/Tests/InsomniaTests/ConfigTests.swift @@ -569,4 +569,31 @@ final class ConfigLoadTests: XCTestCase { XCTAssertEqual(c.defaultFocusField, .hours) XCTAssertEqual(c.endFloor, 15, "one bad value must not throw the whole config away") } + + // MARK: Countdown style + + func testTheDefaultCountdownStyleIsRemaining() { + XCTAssertEqual(Config().countdownStyle, .remaining) + } + + func testAConfigFromAnEarlierBuildKeepsTheRemainingCountdown() throws { + XCTAssertEqual(try decode("{}").countdownStyle, .remaining) + } + + func testTheCountdownStyleIsSavedByName() throws { + for style in CountdownStyle.allCases { + var c = Config() + c.countdownStyle = style + let data = try JSONEncoder().encode(c) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["countdownStyle"] as? String, "\(style)") + XCTAssertEqual(try JSONDecoder().decode(Config.self, from: data).countdownStyle, style) + } + } + + func testAnUnknownCountdownStyleFallsBackToRemainingAndKeepsTheRestOfTheConfig() throws { + let c = try decode(#"{"countdownStyle": "minutes", "endFloor": 15}"#) + XCTAssertEqual(c.countdownStyle, .remaining) + XCTAssertEqual(c.endFloor, 15, "one bad value must not throw the whole config away") + } } diff --git a/Tests/InsomniaTests/SessionMathTests.swift b/Tests/InsomniaTests/SessionMathTests.swift index 96adc582..bc2dbf89 100644 --- a/Tests/InsomniaTests/SessionMathTests.swift +++ b/Tests/InsomniaTests/SessionMathTests.swift @@ -131,4 +131,46 @@ final class SessionMathTests: XCTestCase { // Only reads all-zeros once the deadline is actually reached. XCTAssertEqual(SessionMath.formatCountdown(remaining: 0.3, shape: .hours), "0:00:01") } + + // MARK: End time + + /// Friday 2027-01-15 08:00 UTC, read in a fixed calendar and locale so the + /// text does not depend on the machine running the test. + private var utc: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private func endTime(after offset: TimeInterval, locale: String = "en_GB") -> String { + SessionMath.formatEndTime(t0.addingTimeInterval(offset), now: t0, calendar: utc, locale: Locale(identifier: locale)) + } + + func testEndTimeReadsTheClockTimeToday() { + XCTAssertEqual(endTime(after: 9 * 3600 + 40 * 60), "17:40") + XCTAssertEqual(endTime(after: 9 * 3600 + 40 * 60 + 59), "17:40", "seconds are not shown") + } + + func testEndTimeAddsTheWeekdayWhenItIsNotToday() { + XCTAssertEqual(endTime(after: 19 * 3600), "Sat 03:00") + XCTAssertEqual(endTime(after: 6 * 86400 + 9 * 3600 + 40 * 60), "Thu 17:40") + } + + func testEndTimeAddsTheDateOnceTheWeekdayWouldBeAmbiguous() { + XCTAssertEqual(endTime(after: 7 * 86400 + 9 * 3600 + 40 * 60), "22 Jan at 17:40") + } + + func testEndTimeFollowsTheTwelveHourLocale() { + let text = endTime(after: 9 * 3600 + 40 * 60, locale: "en_US").replacingOccurrences(of: "\u{202F}", with: " ") + XCTAssertEqual(text, "5:40 PM") + } + + func testCountdownTextFollowsTheStyle() { + let end = t0.addingTimeInterval(4 * 3600) + XCTAssertEqual(SessionMath.countdownText(endsAt: end, shape: .hours, style: .remaining, now: t0), "4:00:00") + XCTAssertEqual( + SessionMath.countdownText(endsAt: end, shape: .hours, style: .endTime, now: t0, calendar: utc, locale: Locale(identifier: "en_GB")), + "12:00" + ) + } } diff --git a/Tests/InsomniaTests/StatusMenuWarningTests.swift b/Tests/InsomniaTests/StatusMenuWarningTests.swift index 55e5005d..c63f1bf3 100644 --- a/Tests/InsomniaTests/StatusMenuWarningTests.swift +++ b/Tests/InsomniaTests/StatusMenuWarningTests.swift @@ -168,4 +168,16 @@ final class StatusMenuWarningTests: XCTestCase { let closed = StatusMenu.items(sessionActive: false, sleepHeld: false, machine: nil, actions: nil, throttledBrowsers: [], relaunchProblems: [problem], error: nil) XCTAssertEqual(closed.filter { $0.kind == .warning }.map(\.title), ["\u{26A0} \(problem)"]) } + + func testTheMenuShowsTimeLeftWhileASessionIsActive() { + let items = StatusMenu.items(sessionActive: true, sleepHeld: true, machine: nil, actions: nil, throttledBrowsers: [], error: nil, remaining: "2h 14m") + XCTAssertEqual(items.map(\.kind), [.info, .info, .separator, .settings, .quit]) + XCTAssertEqual(items[0].title, "Sleep held per journal \u{2014} not verified live") + XCTAssertEqual(items[1].title, "Time left: 2h 14m") + } + + func testTheMenuHasNoTimeLeftLineWithoutASession() { + let items = StatusMenu.items(sessionActive: false, sleepHeld: false, machine: nil, actions: nil, throttledBrowsers: [], error: nil, remaining: "2h 14m") + XCTAssertEqual(items.map(\.kind), [.settings, .quit]) + } } diff --git a/Tests/InsomniaTests/UIStatusTests.swift b/Tests/InsomniaTests/UIStatusTests.swift index 9b55a2cb..c3be9117 100644 --- a/Tests/InsomniaTests/UIStatusTests.swift +++ b/Tests/InsomniaTests/UIStatusTests.swift @@ -1199,6 +1199,158 @@ final class UIStatusTests: XCTestCase { XCTAssertEqual(manager.countdownText, "1:59:59") } + // MARK: Countdown style + + /// The end time is a fixed text for the whole session, so nothing needs to + /// redraw the status item each second. + @MainActor + func testEndTimeStyleArmsNoCountdownTimerAndShowsTheEndTime() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + manager.config.countdownStyle = .endTime + await manager.start(duration: 4 * 3600) + let endsAt = h.clock.now.addingTimeInterval(4 * 3600) + XCTAssertFalse(manager.countdownTimerArmed) + XCTAssertEqual(manager.countdownText, SessionMath.formatEndTime(endsAt, now: h.clock.now)) + XCTAssertFalse(manager.countdownText.isEmpty) + } + + @MainActor + func testRemainingStyleStillArmsTheCountdownTimer() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + await manager.start(duration: 4 * 3600) + XCTAssertTrue(manager.countdownTimerArmed) + XCTAssertEqual(manager.countdownText, "4:00:00") + } + + /// Settings calls `countdownStyleChanged()` on a change, and a running + /// session shows the new style at once; the tick goes or comes back with it. + @MainActor + func testChangingTheStyleReachesARunningSessionAtOnce() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + await manager.start(duration: 4 * 3600) + XCTAssertTrue(manager.countdownTimerArmed) + XCTAssertEqual(manager.countdownText, "4:00:00") + + manager.config.countdownStyle = .endTime + manager.countdownStyleChanged() + XCTAssertFalse(manager.countdownTimerArmed) + XCTAssertEqual(manager.countdownText, SessionMath.formatEndTime(h.clock.now.addingTimeInterval(4 * 3600), now: h.clock.now)) + + manager.config.countdownStyle = .remaining + manager.countdownStyleChanged() + XCTAssertTrue(manager.countdownTimerArmed) + XCTAssertEqual(manager.countdownText, "4:00:00") + } + + /// A closed lid has the tick stopped; a style change must not restart it. + @MainActor + func testChangingTheStyleDoesNotRestartAPausedCountdown() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + manager.config.countdownStyle = .endTime + await manager.start(duration: 4 * 3600) + manager.pauseCountdown() + + manager.config.countdownStyle = .remaining + manager.countdownStyleChanged() + XCTAssertFalse(manager.countdownTimerArmed) + XCTAssertEqual(manager.countdownText, "4:00:00") + } + + @MainActor + func testLidOpenArmsNoCountdownTimerWithTheEndTime() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + manager.config.countdownStyle = .endTime + await manager.start(duration: 4 * 3600) + let endTime = manager.countdownText + + manager.pauseCountdown() + h.clock.advance(60) + manager.resumeCountdown() + XCTAssertFalse(manager.countdownTimerArmed) + XCTAssertEqual(manager.countdownText, endTime) + } + + @MainActor + func testEndTimeFollowsAnExtension() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + manager.config.countdownStyle = .endTime + await manager.start(duration: 3600) + let before = manager.countdownText + await manager.extend(by: 5 * 3600) + XCTAssertNotEqual(manager.countdownText, before) + XCTAssertEqual(manager.countdownText, SessionMath.formatEndTime(h.clock.now.addingTimeInterval(6 * 3600), now: h.clock.now)) + } + + /// The projection stands in for the live text until the manager confirms + /// the session, so it must read exactly what the manager will. + @MainActor + func testTheProjectionReadsTheSameTextAsTheManagerInEveryStyle() async { + for style in CountdownStyle.allCases { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + manager.config.countdownStyle = style + let projection = MenuBarModel.projectedStart(now: h.clock.now, duration: 4 * 3600, maxDuration: manager.config.maxDuration, style: style) + await manager.start(duration: 4 * 3600) + XCTAssertEqual(projection.countdown(at: h.clock.now), manager.countdownText, "\(style)") + } + } + + /// The 1 Hz tick of the projected countdown has nothing to redraw when the + /// text is the end time. + @MainActor + func testPendingTickRunsOnlyInRemainingStyle() async { + _ = NSApplication.shared + for style in CountdownStyle.allCases { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + manager.config.countdownStyle = style + let clock = h.clock + let controller = StatusItemController(manager: manager, status: PlaceholderStatus(), showSettings: {}, clock: { clock.now }) + let gate = AsyncGate() + h.backstop.armGate = gate + controller.expand(mode: .start) + controller.focus(.hours) + XCTAssertTrue(controller.model.input.append(digit: 2, to: .hours)) + + controller.commit() + await gate.waitUntilStarted() + XCTAssertEqual(controller.model.phase, .starting) + XCTAssertEqual(controller.pendingTickArmed, style == .remaining, "\(style)") + XCTAssertEqual(controller.model.pendingCountdown, MenuBarModel.projectedStart(now: clock.now, duration: 2 * 3600, maxDuration: manager.config.maxDuration, style: style).countdown(at: clock.now), "\(style)") + await gate.open() + } + } + + /// The bar no longer shows the time left, so the right-click menu does. + /// With the time left on the bar the menu is as it was. + @MainActor + func testTheMenuOffersTheTimeLeftOnlyWithTheEndTimeCountdown() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + await manager.start(duration: 2 * 3600 + 14 * 60) + XCTAssertFalse(StatusItemController.menuItems(manager: manager, status: PlaceholderStatus()).map(\.title).contains { $0.hasPrefix("Time left") }) + + manager.config.countdownStyle = .endTime + manager.refreshCountdown() + let titles = StatusItemController.menuItems(manager: manager, status: PlaceholderStatus()).map(\.title) + XCTAssertTrue(titles.contains("Time left: 2h 14m"), "\(titles)") + } + /// The digits are read by a local key monitor, which only sees events sent /// to a window this app owns. The catcher panel is that window, and the /// whole fix rests on it being able to take key without taking a click, diff --git a/docs/releasing.md b/docs/releasing.md index 51ef0f8d..73046d04 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -250,6 +250,11 @@ below cover what only a real Mac shows. 10 s, then sample `ps -o %cpu= -p ` every 0.5 s for 20 s. The mean stays under about 5%, and the countdown still changes every second. It read about 46% before the countdown stopped animating its digits +- With Countdown set to End time in Settings, a running session shows its end + time in the bar, the right-click menu has a Time left line, and switching + the setting while the session runs changes the bar at once. Start a + session, wait 10 s, then sample `ps -o %cpu= -p ` every 0.5 s for 20 s: + the mean stays near zero (under about 0.1%) ### Lid close diff --git a/docs/spec.md b/docs/spec.md index 55402a81..8cb25107 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -98,7 +98,9 @@ recovery; newly written journals use `frozenProcesses`. - Time is entered inline in the menu bar as Days / Hours / Minutes pills. Enter with empty fields uses the configured default preset. Maximum 30 days. - While active the menu bar shows a second-resolution countdown. The redraw - timer runs at 1 Hz and stops while the lid is closed. + timer runs at 1 Hz and stops while the lid is closed. With the countdown + style set to end time it shows the clock time the session ends at instead + (Settings, section 10), and no timer runs. - Click the cup/countdown to enter an extension; hold the end control to end. Right-click opens the status, browser actions, Settings, and Quit menu. - Session start: write session + state to disk, arm the launchd backstop, and @@ -1064,7 +1066,14 @@ lock it is created mode 0600 in a 0700 directory, and a looser file from an older build is tightened when the app reads it: - presets, default preset, default field (the pill focused when the pills open: - days, hours or minutes; hours by default) + days, hours or minutes; hours by default), countdown (what the menu bar + shows during a session: the time left, or the end time; the time left by + default). The end time is the clock time in the system's 12 or 24 hour + style, with the weekday when it is another day and the date a week or more + away. It changes only when the session does, so no timer redraws the + status item, and the right-click menu gains a "Time left" line. A change + reaches a running session at once. A value this build does not know reads + as the time left. - freeze list (bundle ids), freeze every other app on/off (default off), Docker rule on/off, mute on lid close on/off (default on). Under the mute toggle: on Mac laptops with Apple silicon or a T2 chip, closing the lid @@ -1166,10 +1175,11 @@ one after another with a short stagger. from the original design. Empty-field Enter starts the default preset. **Running state.** On Enter the pills collapse and morph into a compact -second-resolution countdown next to the icon. Clicking the cup or countdown -opens inline duration entry for an extension. Hold the end control to end the -session. The right-click menu contains status lines (lid, watts, Wi-Fi, frozen -apps, Docker), browser relaunch actions, Settings, and Quit. +second-resolution countdown next to the icon (or the end time, when Settings +asks for it). Clicking the cup or countdown opens inline duration entry for an +extension. Hold the end control to end the session. The right-click menu +contains status lines (lid, watts, Wi-Fi, frozen apps, Docker), browser +relaunch actions, Settings, and Quit. Battery watts are read from `AppleSmartBattery` (`InstantAmperage` × `Voltage`) on demand when status is requested. Never continuously polled. @@ -1239,7 +1249,7 @@ transitions. If it feels like a web dropdown, it is wrong. | thermal | `ProcessInfo` notification | none | | network path | `NWPathMonitor` | none | | session deadline | one in-app timer plus independent launchd recovery | recovery checks may wake periodically | -| countdown redraw | 1 Hz timer, stopped while lid closed | one wake per second while active and visible | +| countdown redraw | 1 Hz timer, stopped while lid closed; none with the end time | one wake per second while active and visible; none with the end time | | hotspot retry | only during an outage | none otherwise | | update check | two HTTPS requests to api.github.com per click on "Check for updates" | none | From cab1050e3d4154488235697444af67a3f24447e0 Mon Sep 17 00:00:00 2001 From: Chema Alfonso Date: Mon, 19 Oct 2026 13:40:50 +0200 Subject: [PATCH 2/2] Menu bar: restyle a pending countdown when the setting changes A style change while a start or an extension waits on the helper left the projected countdown in the old style, and a pending start kept its 1 Hz tick after End time was picked. The controller now observes the style, and a pending extension keeps a projection like a pending start, so both redraw in the new style and the tick follows it. Two spec lines this change had left stale are corrected, and the pending tick test ends each session it starts. --- Sources/Insomnia/UI/MenuBarModel.swift | 14 ++--- .../Insomnia/UI/StatusItemController.swift | 38 ++++++++++-- Tests/InsomniaTests/UIStartupTests.swift | 60 +++++++++++++++++++ Tests/InsomniaTests/UIStatusTests.swift | 6 ++ docs/spec.md | 9 +-- 5 files changed, 111 insertions(+), 16 deletions(-) diff --git a/Sources/Insomnia/UI/MenuBarModel.swift b/Sources/Insomnia/UI/MenuBarModel.swift index 6e7f6001..24a44674 100644 --- a/Sources/Insomnia/UI/MenuBarModel.swift +++ b/Sources/Insomnia/UI/MenuBarModel.swift @@ -86,9 +86,9 @@ final class MenuBarModel { var focusVisible: Bool = false /// Countdown text shown while the manager is still starting the session. var pendingCountdown: String? - /// The session a pending start will become; the controller ticks - /// `pendingCountdown` off it at 1 Hz while the phase is `.starting` and - /// the countdown shows the time left. + /// The session a pending start or extension will become; the controller + /// ticks `pendingCountdown` off it at 1 Hz while the phase is `.starting` + /// and the countdown shows the time left. var pendingProjection: StartProjection? // Animation triggers. Views run a bounce whenever one of these changes. @@ -101,10 +101,10 @@ final class MenuBarModel { return nil } - /// The session a start pressed at some moment will become, reduced to - /// what the countdown needs: the same deadline and the same shape the - /// manager will use, so the live text replaces the projection without a - /// jump. + /// The session a start or an extension pressed at some moment will + /// become, reduced to what the countdown needs: the same deadline and the + /// same shape the manager will use, so the live text replaces the + /// projection without a jump. struct StartProjection: Equatable, Sendable { let endsAt: Date let shape: CountdownShape diff --git a/Sources/Insomnia/UI/StatusItemController.swift b/Sources/Insomnia/UI/StatusItemController.swift index de5b9e6c..266b59db 100644 --- a/Sources/Insomnia/UI/StatusItemController.swift +++ b/Sources/Insomnia/UI/StatusItemController.swift @@ -85,6 +85,7 @@ final class StatusItemController: NSObject { super.init() installHostingView() observeManager() + observeCountdownStyle() } // MARK: Status item @@ -225,6 +226,29 @@ final class StatusItemController: NSObject { } } + /// Settings can change the countdown style while a start or an extension + /// is pending; the projected text and its tick follow it at once. + private func observeCountdownStyle() { + withObservationTracking { + _ = manager.config.countdownStyle + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + self.restylePendingCountdown() + self.observeCountdownStyle() + } + } + } + + private func restylePendingCountdown() { + let style = manager.config.countdownStyle + guard let projection = model.pendingProjection, projection.style != style else { return } + let restyled = MenuBarModel.StartProjection(endsAt: projection.endsAt, shape: projection.shape, style: style) + model.pendingProjection = restyled + model.pendingCountdown = restyled.countdown(at: clock()) + syncPendingTick() + } + private func managerChanged() { reminder.sync(endsAt: manager.session?.endsAt) if let next = Self.phase(forActive: manager.isActive, phase: model.phase) { @@ -503,13 +527,13 @@ final class StatusItemController: NSObject { // session so the countdown already has the final shape while the // manager catches up (pmset takes a moment). let projected = SessionMath.extended(s, by: duration, now: now, maxDuration: manager.config.maxDuration) - model.pendingProjection = nil - model.pendingCountdown = SessionMath.countdownText( + let projection = MenuBarModel.StartProjection( endsAt: projected.endsAt, shape: projected.countdownShape, - style: manager.config.countdownStyle, - now: now + style: manager.config.countdownStyle ) + model.pendingProjection = projection + model.pendingCountdown = projection.countdown(at: now) } else { // No session yet: the manager has to arm the recovery agent and // disable sleep first, and either can take seconds or refuse. Show @@ -531,7 +555,7 @@ final class StatusItemController: NSObject { // leave in one relayout and the countdown scales in. Enter during // the open stagger folds whatever is up. model.phase = mode == .extend && manager.isActive ? .running : .starting - if model.phase == .starting, manager.config.countdownStyle == .remaining { armPendingTick() } else { stopPendingTick() } + syncPendingTick() withAnimation(Motion.base(reduceMotion: reduceMotion)) { model.focusVisible = false } @@ -612,6 +636,10 @@ final class StatusItemController: NSObject { pendingTick = nil } + private func syncPendingTick() { + if model.phase == .starting, manager.config.countdownStyle == .remaining { armPendingTick() } else { stopPendingTick() } + } + private func refreshPendingCountdown() { guard model.phase == .starting, let projection = model.pendingProjection else { stopPendingTick() diff --git a/Tests/InsomniaTests/UIStartupTests.swift b/Tests/InsomniaTests/UIStartupTests.swift index 30d3493c..8b92c665 100644 --- a/Tests/InsomniaTests/UIStartupTests.swift +++ b/Tests/InsomniaTests/UIStartupTests.swift @@ -473,4 +473,64 @@ final class UIStartupTests: XCTestCase { XCTAssertEqual(rig.model.visiblePills, 0) XCTAssertFalse(NSApp.windows.contains { ($0 as? KeyCatcherPanel)?.isVisible == true }) } + + // MARK: Countdown style + + /// Settings can change the style while a start waits on the helper: the + /// projected text follows at once, and so does its 1 Hz tick. + @MainActor + func testChangingTheStyleRestylesAPendingStart() async { + let rig = Rig() + defer { rig.h.home.destroy() } + let gate = AsyncGate() + rig.h.backstop.armGate = gate + rig.openAndTypeOneDay() + rig.controller.commit() + await gate.waitUntilStarted() + let projection = MenuBarModel.projectedStart(now: rig.h.clock.now, duration: 86400, maxDuration: rig.manager.config.maxDuration) + XCTAssertEqual(rig.model.pendingCountdown, "1d 0:00:00") + XCTAssertTrue(rig.controller.pendingTickArmed) + + rig.manager.config.countdownStyle = .endTime + var ok = await waitUntil { !rig.controller.pendingTickArmed } + XCTAssertTrue(ok) + XCTAssertEqual(rig.model.pendingCountdown, SessionMath.formatEndTime(projection.endsAt, now: rig.h.clock.now)) + + rig.manager.config.countdownStyle = .remaining + ok = await waitUntil { rig.controller.pendingTickArmed } + XCTAssertTrue(ok) + XCTAssertEqual(rig.model.pendingCountdown, "1d 0:00:00") + + await gate.open() + ok = await waitUntil { rig.model.pendingCountdown == nil } + XCTAssertTrue(ok) + XCTAssertFalse(rig.controller.pendingTickArmed) + } + + /// The same for an extension in flight, which has no tick of its own. + @MainActor + func testChangingTheStyleRestylesAPendingExtension() async { + let rig = Rig() + defer { rig.h.home.destroy() } + await rig.manager.start(duration: 3600) + var ok = await waitUntil { rig.model.phase == .running } + XCTAssertTrue(ok) + let gate = AsyncGate() + rig.h.backstop.armGate = gate + rig.controller.expand(mode: .extend) + XCTAssertTrue(rig.model.input.append(digit: 1, to: .hours)) + rig.controller.commit() + await gate.waitUntilStarted() + XCTAssertEqual(rig.model.pendingCountdown, "2:00:00") + + rig.manager.config.countdownStyle = .endTime + let endTime = SessionMath.formatEndTime(rig.h.clock.now.addingTimeInterval(2 * 3600), now: rig.h.clock.now) + ok = await waitUntil { rig.model.pendingCountdown == endTime } + XCTAssertTrue(ok, "\(rig.model.pendingCountdown ?? "nil")") + XCTAssertFalse(rig.controller.pendingTickArmed) + + await gate.open() + ok = await waitUntil { rig.model.pendingCountdown == nil } + XCTAssertTrue(ok) + } } diff --git a/Tests/InsomniaTests/UIStatusTests.swift b/Tests/InsomniaTests/UIStatusTests.swift index c3be9117..918280a1 100644 --- a/Tests/InsomniaTests/UIStatusTests.swift +++ b/Tests/InsomniaTests/UIStatusTests.swift @@ -1332,6 +1332,12 @@ final class UIStatusTests: XCTestCase { XCTAssertEqual(controller.pendingTickArmed, style == .remaining, "\(style)") XCTAssertEqual(controller.model.pendingCountdown, MenuBarModel.projectedStart(now: clock.now, duration: 2 * 3600, maxDuration: manager.config.maxDuration, style: style).countdown(at: clock.now), "\(style)") await gate.open() + let confirmBy = Date().addingTimeInterval(2) + while controller.model.phase != .running, Date() < confirmBy { + try? await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(controller.model.phase, .running, "\(style)") + await manager.end(reason: .user) } } diff --git a/docs/spec.md b/docs/spec.md index 8cb25107..fbe4f0c4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -153,7 +153,7 @@ Quit, or reconcile. | Docker rule (default off) | if Docker Desktop is running and `docker ps -q` is empty, journal its tree, ask `docker ps -q` once more right before the SIGSTOP and freeze it only on a second clean empty answer; busy, a failed probe, a timeout, a lid open or a session end at either point leaves it running | resume | | Mute (optional, default on) | journal the default output's UID, name, volume and mute state as its own entry, unless that device already has one, then mute that device; another device's entry never stops it | restore each entry on its own device, never on another output, and clear only that entry; a device the user unmuted meanwhile is left as it is; a device that is not connected keeps its entry (see below) | | Low Power Mode | on (optional, default on) | off unless a battery or thermal floor still wants it | -| Countdown redraw | stop timer | restart timer | +| Countdown redraw | stop timer | restart timer; with the end time, format the text again only | The display and keyboard rows go through private frameworks with no ABI contract: the DisplayServices C functions and CoreBrightness's @@ -1214,9 +1214,10 @@ Battery watts are read from `AppleSmartBattery` (`InstantAmperage` × its neighbour. The slots leave in one relayout 0.36 s after the last pill's step, and the width is written then, never across a visible pill. On Enter the eye starts to open at once and the countdown scales in from - the leading edge after the slots have left, showing the time the session - will read once the manager confirms it and ticking at 1 Hz meanwhile; - the hold-to-end ring follows the confirmation. A click on the eye during + the leading edge after the slots have left, showing what the session + will read once the manager confirms it, in the current countdown style, + and ticking at 1 Hz meanwhile when that is the time left; the + hold-to-end ring follows the confirmation. A click on the eye during an Esc fold brings the pills back; during an Enter fold the pending start owns the bar and the click does nothing until the manager answers. Transitions carry their own animation,