Skip to content
Open
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
18 changes: 14 additions & 4 deletions Sources/Insomnia/Core/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 }
}

Expand Down Expand Up @@ -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() }
Expand Down
38 changes: 38 additions & 0 deletions Sources/Insomnia/Core/SessionMath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Sources/Insomnia/Model/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
25 changes: 16 additions & 9 deletions Sources/Insomnia/UI/MenuBarModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions Sources/Insomnia/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
62 changes: 53 additions & 9 deletions Sources/Insomnia/UI/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -85,6 +85,7 @@ final class StatusItemController: NSObject {
super.init()
installHostingView()
observeManager()
observeCountdownStyle()
}

// MARK: Status item
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
model.pendingProjection = projection
model.pendingCountdown = projection.countdown(at: now)
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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())
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
}

Expand Down
8 changes: 7 additions & 1 deletion Sources/Insomnia/UI/StatusMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
}
Expand Down
27 changes: 27 additions & 0 deletions Tests/InsomniaTests/ConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading
Loading