diff --git a/rootshell/App/UIApplication+CommandFallback.swift b/rootshell/App/UIApplication+CommandFallback.swift index 27d2c1ed..15d9213b 100644 --- a/rootshell/App/UIApplication+CommandFallback.swift +++ b/rootshell/App/UIApplication+CommandFallback.swift @@ -53,6 +53,10 @@ extension UIApplication { @MainActor private func ghostty_postNotification(_ name: Notification.Name, userInfo: [String: Any] = [:]) { + // App-wide chords arrive here when TerminalView is out of the responder + // chain, so its `pressesBegan` hook never sees them. + noteAlwaysOnDisplayInteraction() + var info = userInfo if let sceneID = ghostty_activeWindowSceneSessionID() { info[GhosttyCommandRouting.windowSceneSessionIDKey] = sceneID diff --git a/rootshell/UI/Settings/Appearance/AppearanceDetailSettingsViews.swift b/rootshell/UI/Settings/Appearance/AppearanceDetailSettingsViews.swift index 57551fe8..468eb49c 100644 --- a/rootshell/UI/Settings/Appearance/AppearanceDetailSettingsViews.swift +++ b/rootshell/UI/Settings/Appearance/AppearanceDetailSettingsViews.swift @@ -276,7 +276,8 @@ struct WindowSettingsView: View { /// Whether the "Display" Section has anything to show. On Catalyst the /// brightness controls are its only rows, so the whole Section is hidden - /// pre-26; on iOS the Full Screen / Always On Display toggles keep it alive. + /// pre-26; on iOS the Full Screen toggle and Always On Display slider keep it + /// alive. private var showDisplaySection: Bool { #if targetEnvironment(macCatalyst) return brightnessBoostAvailable @@ -469,8 +470,8 @@ struct WindowSettingsView: View { } // EDR is meaningless on visionOS, so the Display section (and its HDR - // brightness boost) is excluded there. The Full Screen / Always On - // Display / home-indicator toggles are iOS-only concepts, so they stay + // brightness boost) is excluded there. The Full Screen / home-indicator + // toggles and the Always On Display slider are iOS-only concepts, so they stay // gated to non-Catalyst; the brightness boost shows on both iOS and // Catalyst (HDR-capable built-in and external displays) — but only on // OS 26+, where the EDR APIs exist. On Catalyst the boost is the @@ -487,8 +488,42 @@ struct WindowSettingsView: View { ) .themedRow() - Toggle("Always On Display", isOn: $alwaysOnDisplayManager.isEnabled) - .themedRow() + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Always On Display") + Spacer() + Text(alwaysOnDisplayManager.duration.displayValue) + .foregroundColor(.secondary) + .monospacedDigit() + // A floor, not a fixed width: it keeps the slider from + // shifting as the value changes without clipping + // "Always" at large Dynamic Type sizes. + .frame(minWidth: 68, alignment: .trailing) + } + HStack(spacing: 12) { + Image(systemName: "moon.zzz") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Slider( + value: $alwaysOnDisplayManager.sliderValue, + in: AlwaysOnDisplayDuration.sliderRange, + step: 1 + ) + // The row title is a sibling in the HStack above, so the + // slider needs its own name for VoiceOver. + .accessibilityLabel(Text("Always On Display")) + .accessibilityValue(alwaysOnDisplayManager.duration.displayValue) + Image(systemName: "infinity") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + Text(alwaysOnDisplayManager.duration.explanation) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .themedRow() if deviceHasHomeIndicator { DescribedToggle( diff --git a/rootshell/UI/Terminal/TerminalView+Keyboard.swift b/rootshell/UI/Terminal/TerminalView+Keyboard.swift index bf7c2f7a..7dcafff3 100644 --- a/rootshell/UI/Terminal/TerminalView+Keyboard.swift +++ b/rootshell/UI/Terminal/TerminalView+Keyboard.swift @@ -335,6 +335,10 @@ extension Ghostty.TerminalView { extension Ghostty.TerminalView { override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + // Hardware keys reach the responder chain, not the window-level touch + // observer, so typing has to restart the always-on-display window here. + noteAlwaysOnDisplayInteraction() + var handled = false var shouldSkipSuper = false diff --git a/rootshell/UI/Terminal/TerminalView.swift b/rootshell/UI/Terminal/TerminalView.swift index e61a2e3b..93230e5a 100644 --- a/rootshell/UI/Terminal/TerminalView.swift +++ b/rootshell/UI/Terminal/TerminalView.swift @@ -3708,6 +3708,13 @@ extension Ghostty { #endif func insertText(_ text: String) { + // Software-keyboard and input-method text arrives here, not through + // `pressesBegan`, and the terminal is not a UITextField, so this is + // the only path that sees it. It has to restart the always-on-display + // window before any of the early returns below: a suppressed + // duplicate or a sentinel key is still the user typing. + noteAlwaysOnDisplayInteraction() + // Sentinel key names are not text. Drop before any flag is consumed. if let sentinel = KeyCode.sentinelKey(for: text) { if sentinel == .escape { diff --git a/rootshell/UI/Window/AlwaysOnDisplayManager.swift b/rootshell/UI/Window/AlwaysOnDisplayManager.swift index 627d76ea..963aa5d3 100644 --- a/rootshell/UI/Window/AlwaysOnDisplayManager.swift +++ b/rootshell/UI/Window/AlwaysOnDisplayManager.swift @@ -2,9 +2,19 @@ // AlwaysOnDisplayManager.swift // rootshell // -// Global "Always On Display" toggle: while enabled, the system idle timer is -// disabled so the screen never auto-locks or dims during long-running -// terminal work (builds, SSH sessions, `tail -f`, monitoring). +// Global "Always On Display" setting: while a hold is active the system idle +// timer is disabled so the screen never auto-locks or dims during +// long-running terminal work (builds, SSH sessions, `tail -f`, monitoring). +// +// The setting is a duration, not a plain switch (see `AlwaysOnDisplayDuration`): +// Off, 1...30 minutes, or Always. A timed hold behaves like a custom auto-lock +// interval — every touch or key press restarts the window, so the screen stays +// awake while you work and hands control back to the system once you stop. +// +// Only a timed hold has a window to restart, so the observers and window +// gesture recognizers that watch for interaction are installed with one and +// torn down with it. Off and Always install none of them, and the input paths +// that report interaction return on a stored Bool. // // iOS/iPadOS only: // - `UIApplication.isIdleTimerDisabled` is the correct API on native iOS/iPad. @@ -12,8 +22,8 @@ // (governed by power assertions, not the idle timer). // - visionOS has no traditional auto-lock idle timer (the headset sleeps when // removed), so the concept is not meaningful. -// The whole file is gated to native iOS/iPad; the `#else` provides a no-op -// modifier so the App body compiles unchanged on every platform. +// The whole file is gated to native iOS/iPad; the `#else` provides no-op +// hooks so the App body and the key-input path compile unchanged everywhere. // // Mirrors `PaddingManager` (@Observable singleton persisting to UserDefaults // with a synchronous `didSet`, bound into settings via @Bindable) and @@ -25,31 +35,128 @@ import UIKit #if !targetEnvironment(macCatalyst) && !os(visionOS) +/// How long the screen is kept awake after the last user interaction. +/// +/// Persisted as the raw slider position: `0` = off, `1...30` = minutes, +/// `31` = always. Keeping the storage and the slider on one integer scale is +/// what lets a single `Slider` walk Off -> 1 min -> ... -> 30 min -> Always. +enum AlwaysOnDisplayDuration: Equatable { + case off + case minutes(Int) // 1...maxMinutes + case always + + static let maxMinutes = 30 + + /// `Slider` bounds covering every case, Off through Always. + static let sliderRange: ClosedRange = 0...Double(maxMinutes + 1) + + init(rawValue: Int) { + if rawValue <= 0 { + self = .off + } else if rawValue > Self.maxMinutes { + self = .always + } else { + self = .minutes(rawValue) + } + } + + var rawValue: Int { + switch self { + case .off: return 0 + case .minutes(let minutes): return min(max(minutes, 1), Self.maxMinutes) + case .always: return Self.maxMinutes + 1 + } + } + + /// Whether the idle timer should currently be disabled for this setting. + var keepsScreenAwake: Bool { self != .off } + + /// The awake window, or nil when there is nothing to time (Off never holds + /// the screen awake, Always holds it without a deadline). + var holdInterval: TimeInterval? { + guard case .minutes(let minutes) = self else { return nil } + return TimeInterval(minutes) * 60 + } + + /// Compact value shown next to the row title. + var displayValue: String { + switch self { + case .off: + return String(localized: "Off", comment: "Always On Display duration: disabled") + case .minutes(let minutes): + return String(localized: "\(minutes) min", comment: "Always On Display duration in minutes") + case .always: + return String(localized: "Always", comment: "Always On Display duration: never auto-lock") + } + } + + /// Caption under the slider explaining what the current value does. + var explanation: String { + switch self { + case .off: + return String(localized: "The screen dims and locks on the system's normal auto-lock schedule.", + comment: "Always On Display footer: disabled") + case .minutes(let minutes): + return String(localized: "Keeps the screen awake while you work. It locks \(minutes) minute\(minutes == 1 ? "" : "s") after your last touch or key press.", + comment: "Always On Display footer: timed hold") + case .always: + return String(localized: "Keeps the screen awake the whole time rootshell is in the foreground. It never auto-locks or dims.", + comment: "Always On Display footer: never auto-lock") + } + } +} + @MainActor @Observable final class AlwaysOnDisplayManager { static let shared = AlwaysOnDisplayManager() - private nonisolated static let storageKey = "alwaysOnDisplayEnabled" + private nonisolated static let storageKey = "alwaysOnDisplayMinutes" + /// Pre-slider builds stored a plain on/off switch, where "on" meant + /// "never auto-lock". Migrated to `storageKey` on first launch. + private nonisolated static let legacyToggleKey = "alwaysOnDisplayEnabled" - /// While true the system idle timer is disabled (screen stays awake). - var isEnabled: Bool { + /// How long the screen is held awake after the last interaction. + var duration: AlwaysOnDisplayDuration { didSet { - guard isEnabled != oldValue else { return } - UserDefaults.standard.set(isEnabled, forKey: Self.storageKey) - apply() // synchronous -> instant effect + guard duration != oldValue else { return } + UserDefaults.standard.set(duration.rawValue, forKey: Self.storageKey) + armHold() // synchronous -> instant effect } } - private var isInstalled = false + /// Bridge for `Slider`, which works in `Double` while the setting is a + /// discrete step. Reads `duration`, so @Observable still tracks it. + var sliderValue: Double { + get { Double(duration.rawValue) } + set { duration = AlwaysOnDisplayDuration(rawValue: Int(newValue.rounded())) } + } + + // None of the state below is UI state, and `isTrackingInteraction` and + // `lastArmed` are read on every touch and key press, so keep all of it out + // of the observation registrar. + @ObservationIgnored private var isInstalled = false + @ObservationIgnored private var holdTimer: Timer? + /// Coalesces the per-touch rearm; see `noteUserInteraction()`. + @ObservationIgnored private var lastArmed: ContinuousClock.Instant? + /// True only while a timed hold is selected, i.e. while interaction has to + /// be watched at all. Stored rather than derived from `duration`, so the + /// check on the input paths costs nothing beyond the branch. + @ObservationIgnored private var isTrackingInteraction = false + /// Notification observers and window gesture recognizers owned by the + /// interaction tracking; both come off when it is torn down. The gestures + /// are held weakly — the window they are attached to owns them. + @ObservationIgnored private var interactionObservers: [NSObjectProtocol] = [] + @ObservationIgnored private let interactionGestures = + NSHashTable.weakObjects() private init() { // didSet does NOT fire during init, so this does not apply early. // This is only an optimistic seed: on a locked/background launch where - // protected data is unavailable, the read returns false. `install()` + // protected data is unavailable, the read returns nothing. `install()` // re-reads the real value once protected data is available, so a - // persisted "on" is never masked for the process lifetime. - self.isEnabled = UserDefaults.standard.bool(forKey: Self.storageKey) + // persisted duration is never masked for the process lifetime. + self.duration = Self.persistedDuration() } /// Idempotent. Loads and applies the persisted state once protected data is @@ -57,9 +164,10 @@ final class AlwaysOnDisplayManager { /// /// The persisted value is always re-read from `UserDefaults` (not the /// possibly-stale in-memory seed): a locked/background launch can make - /// `UserDefaults` read as false, which would otherwise mask a persisted - /// "on" for the whole process. `isIdleTimerDisabled` also resets to false on - /// a fresh launch, so re-applying on `didBecomeActive` keeps state robust. + /// `UserDefaults` read as empty, which would otherwise mask a persisted + /// duration for the whole process. `isIdleTimerDisabled` also resets to + /// false on a fresh launch, so re-applying on `didBecomeActive` keeps state + /// robust — and starts a fresh awake window on every return to the app. func install() { guard !isInstalled else { return } isInstalled = true @@ -71,25 +179,240 @@ final class AlwaysOnDisplayManager { object: nil, queue: .main ) { _ in - Task { @MainActor in + MainActor.assumeIsolated { AlwaysOnDisplayManager.shared.reloadAndApply() } } + // Backgrounding hands the screen back to the system: UIKit asks for the + // assertion to be dropped once it is not needed, and `whenAvailable` + // above is deliberately not gated on activation, so a background launch + // or unlock must not leave a hold armed where nobody can see the screen. + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { _ in + // Synchronous: a `Task` hop could still be pending when the process + // is suspended, leaving the assertion set with nobody watching. + MainActor.assumeIsolated { + AlwaysOnDisplayManager.shared.suspendHold() + } + } + // Interaction tracking is deliberately NOT installed here: it belongs + // to a timed hold, so `armHold()` installs and removes it with one. + // The two lifecycle observers above stay unconditional — they fire a + // handful of times per session and are what makes the persisted setting + // take effect after a launch or a background/unlock cycle. + } + + /// Restarts the awake window. Called for every touch-down, hardware key + /// press and text edit, so it stays cheap: without a timed hold it returns + /// on a stored Bool (and nothing is installed to call it in the first + /// place), and rapid input is coalesced to at most one rearm per second + /// (a window is a minute or more, so the lost fraction is irrelevant). + func noteUserInteraction() { + guard isTrackingInteraction else { return } + if let lastArmed, ContinuousClock.now - lastArmed < .seconds(1) { return } + armHold() // owns `lastArmed` } /// Re-read the persisted setting (protected data assumed available here) and /// apply it to the system idle timer. private func reloadAndApply() { - let persisted = UserDefaults.standard.bool(forKey: Self.storageKey) - if persisted != isEnabled { - isEnabled = persisted // didSet persists (no-op, same value) + applies + Self.migrateLegacyToggleIfNeeded() + let persisted = Self.persistedDuration() + if persisted != duration { + duration = persisted // didSet persists (no-op, same value) + arms } else { - apply() + armHold() + } + } + + /// (Re)start the awake window for the current setting and apply it to the + /// system idle timer. + private func armHold() { + holdTimer?.invalidate() + holdTimer = nil + lastArmed = nil + + // The one place where interaction tracking follows the setting: + // `armHold()` already runs on every change to `duration`, on the reload + // once protected data is available, and on every activation. It follows + // the setting only, not the lifecycle — a backgrounded app receives no + // touches, so tearing tracking down there would be pure churn. + setInteractionTracking(duration.holdInterval != nil) + + // A background launch or unlock reaches here through `whenAvailable`; + // arming then would hold an assertion for a screen nobody is looking at. + // `didBecomeActive` arms for real once the app is in front. + guard UIApplication.shared.applicationState != .background else { + UIApplication.shared.isIdleTimerDisabled = false + return + } + + UIApplication.shared.isIdleTimerDisabled = duration.keepsScreenAwake + + guard let interval = duration.holdInterval else { return } + lastArmed = ContinuousClock.now + // Scheduled in `.common` so a long scroll or text-selection drag cannot + // postpone the deadline, and fired synchronously on the main run loop: + // hopping through a `Task` would let a rearm land between the fire and + // the callback, and the stale callback would then tear down the newer + // hold. The identity check below covers that shape regardless. + let timer = Timer(timeInterval: interval, repeats: false) { timer in + MainActor.assumeIsolated { + AlwaysOnDisplayManager.shared.expireHold(timer) + } } + RunLoop.main.add(timer, forMode: .common) + holdTimer = timer } - private func apply() { - UIApplication.shared.isIdleTimerDisabled = isEnabled + /// The window elapsed without any interaction: hand the screen back to the + /// system idle timer, which decides when to dim and lock from here. The + /// setting is not spent — the next touch or key press arms a fresh window. + private func expireHold(_ timer: Timer) { + // Anything that rearms invalidates the timer it replaces, so a timer + // that is no longer the current one has already been superseded. + guard timer === holdTimer else { return } + holdTimer = nil + lastArmed = nil + UIApplication.shared.isIdleTimerDisabled = false + } + + /// Going to the background: drop the assertion and the pending timer but + /// keep `duration`, so the setting is intact when the app comes back and + /// `didBecomeActive` arms a fresh window. + private func suspendHold() { + holdTimer?.invalidate() + holdTimer = nil + lastArmed = nil + UIApplication.shared.isIdleTimerDisabled = false + } + + // MARK: - Persistence + + private static func persistedDuration() -> AlwaysOnDisplayDuration { + let defaults = UserDefaults.standard + if let raw = defaults.object(forKey: storageKey) as? Int { + return AlwaysOnDisplayDuration(rawValue: raw) + } + return defaults.bool(forKey: legacyToggleKey) ? .always : .off + } + + /// Fold a pre-slider "on" switch into the duration setting. Only ever runs + /// where protected data is available, so an unreadable `UserDefaults` on a + /// locked launch cannot mistake a persisted setting for "never configured" + /// and write the default over it. + private static func migrateLegacyToggleIfNeeded() { + let defaults = UserDefaults.standard + guard defaults.object(forKey: legacyToggleKey) != nil else { return } + if defaults.object(forKey: storageKey) == nil, defaults.bool(forKey: legacyToggleKey) { + defaults.set(AlwaysOnDisplayDuration.always.rawValue, forKey: storageKey) + } + defaults.removeObject(forKey: legacyToggleKey) + } + + // MARK: - Interaction observation + + /// Install or remove the whole interaction-watching apparatus. Idempotent. + private func setInteractionTracking(_ enabled: Bool) { + guard enabled != isTrackingInteraction else { return } + isTrackingInteraction = enabled + if enabled { + startInteractionTracking() + } else { + stopInteractionTracking() + } + } + + private func startInteractionTracking() { + // New scenes/windows (iPadOS multi-window, Stage Manager) need their own + // touch observer; the app's windows are usually already up here. This + // also catches the keyboard-hosting windows that never appear in + // `UIWindowScene.windows`, so taps on the software keyboard count too. + interactionObservers.append( + NotificationCenter.default.addObserver( + forName: UIWindow.didBecomeVisibleNotification, + object: nil, + queue: .main + ) { notification in + // Delivered on the main queue, so the actor hop a `Task` would + // add is pure overhead — and the text observers below run per + // keystroke. + MainActor.assumeIsolated { + guard let window = notification.object as? UIWindow else { return } + AlwaysOnDisplayManager.shared.attachInteractionObserver(to: window) + } + } + ) + // Typing is not a touch. Hardware keys go to the responder chain, so + // text edited anywhere in the app — Quick Connect, search fields, + // dialogs — counts as interaction. (The terminal is not a UITextField; + // it calls `noteAlwaysOnDisplayInteraction()` from `pressesBegan`.) + for name in [UITextField.textDidChangeNotification, UITextView.textDidChangeNotification] { + interactionObservers.append( + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { _ in + MainActor.assumeIsolated { + AlwaysOnDisplayManager.shared.noteUserInteraction() + } + } + ) + } + attachInteractionObservers() + } + + private func stopInteractionTracking() { + for observer in interactionObservers { + NotificationCenter.default.removeObserver(observer) + } + interactionObservers.removeAll() + // Windows outlive a change to the setting, so their observer gesture + // has to come off explicitly. The table holds gestures weakly, so + // windows that are already gone are simply no longer in it. + for gesture in interactionGestures.allObjects { + gesture.view?.removeGestureRecognizer(gesture) + } + interactionGestures.removeAllObjects() + } + + private func attachInteractionObservers() { + for scene in UIApplication.shared.connectedScenes { + guard let windowScene = scene as? UIWindowScene else { continue } + for window in windowScene.windows { + attachInteractionObserver(to: window) + } + } + } + + fileprivate func attachInteractionObserver(to window: UIWindow) { + guard isTrackingInteraction else { return } + let alreadyAttached = window.gestureRecognizers?.contains { $0 is InteractionObserverGesture } ?? false + guard !alreadyAttached else { return } + let gesture = InteractionObserverGesture() + window.addGestureRecognizer(gesture) + interactionGestures.add(gesture) + } +} + +/// Window-level touch observer: fails immediately on the first touch, so it +/// sees every touch-down without ever claiming a gesture, delaying delivery or +/// cancelling touches in the view below. +private final class InteractionObserverGesture: UIGestureRecognizer { + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + cancelsTouchesInView = false + delaysTouchesBegan = false + delaysTouchesEnded = false + } + + convenience init() { + self.init(target: nil, action: nil) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + state = .failed + AlwaysOnDisplayManager.shared.noteUserInteraction() } } @@ -111,6 +434,14 @@ extension View { } } +/// Restarts the always-on-display awake window from input paths the window +/// touch observer cannot see — hardware key presses reach the responder chain, +/// not `touchesBegan`. No-op on platforms without an idle timer. +@MainActor +func noteAlwaysOnDisplayInteraction() { + AlwaysOnDisplayManager.shared.noteUserInteraction() +} + #else // Mac Catalyst / visionOS: no meaningful idle timer to control. @@ -120,4 +451,7 @@ extension View { } } +@MainActor +func noteAlwaysOnDisplayInteraction() {} + #endif