diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 028d4506c..0624d28cd 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import Combine import UserNotifications import OSLog import Sparkle @@ -151,6 +152,21 @@ class AppDelegate: NSObject, /// Signals private var signals: [DispatchSourceSignal] = [] + /// Cancellables used for app-level bell badge tracking. + private var bellTrackingCancellables: Set = [] + + /// Per-window bell observation cancellables keyed by controller identity. + private var windowBellCancellables: [ObjectIdentifier: AnyCancellable] = [:] + + /// Current bell state keyed by terminal controller identity. + private var windowBellStates: [ObjectIdentifier: Bool] = [:] + + /// Cached permission state for dock badges. + private var canShowDockBadgeForBell: Bool = false + + /// Prevent repeated badge permission prompts. + private var hasRequestedDockBadgeAuthorization: Bool = false + /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? @@ -254,6 +270,9 @@ class AppDelegate: NSObject, name: Ghostty.Notification.ghosttyNewTab, object: nil) + // Track per-window bell state and keep the dock badge in sync. + setupBellBadgeTracking() + // Configure user notifications let actions = [ UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show") @@ -327,8 +346,8 @@ class AppDelegate: NSObject, // If we're back manually then clear the hidden state because macOS handles it. self.hiddenState = nil - // Clear the dock badge when the app becomes active - self.setDockBadge(nil) + // Recompute the dock badge based on active terminal bell state. + syncDockBadgeToTrackedBellState() // First launch stuff if !applicationHasBecomeActive { @@ -783,41 +802,105 @@ class AppDelegate: NSObject, } } + /// Sets up observation for all terminal window controllers and aggregates whether any + /// associated surface has an active bell. + private func setupBellBadgeTracking() { + let center = NotificationCenter.default + Publishers.MergeMany( + center.publisher(for: NSWindow.didBecomeMainNotification).map { _ in () }, + center.publisher(for: NSWindow.willCloseNotification).map { _ in () } + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshTrackedTerminalWindows() + } + .store(in: &bellTrackingCancellables) + + refreshTrackedTerminalWindows() + ghosttyUpdateBadgeForBell() + } + + private func refreshTrackedTerminalWindows() { + let controllers = NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } + let controllersByID = Dictionary(uniqueKeysWithValues: controllers.map { (ObjectIdentifier($0), $0) }) + let trackedIDs = Set(windowBellCancellables.keys) + let currentIDs = Set(controllersByID.keys) + + for id in trackedIDs.subtracting(currentIDs) { + windowBellCancellables[id]?.cancel() + windowBellCancellables[id] = nil + windowBellStates[id] = nil + } + + for (id, controller) in controllersByID where windowBellCancellables[id] == nil { + windowBellCancellables[id] = makeWindowBellCancellable(controller: controller, id: id) + } + + syncDockBadgeToTrackedBellState() + } + + private func makeWindowBellCancellable( + controller: BaseTerminalController, + id: ObjectIdentifier + ) -> AnyCancellable { + controller.surfaceValuesPublisher(valueKeyPath: \.bell, publisherKeyPath: \.$bell) + .map { $0.values.contains(true) } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] hasBell in + self?.windowBellStates[id] = hasBell + self?.syncDockBadgeToTrackedBellState() + } + } + + private func syncDockBadgeToTrackedBellState() { + let anyBell = windowBellStates.values.contains(true) + let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && anyBell + + if wantsBadge && !canShowDockBadgeForBell && !hasRequestedDockBadgeAuthorization { + ghosttyUpdateBadgeForBell() + } + + setDockBadge(wantsBadge && canShowDockBadgeForBell ? "•" : nil) + } + private func ghosttyUpdateBadgeForBell() { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in - switch settings.authorizationStatus { - case .authorized: - // Already authorized, check badge setting and set if enabled - if settings.badgeSetting == .enabled { - DispatchQueue.main.async { - self.setDockBadge() - } - } + DispatchQueue.main.async { + switch settings.authorizationStatus { + case .authorized: + // Already authorized, check badge setting and set if enabled + self.canShowDockBadgeForBell = settings.badgeSetting == .enabled + self.syncDockBadgeToTrackedBellState() - case .notDetermined: - // Not determined yet, request authorization for badge - center.requestAuthorization(options: [.badge]) { granted, error in - if let error = error { - Self.logger.warning("Error requesting badge authorization: \(error)") - return - } + case .notDetermined: + guard !self.hasRequestedDockBadgeAuthorization else { return } + self.hasRequestedDockBadgeAuthorization = true + + // Not determined yet, request authorization for badge + center.requestAuthorization(options: [.badge]) { granted, error in + if let error = error { + Self.logger.warning("Error requesting badge authorization: \(error)") + return + } - if granted { - // Permission granted, set the badge DispatchQueue.main.async { - self.setDockBadge() + self.canShowDockBadgeForBell = granted + self.syncDockBadgeToTrackedBellState() } } + + case .denied, .provisional, .ephemeral: + // In these known non-authorized states, do not attempt to set the badge. + self.canShowDockBadgeForBell = false + self.syncDockBadgeToTrackedBellState() + + @unknown default: + // Handle future unknown states by doing nothing. + self.canShowDockBadgeForBell = false + self.syncDockBadgeToTrackedBellState() } - - case .denied, .provisional, .ephemeral: - // In these known non-authorized states, do not attempt to set the badge. - break - - @unknown default: - // Handle future unknown states by doing nothing. - break } } } @@ -886,6 +969,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) TerminalController.all.forEach { $0.relabelTabs() } + syncDockBadgeToTrackedBellState() // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 86932b1bb..30caae0da 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,4 +1,5 @@ import AppKit +import Combine /// SplitTree represents a tree of views that can be divided. struct SplitTree { @@ -1215,6 +1216,57 @@ extension SplitTree: Collection { } } +// MARK: SplitTree Combine + +extension SplitTree { + /// Builds a publisher that emits current values for all leaf views keyed by view ID. + /// + /// The returned publisher emits a full `[ViewType.ID: Value]` snapshot whenever any leaf view + /// publishes through the provided publisher key path. + func valuesPublisher( + valueKeyPath: KeyPath, + publisherKeyPath: KeyPath.Publisher> + ) -> AnyPublisher<[ViewType.ID: Value], Never> { + // Flatten the split tree into a list of current leaf views. + let views = map { $0 } + guard !views.isEmpty else { + // If there are no leaves, immediately publish an empty snapshot. + // `Just([:])` keeps the return type simple and makes downstream usage easy. + return Just([:]).eraseToAnyPublisher() + } + + // Capture each view's current value up front. + // We key by `ViewType.ID` so updates can replace the correct entry later. + // This avoids waiting for all views to emit before consumers see data. + let initial = Dictionary(uniqueKeysWithValues: views.map { view in + (view.id, view[keyPath: valueKeyPath]) + }) + + // Build one publisher per view from the requested key path. + // Each emission is mapped into `(id, value)` so we know which entry changed. + // `MergeMany` combines all per-view streams into a single update stream. + let updates = Publishers.MergeMany(views.map { view in + view[keyPath: publisherKeyPath] + .map { (view.id, $0) } + .eraseToAnyPublisher() + }) + + return updates + // Accumulate updates into a full "latest value per ID" dictionary. + // This turns incremental events into complete state snapshots. + .scan(initial) { state, update in + var state = state + state[update.0] = update.1 + return state + } + // Emit the initial snapshot first so subscribers always get a + // complete value dictionary immediately upon subscription. + .prepend(initial) + // Hide implementation details and expose a stable API type. + .eraseToAnyPublisher() + } +} + // MARK: Structural Identity extension SplitTree.Node { diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9f65d35ce..1d5db199c 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -222,6 +222,30 @@ class BaseTerminalController: NSWindowController, // MARK: Methods + /// Creates a publisher for values on all surfaces in this controller's tree. + /// + /// The publisher emits a dictionary of surface IDs to values whenever the tree changes + /// or any surface publishes a new value for the key path. + func surfaceValuesPublisher( + valueKeyPath: KeyPath, + publisherKeyPath: KeyPath.Publisher> + ) -> AnyPublisher<[Ghostty.SurfaceView.ID: Value], Never> { + // `surfaceTree` can be replaced entirely when splits are added/removed/closed. + // For each tree snapshot we build a fresh publisher that watches all surfaces + // in that snapshot. + $surfaceTree + .map { tree in + tree.valuesPublisher( + valueKeyPath: valueKeyPath, + publisherKeyPath: publisherKeyPath + ) + } + // Keep only the latest tree publisher active. This automatically cancels + // subscriptions for old/removed surfaces when the tree changes. + .switchToLatest() + .eraseToAnyPublisher() + } + /// Create a new split. @discardableResult func newSplit(