From a5f99f56c36ac41292d7ca201427eb7529ffd203 Mon Sep 17 00:00:00 2001 From: deathemperor Date: Fri, 28 Aug 2026 00:19:41 +0700 Subject: [PATCH 1/3] Add menu bar timer showing how long Caffeine has been active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu bar icon gave no indication of how long Caffeine had been running, and for indefinite activations nothing was tracking it at all — the display timer only ran when a timeout was set. Track the activation date and run the display timer whenever active, then render the duration next to the status item icon. Three preferences control it: show/hide, elapsed vs. remaining (remaining is undefined for indefinite activations, so those fall back to elapsed), and a compact (1:23:45) or verbose (1h 23m) format. The menu's status item honors the elapsed/remaining choice too. Durations are formatted with Duration.FormatStyle so they localize automatically; only the new preference labels needed new strings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WxSse82nGGvb6CHbDatmfC --- CHANGELOG.md | 4 + .../ViewModels/CaffeineViewModel.swift | 179 ++++++++++++++---- .../Classes/Views/MenuBarController.swift | 31 ++- .../Classes/Views/PreferencesView.swift | 29 +++ .../Resources/de.lproj/Localizable.strings | 9 + .../Resources/en.lproj/Localizable.strings | 9 + .../Resources/es.lproj/Localizable.strings | 9 + .../Resources/fr.lproj/Localizable.strings | 9 + .../Resources/it.lproj/Localizable.strings | 9 + .../Resources/ja.lproj/Localizable.strings | 9 + .../Resources/ko.lproj/Localizable.strings | 9 + .../Resources/nl.lproj/Localizable.strings | 9 + .../Resources/pt-BR.lproj/Localizable.strings | 9 + .../Resources/pt.lproj/Localizable.strings | 9 + .../Resources/ru.lproj/Localizable.strings | 9 + .../Resources/uk.lproj/Localizable.strings | 9 + .../zh-Hans.lproj/Localizable.strings | 9 + 17 files changed, 321 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e3713..2684b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Menu bar timer showing how long Caffeine has been active, with preferences to hide it, switch between elapsed and remaining time, and choose a compact (`1:23:45`) or verbose (`1h 23m`) format. + ### Changed - Improved Ukrainian translation. diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift index 407c4d1..2024a42 100644 --- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift +++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift @@ -16,17 +16,21 @@ class CaffeineViewModel: ObservableObject { @Published var isActive = false @Published var timeRemaining: TimeInterval? + @Published var elapsedTime: TimeInterval = 0 @Published var showPreferences = false // MARK: - Private Properties private var timeoutTimer: Timer? private var displayTimer: Timer? + private var activationDate: Date? private var cancellables = Set() // MARK: - Initialization init() { + Self.registerDefaults() + // Explicitly ensure we start inactive self.isActive = false self.timeRemaining = nil @@ -69,6 +73,9 @@ class CaffeineViewModel: ObservableObject { // Cancel existing timers self.cancelTimers() + self.activationDate = Date() + self.elapsedTime = 0 + // Set up timeout timer if duration specified if let duration { self.timeRemaining = duration @@ -81,32 +88,21 @@ class CaffeineViewModel: ObservableObject { self?.deactivate() } } - - // Update display every second - self.displayTimer = Timer.scheduledTimer( - withTimeInterval: 1.0, - repeats: true - ) { [weak self] _ in - DispatchQueue.main.async { - guard - let self, - let timeoutTimer = self.timeoutTimer else - { - self?.displayTimer?.invalidate() - return - } - - self.timeRemaining = max(0, timeoutTimer.fireDate.timeIntervalSinceNow) - if self.timeRemaining ?? 0 <= 0 { - self.displayTimer?.invalidate() - self.displayTimer = nil - } - } - } } else { self.timeRemaining = nil } + // Update display every second, whether or not a timeout is set, so that + // elapsed time keeps ticking for indefinite activations too + self.displayTimer = Timer.scheduledTimer( + withTimeInterval: 1.0, + repeats: true + ) { [weak self] _ in + DispatchQueue.main.async { + self?.updateDisplayTime() + } + } + self.isActive = true SleepPreventionManager.shared.preventSleep() @@ -119,6 +115,8 @@ class CaffeineViewModel: ObservableObject { func deactivate() { self.cancelTimers() self.timeRemaining = nil + self.activationDate = nil + self.elapsedTime = 0 self.isActive = false SleepPreventionManager.shared.allowSleep() ActivitySimulator.shared.stopMonitoring() @@ -139,29 +137,32 @@ class CaffeineViewModel: ObservableObject { } } - /// Returns a formatted string for the remaining time - func formattedTimeRemaining() -> String? { + /// Returns the title to show next to the menu bar icon, or `nil` when no timer should be shown + func menuBarTitle() -> String? { + guard + self.isActive, + UserDefaults.standard.bool(forKey: PreferenceKeys.showMenuBarTimer) else + { + return nil + } + + return Self.formattedDuration(self.displayedInterval, format: self.timerFormat) + } + + /// Returns a formatted status string for the menu, honoring the timer display preference + func formattedStatusText() -> String? { // Only return a status if actually active guard self.isActive else { return nil } + if self.timerDisplayMode == .elapsed { + return Self.descriptiveDuration(self.elapsedTime) + } + // If there's time remaining, format it if let remaining = timeRemaining, remaining > 0 { - let seconds = Int(remaining) - - if seconds >= 3600 { - let hours = seconds / 3600 - let minutes = (seconds % 3600) / 60 - return String(format: "%02d:%02d", hours, minutes) - } else if seconds > 60 { - let minutes = seconds / 60 - let format = String(localized: "%d minutes", comment: "Time remaining in minutes") - return String.localizedStringWithFormat(format, minutes) - } else { - let format = String(localized: "%d seconds", comment: "Time remaining in seconds") - return String.localizedStringWithFormat(format, seconds) - } + return Self.descriptiveDuration(remaining) } // Active with no timer (indefinite) @@ -170,6 +171,91 @@ class CaffeineViewModel: ObservableObject { // MARK: - Private Methods + /// The interval the menu bar timer should show. Remaining time is undefined for indefinite + /// activations, so those fall back to elapsed time. + private var displayedInterval: TimeInterval { + if self.timerDisplayMode == .remaining, let timeRemaining { + return timeRemaining + } + return self.elapsedTime + } + + private var timerDisplayMode: TimerDisplayMode { + let raw = UserDefaults.standard.string(forKey: PreferenceKeys.timerDisplayMode) ?? "" + return TimerDisplayMode(rawValue: raw) ?? .elapsed + } + + private var timerFormat: TimerFormat { + let raw = UserDefaults.standard.string(forKey: PreferenceKeys.timerFormat) ?? "" + return TimerFormat(rawValue: raw) ?? .compact + } + + /// Terse duration for the menu bar, e.g. `1:23:45` / `5:07` or `1h 23m` / `5m` / `42s`. + /// Both styles are formatted by the system and therefore localized automatically. + private static func formattedDuration(_ interval: TimeInterval, format: TimerFormat) -> String { + let seconds = max(0, Int(interval)) + let duration = Duration.seconds(seconds) + + switch format { + case .compact: + return duration.formatted(.time(pattern: seconds >= 3600 ? .hourMinuteSecond : .minuteSecond)) + + case .verbose: + let allowed: Set = + if seconds >= 3600 { + [.hours, .minutes] + } else if seconds >= 60 { + [.minutes] + } else { + [.seconds] + } + // Truncate rather than round, so verbose never runs ahead of compact (1:23:45 -> "1h 23m") + return duration.formatted( + .units( + allowed: allowed, + width: .narrow, + maximumUnitCount: 2, + fractionalPart: .hide(rounded: .towardZero) + ) + ) + } + } + + /// Wordier duration for the menu's status item, matching the app's existing phrasing + private static func descriptiveDuration(_ interval: TimeInterval) -> String { + let seconds = Int(max(0, interval)) + + if seconds >= 3600 { + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + return String(format: "%02d:%02d", hours, minutes) + } else if seconds > 60 { + let format = String(localized: "%d minutes", comment: "Time remaining in minutes") + return String.localizedStringWithFormat(format, seconds / 60) + } else { + let format = String(localized: "%d seconds", comment: "Time remaining in seconds") + return String.localizedStringWithFormat(format, seconds) + } + } + + private static func registerDefaults() { + UserDefaults.standard.register(defaults: [ + PreferenceKeys.showMenuBarTimer: true, + PreferenceKeys.timerDisplayMode: TimerDisplayMode.elapsed.rawValue, + PreferenceKeys.timerFormat: TimerFormat.compact.rawValue, + ]) + } + + private func updateDisplayTime() { + guard let activationDate = self.activationDate else { return } + + self.elapsedTime = Date().timeIntervalSince(activationDate) + + if let timeoutTimer = self.timeoutTimer { + self.timeRemaining = max(0, timeoutTimer.fireDate.timeIntervalSinceNow) + } + } + private func setupObservers() { // Observe workspace sleep notification NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.willSleepNotification) @@ -204,6 +290,20 @@ class CaffeineViewModel: ObservableObject { } } +// MARK: - Timer Display Preferences + +/// Whether the menu bar timer counts up from activation or down to the timeout +enum TimerDisplayMode: String { + case elapsed + case remaining +} + +/// How the menu bar timer renders a duration +enum TimerFormat: String { + case compact + case verbose +} + // MARK: - Preference Keys enum PreferenceKeys { @@ -212,4 +312,7 @@ enum PreferenceKeys { static let suppressLaunchMessage = "CASuppressLaunchMessage" static let deactivateOnManualSleep = "CADeactivateOnManualSleep" static let keepAppsActive = "CAKeepAppsActive" + static let showMenuBarTimer = "CAShowMenuBarTimer" + static let timerDisplayMode = "CATimerDisplayMode" + static let timerFormat = "CATimerFormat" } diff --git a/src/Caffeine/Classes/Views/MenuBarController.swift b/src/Caffeine/Classes/Views/MenuBarController.swift index 8747766..adb57ce 100644 --- a/src/Caffeine/Classes/Views/MenuBarController.swift +++ b/src/Caffeine/Classes/Views/MenuBarController.swift @@ -58,6 +58,16 @@ class MenuBarController: NSObject { } .store(in: &self.cancellables) + // Keeps the menu bar title ticking while Caffeine is active + self.viewModel.$elapsedTime + .sink { [weak self] _ in + guard let self else { return } + DispatchQueue.main.async { + self.updateIcon() + } + } + .store(in: &self.cancellables) + self.viewModel.$showPreferences .sink { [weak self] show in if show { @@ -74,6 +84,25 @@ class MenuBarController: NSObject { if let image = NSImage(named: NSImage.Name(imageName)) { button.image = image } + + self.updateTitle(of: button) + } + + private func updateTitle(of button: NSStatusBarButton) { + guard let title = viewModel.menuBarTitle() else { + button.attributedTitle = NSAttributedString(string: "") + button.imagePosition = .imageOnly + return + } + + // Monospaced digits keep the status item from jittering as the time changes + let font = NSFont.monospacedDigitSystemFont( + ofSize: NSFont.systemFontSize(for: .small), + weight: .regular + ) + button.attributedTitle = NSAttributedString(string: " " + title, attributes: [.font: font]) + button.imagePosition = .imageLeading + button.imageHugsTitle = true } @objc @@ -91,7 +120,7 @@ class MenuBarController: NSObject { let menu = NSMenu() // Status info (only show if active) - if self.viewModel.isActive, let timeString = viewModel.formattedTimeRemaining() { + if self.viewModel.isActive, let timeString = viewModel.formattedStatusText() { let infoItem = NSMenuItem(title: timeString, action: nil, keyEquivalent: "") infoItem.isEnabled = false menu.addItem(infoItem) diff --git a/src/Caffeine/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift index 9b9c2d1..9d53194 100644 --- a/src/Caffeine/Classes/Views/PreferencesView.swift +++ b/src/Caffeine/Classes/Views/PreferencesView.swift @@ -14,6 +14,9 @@ struct PreferencesView: View { @AppStorage(PreferenceKeys.suppressLaunchMessage) private var suppressLaunchMessage = false @AppStorage(PreferenceKeys.deactivateOnManualSleep) private var deactivateOnManualSleep = false @AppStorage(PreferenceKeys.keepAppsActive) private var keepAppsActive = false + @AppStorage(PreferenceKeys.showMenuBarTimer) private var showMenuBarTimer = true + @AppStorage(PreferenceKeys.timerDisplayMode) private var timerDisplayMode = TimerDisplayMode.elapsed + @AppStorage(PreferenceKeys.timerFormat) private var timerFormat = TimerFormat.compact var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -95,6 +98,32 @@ struct PreferencesView: View { .font(.system(size: 11)) .foregroundColor(.secondary) .padding(.leading, 20) + + Divider() + .padding(.vertical, 4) + + Toggle("Show timer in menu bar", isOn: self.$showMenuBarTimer) + .font(.system(size: 13)) + + HStack(spacing: 16) { + Picker("Timer display:", selection: self.$timerDisplayMode) { + Text("Elapsed").tag(TimerDisplayMode.elapsed) + Text("Remaining").tag(TimerDisplayMode.remaining) + } + .frame(width: 240) + + Picker("Time format:", selection: self.$timerFormat) { + Text("Compact").tag(TimerFormat.compact) + Text("Verbose").tag(TimerFormat.verbose) + } + .frame(width: 240) + + Spacer() + } + .font(.system(size: 13)) + .pickerStyle(.menu) + .disabled(!self.showMenuBarTimer) + .padding(.leading, 20) } Spacer() diff --git a/src/Caffeine/Resources/de.lproj/Localizable.strings b/src/Caffeine/Resources/de.lproj/Localizable.strings index fddf93f..09940e4 100644 --- a/src/Caffeine/Resources/de.lproj/Localizable.strings +++ b/src/Caffeine/Resources/de.lproj/Localizable.strings @@ -43,3 +43,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine verhindert den Ruhezustand"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Timer in der Menüleiste anzeigen"; +"Timer display:" = "Timer-Anzeige:"; +"Elapsed" = "Verstrichen"; +"Remaining" = "Verbleibend"; +"Time format:" = "Zeitformat:"; +"Compact" = "Kompakt"; +"Verbose" = "Ausführlich"; diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings index 66e5e4c..6ff6693 100644 --- a/src/Caffeine/Resources/en.lproj/Localizable.strings +++ b/src/Caffeine/Resources/en.lproj/Localizable.strings @@ -43,3 +43,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine prevents sleep"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Show timer in menu bar"; +"Timer display:" = "Timer display:"; +"Elapsed" = "Elapsed"; +"Remaining" = "Remaining"; +"Time format:" = "Time format:"; +"Compact" = "Compact"; +"Verbose" = "Verbose"; diff --git a/src/Caffeine/Resources/es.lproj/Localizable.strings b/src/Caffeine/Resources/es.lproj/Localizable.strings index e708b41..4c23ccc 100644 --- a/src/Caffeine/Resources/es.lproj/Localizable.strings +++ b/src/Caffeine/Resources/es.lproj/Localizable.strings @@ -43,3 +43,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine impide el reposo"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Mostrar el temporizador en la barra de menús"; +"Timer display:" = "Visualización del temporizador:"; +"Elapsed" = "Transcurrido"; +"Remaining" = "Restante"; +"Time format:" = "Formato de tiempo:"; +"Compact" = "Compacto"; +"Verbose" = "Detallado"; diff --git a/src/Caffeine/Resources/fr.lproj/Localizable.strings b/src/Caffeine/Resources/fr.lproj/Localizable.strings index e1388d1..9b32486 100644 --- a/src/Caffeine/Resources/fr.lproj/Localizable.strings +++ b/src/Caffeine/Resources/fr.lproj/Localizable.strings @@ -43,3 +43,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine empêche la mise en veille"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Afficher le minuteur dans la barre des menus"; +"Timer display:" = "Affichage du minuteur :"; +"Elapsed" = "Écoulé"; +"Remaining" = "Restant"; +"Time format:" = "Format de durée :"; +"Compact" = "Compact"; +"Verbose" = "Détaillé"; diff --git a/src/Caffeine/Resources/it.lproj/Localizable.strings b/src/Caffeine/Resources/it.lproj/Localizable.strings index 69891b9..e6c2615 100644 --- a/src/Caffeine/Resources/it.lproj/Localizable.strings +++ b/src/Caffeine/Resources/it.lproj/Localizable.strings @@ -42,3 +42,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine impedisce lo stop"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Mostra il timer nella barra dei menu"; +"Timer display:" = "Visualizzazione timer:"; +"Elapsed" = "Trascorso"; +"Remaining" = "Rimanente"; +"Time format:" = "Formato tempo:"; +"Compact" = "Compatto"; +"Verbose" = "Esteso"; diff --git a/src/Caffeine/Resources/ja.lproj/Localizable.strings b/src/Caffeine/Resources/ja.lproj/Localizable.strings index c020ed0..c0e1168 100644 --- a/src/Caffeine/Resources/ja.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ja.lproj/Localizable.strings @@ -43,3 +43,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine がスリープを防ぎます"; + +/* Timer display preferences */ +"Show timer in menu bar" = "メニューバーにタイマーを表示"; +"Timer display:" = "タイマー表示:"; +"Elapsed" = "経過時間"; +"Remaining" = "残り時間"; +"Time format:" = "時間の形式:"; +"Compact" = "コンパクト"; +"Verbose" = "詳細"; diff --git a/src/Caffeine/Resources/ko.lproj/Localizable.strings b/src/Caffeine/Resources/ko.lproj/Localizable.strings index f05d37e..b682ecb 100644 --- a/src/Caffeine/Resources/ko.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ko.lproj/Localizable.strings @@ -42,3 +42,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine이 잠자기를 방지합니다"; + +/* Timer display preferences */ +"Show timer in menu bar" = "메뉴 막대에 타이머 표시"; +"Timer display:" = "타이머 표시:"; +"Elapsed" = "경과 시간"; +"Remaining" = "남은 시간"; +"Time format:" = "시간 형식:"; +"Compact" = "간단히"; +"Verbose" = "자세히"; diff --git a/src/Caffeine/Resources/nl.lproj/Localizable.strings b/src/Caffeine/Resources/nl.lproj/Localizable.strings index ce3caa7..6eae601 100644 --- a/src/Caffeine/Resources/nl.lproj/Localizable.strings +++ b/src/Caffeine/Resources/nl.lproj/Localizable.strings @@ -42,3 +42,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine voorkomt sluimerstand"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Timer in menubalk tonen"; +"Timer display:" = "Timerweergave:"; +"Elapsed" = "Verstreken"; +"Remaining" = "Resterend"; +"Time format:" = "Tijdnotatie:"; +"Compact" = "Compact"; +"Verbose" = "Uitgebreid"; diff --git a/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings b/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings index f9764e8..90ed255 100644 --- a/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings +++ b/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings @@ -42,3 +42,12 @@ /* System messages */ "Caffeine prevents sleep" = "O Caffeine impede o repouso"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Mostrar o cronômetro na barra de menus"; +"Timer display:" = "Exibição do cronômetro:"; +"Elapsed" = "Decorrido"; +"Remaining" = "Restante"; +"Time format:" = "Formato de hora:"; +"Compact" = "Compacto"; +"Verbose" = "Detalhado"; diff --git a/src/Caffeine/Resources/pt.lproj/Localizable.strings b/src/Caffeine/Resources/pt.lproj/Localizable.strings index 53c6852..19dfac5 100644 --- a/src/Caffeine/Resources/pt.lproj/Localizable.strings +++ b/src/Caffeine/Resources/pt.lproj/Localizable.strings @@ -42,3 +42,12 @@ /* System messages */ "Caffeine prevents sleep" = "O Caffeine impede o repouso"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Mostrar o temporizador na barra de menus"; +"Timer display:" = "Apresentação do temporizador:"; +"Elapsed" = "Decorrido"; +"Remaining" = "Restante"; +"Time format:" = "Formato da hora:"; +"Compact" = "Compacto"; +"Verbose" = "Detalhado"; diff --git a/src/Caffeine/Resources/ru.lproj/Localizable.strings b/src/Caffeine/Resources/ru.lproj/Localizable.strings index a83a207..c47d4ee 100644 --- a/src/Caffeine/Resources/ru.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ru.lproj/Localizable.strings @@ -42,3 +42,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine предотвращает переход в сон"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Показывать таймер в строке меню"; +"Timer display:" = "Отображение таймера:"; +"Elapsed" = "Прошло"; +"Remaining" = "Осталось"; +"Time format:" = "Формат времени:"; +"Compact" = "Компактный"; +"Verbose" = "Подробный"; diff --git a/src/Caffeine/Resources/uk.lproj/Localizable.strings b/src/Caffeine/Resources/uk.lproj/Localizable.strings index b1cd894..6479039 100644 --- a/src/Caffeine/Resources/uk.lproj/Localizable.strings +++ b/src/Caffeine/Resources/uk.lproj/Localizable.strings @@ -43,3 +43,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine запобігає переходу в режим сну"; + +/* Timer display preferences */ +"Show timer in menu bar" = "Показувати таймер у рядку меню"; +"Timer display:" = "Відображення таймера:"; +"Elapsed" = "Минуло"; +"Remaining" = "Залишилось"; +"Time format:" = "Формат часу:"; +"Compact" = "Компактний"; +"Verbose" = "Докладний"; diff --git a/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings b/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings index 768aca3..a1a5874 100644 --- a/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings +++ b/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings @@ -43,3 +43,12 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine 会阻止睡眠"; + +/* Timer display preferences */ +"Show timer in menu bar" = "在菜单栏中显示计时器"; +"Timer display:" = "计时器显示:"; +"Elapsed" = "已用时间"; +"Remaining" = "剩余时间"; +"Time format:" = "时间格式:"; +"Compact" = "紧凑"; +"Verbose" = "详细"; From 826bbe45e073fd3114a6e4c68647c7d99c4dc132 Mon Sep 17 00:00:00 2001 From: deathemperor Date: Fri, 28 Aug 2026 05:58:15 +0700 Subject: [PATCH 2/3] =?UTF-8?q?Show=20=E2=99=BE=EF=B8=8F=20instead=20of=20?= =?UTF-8?q?a=20countdown=20for=20indefinite=20activations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining mode fell back to counting elapsed time when no timeout was set, which reads as a countdown that never ends. There is nothing to count down to, so show the infinity symbol instead. Elapsed mode is unchanged and still counts up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WxSse82nGGvb6CHbDatmfC --- CHANGELOG.md | 2 +- .../Classes/ViewModels/CaffeineViewModel.swift | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2684b53..47936af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Menu bar timer showing how long Caffeine has been active, with preferences to hide it, switch between elapsed and remaining time, and choose a compact (`1:23:45`) or verbose (`1h 23m`) format. +- Menu bar timer showing how long Caffeine has been active, with preferences to hide it, switch between elapsed and remaining time, and choose a compact (`1:23:45`) or verbose (`1h 23m`) format. Indefinite activations show ♾️ in remaining mode. ### Changed diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift index 2024a42..d70be4e 100644 --- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift +++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift @@ -146,6 +146,11 @@ class CaffeineViewModel: ObservableObject { return nil } + // There is nothing to count down to for an indefinite activation + if self.timerDisplayMode == .remaining, self.timeRemaining == nil { + return Self.indefiniteSymbol + } + return Self.formattedDuration(self.displayedInterval, format: self.timerFormat) } @@ -171,13 +176,12 @@ class CaffeineViewModel: ObservableObject { // MARK: - Private Methods - /// The interval the menu bar timer should show. Remaining time is undefined for indefinite - /// activations, so those fall back to elapsed time. + /// Shown instead of a countdown when Caffeine is active with no timeout + private static let indefiniteSymbol = "\u{267E}\u{FE0F}" + + /// The interval the menu bar timer should show private var displayedInterval: TimeInterval { - if self.timerDisplayMode == .remaining, let timeRemaining { - return timeRemaining - } - return self.elapsedTime + self.timerDisplayMode == .remaining ? (self.timeRemaining ?? 0) : self.elapsedTime } private var timerDisplayMode: TimerDisplayMode { From 471aa09c46a7e38811b2d55d494d6869f3abb5a7 Mon Sep 17 00:00:00 2001 From: deathemperor Date: Fri, 28 Aug 2026 06:13:34 +0700 Subject: [PATCH 3/3] =?UTF-8?q?Use=20plain=20=E2=88=9E=20rather=20than=20t?= =?UTF-8?q?he=20emoji=20for=20indefinite=20activations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ♾️ emoji renders in color and reads as a dark blob in the menu bar. U+221E is drawn as regular text, so it picks up the menu bar tint in both light and dark appearance. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WxSse82nGGvb6CHbDatmfC --- CHANGELOG.md | 2 +- src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47936af..13579b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Menu bar timer showing how long Caffeine has been active, with preferences to hide it, switch between elapsed and remaining time, and choose a compact (`1:23:45`) or verbose (`1h 23m`) format. Indefinite activations show ♾️ in remaining mode. +- Menu bar timer showing how long Caffeine has been active, with preferences to hide it, switch between elapsed and remaining time, and choose a compact (`1:23:45`) or verbose (`1h 23m`) format. Indefinite activations show ∞ in remaining mode. ### Changed diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift index d70be4e..99c2950 100644 --- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift +++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift @@ -176,8 +176,9 @@ class CaffeineViewModel: ObservableObject { // MARK: - Private Methods - /// Shown instead of a countdown when Caffeine is active with no timeout - private static let indefiniteSymbol = "\u{267E}\u{FE0F}" + /// Shown instead of a countdown when Caffeine is active with no timeout. Plain U+221E rather + /// than the emoji, so it picks up the menu bar's tint instead of rendering in color. + private static let indefiniteSymbol = "\u{221E}" /// The interval the menu bar timer should show private var displayedInterval: TimeInterval {