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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Indefinite activations show ∞ in remaining mode.

### Changed

- Improved Ukrainian translation.
Expand Down
184 changes: 146 additions & 38 deletions src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

// MARK: - Initialization

init() {
Self.registerDefaults()

// Explicitly ensure we start inactive
self.isActive = false
self.timeRemaining = nil
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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()
Expand All @@ -139,29 +137,37 @@ 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
}

// 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)
}

/// 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)
Expand All @@ -170,6 +176,91 @@ class CaffeineViewModel: ObservableObject {

// MARK: - Private Methods

/// 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 {
self.timerDisplayMode == .remaining ? (self.timeRemaining ?? 0) : 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<Duration.UnitsFormatStyle.Unit> =
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)
Expand Down Expand Up @@ -204,6 +295,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 {
Expand All @@ -212,4 +317,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"
}
31 changes: 30 additions & 1 deletion src/Caffeine/Classes/Views/MenuBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions src/Caffeine/Classes/Views/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions src/Caffeine/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
9 changes: 9 additions & 0 deletions src/Caffeine/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading