diff --git a/Crisp/App/AppDelegate.swift b/Crisp/App/AppDelegate.swift index b0c98fa..532a1bf 100644 --- a/Crisp/App/AppDelegate.swift +++ b/Crisp/App/AppDelegate.swift @@ -677,11 +677,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } }) let toolshead = block("toolshead") { - VStack(alignment: .leading, spacing: 0) { - SectionDivider() - ExpandableRowStateful(icon: "wrench.and.screwdriver.fill", iconActive: false, - label: "Tools", state: state, key: \.showTools) - } + ToolsHeaderBlock(state: state) } toolshead.liveInFlight = true blocks.append(toolshead) diff --git a/Crisp/Models/KeepAwakeDuration.swift b/Crisp/Models/KeepAwakeDuration.swift new file mode 100644 index 0000000..c53ef38 --- /dev/null +++ b/Crisp/Models/KeepAwakeDuration.swift @@ -0,0 +1,77 @@ +import Foundation + +enum KeepAwakeDuration: Int, CaseIterable, Identifiable { + case indefinite = 0 + case minutes15 = 900 + case minutes30 = 1800 + case hour1 = 3600 + case hours2 = 7200 + + var id: Int { rawValue } + + /// Duration in seconds, or nil when indefinite (`Always`). + var seconds: TimeInterval? { + self == .indefinite ? nil : TimeInterval(rawValue) + } + + /// Compact label rendered inside each duration pill. + var pillLabel: String { + switch self { + case .indefinite: return String(localized: "Always") + case .minutes15: return String(localized: "15m") + case .minutes30: return String(localized: "30m") + case .hour1: return String(localized: "1h") + case .hours2: return String(localized: "2h") + } + } + + /// Spoken VoiceOver label for each duration pill. + var accessibilityLabel: String { + switch self { + case .indefinite: return String(localized: "Always") + case .minutes15: return String(localized: "15 Minutes") + case .minutes30: return String(localized: "30 Minutes") + case .hour1: return String(localized: "1 Hour") + case .hours2: return String(localized: "2 Hours") + } + } + + /// Rounds positive remaining seconds up to whole minutes (`1...`), or `0` when expired (`<= 0`). + static func remainingMinutes(for remainingSeconds: TimeInterval) -> Int { + guard remainingSeconds > 0 else { return 0 } + return max(1, Int(ceil(remainingSeconds / 60.0))) + } + + /// Formats a positive minute count as a localized time span (`"42m"`, `"1h"`, `"1h 30m"`). + /// Uses only single-argument `String(localized:)` keys (`"%lldh"`, `"%lldm"`) so `xcstrings-compile.py` + /// and `xcodebuild -exportLocalizations` produce identical non-positional catalog keys. + static func formattedSpan(minutes: Int) -> String { + let clamped = max(1, minutes) + let hours = clamped / 60 + let remMins = clamped % 60 + if hours > 0 { + let hPart = String(localized: "\(hours)h") + guard remMins > 0 else { return hPart } + let mPart = String(localized: "\(remMins)m") + return "\(hPart) \(mPart)" + } + return String(localized: "\(clamped)m") + } + + /// Formats the inline countdown subtitle (`"42m left"`, `"1h 30m left"`). + static func countdownLabel(minutes: Int) -> String { + let span = formattedSpan(minutes: minutes) + return String(localized: "\(span) left") + } + + /// Formats the collapsed `Tools` row subtitle: + /// - `nil` when `isActive == false` + /// - `"Keep Awake"` when active indefinitely (`remainingMinutes == nil`) + /// - `"Keep Awake · 42m left"` when timed (`remainingMinutes != nil`) + static func toolsSubtitle(isActive: Bool, remainingMinutes: Int?) -> String? { + guard isActive else { return nil } + let title = String(localized: "Keep Awake") + guard let mins = remainingMinutes, mins > 0 else { return title } + return "\(title) · \(countdownLabel(minutes: mins))" + } +} diff --git a/Crisp/Resources/Localizable.xcstrings b/Crisp/Resources/Localizable.xcstrings index 4f3ac65..f0371ca 100644 --- a/Crisp/Resources/Localizable.xcstrings +++ b/Crisp/Resources/Localizable.xcstrings @@ -135,6 +135,22 @@ } } }, + "%@ left" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩餘 %@" + } + } + } + }, "%@ off" : { "localizations" : { "zh-Hans" : { @@ -237,6 +253,166 @@ "%lld%%" : { "shouldTranslate" : false }, + "%lldh" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小時" + } + } + } + }, + "%lldm" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分钟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分鐘" + } + } + } + }, + "1 Hour" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 小時" + } + } + } + }, + "1h" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "1小時" + } + } + } + }, + "2 Hours" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 小時" + } + } + } + }, + "2h" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "2小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "2小時" + } + } + } + }, + "15 Minutes" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 分钟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 分鐘" + } + } + } + }, + "15m" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "15分" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "15分" + } + } + } + }, + "30 Minutes" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 分钟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 分鐘" + } + } + } + }, + "30m" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "30分" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "30分" + } + } + } + }, "Another input disconnects this display. Reconnect it here to switch back." : { "localizations" : { "zh-Hans" : { @@ -644,6 +820,22 @@ } } }, + "Always" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "始终" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "始終" + } + } + } + }, "Applies a preset saved in Crisp, the same as clicking it in the menu." : { "localizations" : { "zh-Hans" : { diff --git a/Crisp/Services/KeepAwakeService.swift b/Crisp/Services/KeepAwakeService.swift index 3444d03..706aff0 100644 --- a/Crisp/Services/KeepAwakeService.swift +++ b/Crisp/Services/KeepAwakeService.swift @@ -1,5 +1,6 @@ import Foundation import IOKit.pwr_mgt +import SwiftUI // ponytail: session-only + prevent-display-sleep only. Persist across launches or add a // system-only ("let the screen dim") mode if asked; both are a few lines here. @@ -12,7 +13,11 @@ final class KeepAwakeService: ObservableObject { private init() {} @Published private(set) var isActive = false + @Published private(set) var duration: KeepAwakeDuration = .indefinite + @Published private(set) var remainingMinutes: Int? private var assertionID: IOPMAssertionID = 0 + private var expirationDate: Date? + private var timer: Timer? /// True when a configuration profile sets `crisp.disableKeepAwake` = true; managed /// values outrank the user's own, so a `defaults write` cannot switch it back on. @@ -21,6 +26,17 @@ final class KeepAwakeService: ObservableObject { UserDefaults.standard.bool(forKey: "crisp.disableKeepAwake") } + /// Inline subtitle shown on `KeepAwakeRow` when a countdown is running; nil when off or `.indefinite`. + var rowSubtitle: String? { + guard isActive, let mins = remainingMinutes, mins > 0 else { return nil } + return KeepAwakeDuration.countdownLabel(minutes: mins) + } + + /// Subtitle shown on the collapsed `Tools` header when Keep Awake is active. + var toolsSubtitle: String? { + KeepAwakeDuration.toolsSubtitle(isActive: isActive, remainingMinutes: remainingMinutes) + } + /// Prevent display idle sleep (which also blocks system idle sleep) while `on`. func setActive(_ on: Bool) { guard on != isActive else { return } @@ -35,7 +51,11 @@ final class KeepAwakeService: ObservableObject { guard result == kIOReturnSuccess else { return } assertionID = id isActive = true + duration = .indefinite + cancelTimer() } else { + cancelTimer() + duration = .indefinite if assertionID != 0 { IOPMAssertionRelease(assertionID) assertionID = 0 @@ -43,4 +63,52 @@ final class KeepAwakeService: ObservableObject { isActive = false } } + + /// Select a duration (`Always` or a bounded sleep timer), activating Keep Awake if needed. + func selectDuration(_ newDuration: KeepAwakeDuration) { + guard !Self.isDisabledByPolicy else { return } + if !isActive { + setActive(true) + } + guard isActive else { return } + duration = newDuration + cancelTimer() + if let seconds = newDuration.seconds { + expirationDate = Date().addingTimeInterval(seconds) + remainingMinutes = KeepAwakeDuration.remainingMinutes(for: seconds) + let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.tick() } + } + t.tolerance = 0.2 + RunLoop.main.add(t, forMode: .common) + timer = t + } + } + + /// Recompute remaining minutes or expire the assertion when the deadline has passed. + func refreshRemaining(now: Date = Date()) { + guard isActive, let exp = expirationDate else { return } + let rem = exp.timeIntervalSince(now) + if rem <= 0 { + withAnimation(.panelResize) { + setActive(false) + } + } else { + let mins = KeepAwakeDuration.remainingMinutes(for: rem) + if remainingMinutes != mins { + remainingMinutes = mins + } + } + } + + private func tick() { + refreshRemaining() + } + + private func cancelTimer() { + timer?.invalidate() + timer = nil + expirationDate = nil + remainingMinutes = nil + } } diff --git a/Crisp/Views/PanelBlocks.swift b/Crisp/Views/PanelBlocks.swift index 0f9f55e..ea07c85 100644 --- a/Crisp/Views/PanelBlocks.swift +++ b/Crisp/Views/PanelBlocks.swift @@ -146,28 +146,115 @@ struct DisplayHeaderBlock: View { } } +/// Tools section header; surfaces Keep Awake status/countdown while collapsed. +struct ToolsHeaderBlock: View { + @ObservedObject var state: PanelSectionState + @ObservedObject private var keepAwake = KeepAwakeService.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SectionDivider() + ExpandableRow( + icon: "wrench.and.screwdriver.fill", + iconActive: false, + label: "Tools", + subtitle: state.showTools ? nil : keepAwake.toolsSubtitle, + isExpanded: $state.showTools + ) + } + .onReceive(NotificationCenter.default.publisher(for: .crispPanelDidOpen)) { _ in + keepAwake.refreshRemaining() + } + } +} + /// Keep Awake: hold a power assertion against idle-sleep. Session-only, off /// each launch. struct KeepAwakeRow: View { @ObservedObject private var keepAwake = KeepAwakeService.shared var body: some View { - Toggle(isOn: Binding( - get: { keepAwake.isActive }, - set: { keepAwake.setActive($0) } - )) { + VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { MenuItemIcon(systemName: "cup.and.saucer.fill", color: .orange, active: keepAwake.isActive) .accessibilityHidden(true) Text("Keep Awake") .font(.body) Spacer() + if let subtitle = keepAwake.rowSubtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondaryReadable) + .monospacedDigit() + .lineLimit(1) + } + Toggle("", isOn: Binding( + get: { keepAwake.isActive }, + set: { newValue in + guard PanelOpenGuard.allowsActivation else { return } + withAnimation(.panelResize) { keepAwake.setActive(newValue) } + } + )) + .toggleStyle(.switch) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel("Keep Awake") + .accessibilityValue( + keepAwake.rowSubtitle ?? (keepAwake.isActive ? String(localized: "Always") : String(localized: "Off")) + ) + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + + HStack(spacing: 4) { + ForEach(KeepAwakeDuration.allCases) { item in + KeepAwakeDurationPill( + item: item, + isSelected: keepAwake.duration == item + ) { + keepAwake.selectDuration(item) + } + } } + .padding(.leading, 46) + .padding(.trailing, 12) + .padding(.top, 1) + .padding(.bottom, 4) + .curtainReveal(keepAwake.isActive) } - .toggleStyle(.switch) - .controlSize(.small) - .padding(.horizontal, 12) - .padding(.vertical, 3) + .onReceive(NotificationCenter.default.publisher(for: .crispPanelDidOpen)) { _ in + keepAwake.refreshRemaining() + } + } +} + +private struct KeepAwakeDurationPill: View { + let item: KeepAwakeDuration + let isSelected: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button { + guard PanelOpenGuard.allowsActivation else { return } + action() + } label: { + Text(item.pillLabel) + .font(.system(size: 11, weight: isSelected ? .semibold : .regular)) + .lineLimit(1) + .foregroundColor(isSelected ? .white : (isHovered ? .primary : .secondaryReadable)) + .frame(maxWidth: .infinity) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? Color.orange : (isHovered ? Color.primary.opacity(0.10) : Color.primary.opacity(0.05))) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .accessibilityLabel(item.accessibilityLabel) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) } } diff --git a/CrispTests/KeepAwakeDurationTests.swift b/CrispTests/KeepAwakeDurationTests.swift new file mode 100644 index 0000000..40ded61 --- /dev/null +++ b/CrispTests/KeepAwakeDurationTests.swift @@ -0,0 +1,30 @@ +import XCTest + +final class KeepAwakeDurationTests: XCTestCase { + func testDurationSeconds() { + XCTAssertNil(KeepAwakeDuration.indefinite.seconds) + XCTAssertEqual(KeepAwakeDuration.minutes15.seconds, 900) + XCTAssertEqual(KeepAwakeDuration.minutes30.seconds, 1800) + XCTAssertEqual(KeepAwakeDuration.hour1.seconds, 3600) + XCTAssertEqual(KeepAwakeDuration.hours2.seconds, 7200) + } + + func testRemainingMinutesRoundsUpPositiveSecondsAndClampsZero() { + XCTAssertEqual(KeepAwakeDuration.remainingMinutes(for: 3600), 60) + XCTAssertEqual(KeepAwakeDuration.remainingMinutes(for: 3541), 60) + XCTAssertEqual(KeepAwakeDuration.remainingMinutes(for: 3540), 59) + XCTAssertEqual(KeepAwakeDuration.remainingMinutes(for: 0.1), 1) + XCTAssertEqual(KeepAwakeDuration.remainingMinutes(for: 0), 0) + XCTAssertEqual(KeepAwakeDuration.remainingMinutes(for: -5), 0) + } + + func testToolsSubtitleReflectsActiveAndRemainingState() { + XCTAssertNil(KeepAwakeDuration.toolsSubtitle(isActive: false, remainingMinutes: 30)) + let keepAwakeTitle = String(localized: "Keep Awake") + XCTAssertEqual(KeepAwakeDuration.toolsSubtitle(isActive: true, remainingMinutes: nil), keepAwakeTitle) + let timed = KeepAwakeDuration.toolsSubtitle(isActive: true, remainingMinutes: 42) + XCTAssertNotNil(timed) + XCTAssertTrue(timed?.contains(keepAwakeTitle) == true) + XCTAssertTrue(timed?.contains("42") == true) + } +} diff --git a/README.md b/README.md index d2cf096..efd4e4b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Installed from `didriksg/tap` earlier? `brew upgrade` moves you to the main cask - **System toggles**: Dark Mode, Night Shift, and True Tone, one click from the menu bar - **Color**: ICC profile switching, XDR reference presets, HDR on/off per display, and image adjustment (gamma, contrast, gain, invert colors) - **Virtual displays**: create HiDPI virtual screens -- **Extras**: combined brightness slider, auto brightness following the built-in display, a toggle for macOS's own ambient auto-brightness, keep awake, notch hiding, launch at login +- **Extras**: combined brightness slider, auto brightness following the built-in display, a toggle for macOS's own ambient auto-brightness, keep awake for 15/30 minutes, 1/2 hours or indefinitely (with a countdown visible even when Tools is collapsed), notch hiding, launch at login ## How does it compare? diff --git a/project.yml b/project.yml index abdd720..dc3c6bd 100644 --- a/project.yml +++ b/project.yml @@ -90,6 +90,7 @@ targets: - path: Crisp/Models/DDCInputSource.swift - path: Crisp/Models/DisplayRotation.swift - path: Crisp/Models/ArrangementSnap.swift + - path: Crisp/Models/KeepAwakeDuration.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.crisp.tests