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..24a44674 100644 --- a/Sources/Insomnia/UI/MenuBarModel.swift +++ b/Sources/Insomnia/UI/MenuBarModel.swift @@ -86,8 +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`. + /// 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. @@ -100,25 +101,31 @@ 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 + 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..266b59db 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. @@ -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,15 +527,25 @@ 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.formatCountdown(remaining: projected.remaining(at: now), shape: projected.countdownShape) + let projection = MenuBarModel.StartProjection( + endsAt: projected.endsAt, + shape: projected.countdownShape, + 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 // 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 +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 { armPendingTick() } else { stopPendingTick() } + syncPendingTick() withAnimation(Motion.base(reduceMotion: reduceMotion)) { model.focusVisible = false } @@ -582,9 +616,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()) @@ -601,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() @@ -648,6 +687,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 +730,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/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 9b55a2cb..918280a1 100644 --- a/Tests/InsomniaTests/UIStatusTests.swift +++ b/Tests/InsomniaTests/UIStatusTests.swift @@ -1199,6 +1199,164 @@ 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() + 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) + } + } + + /// 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..fbe4f0c4 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 @@ -151,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 @@ -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. @@ -1204,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, @@ -1239,7 +1250,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 |