diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 0624d28cd..0e6a8dd2a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,6 +1,5 @@ import AppKit import SwiftUI -import Combine import UserNotifications import OSLog import Sparkle @@ -152,12 +151,6 @@ 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] = [:] @@ -241,6 +234,12 @@ class AppDelegate: NSObject, name: NSWindow.didBecomeKeyNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(windowWillClose), + name: NSWindow.willCloseNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(quickTerminalDidChangeVisibility), @@ -259,6 +258,12 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(terminalWindowHasBell(_:)), + name: .terminalWindowBellDidChangeNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(ghosttyNewWindow(_:)), @@ -270,9 +275,6 @@ 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") @@ -771,6 +773,14 @@ class AppDelegate: NSObject, syncFloatOnTopMenu(notification.object as? NSWindow) } + @objc private func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let controller = window.windowController as? BaseTerminalController else { return } + + windowBellStates[ObjectIdentifier(controller)] = nil + syncDockBadgeToTrackedBellState() + } + @objc private func quickTerminalDidChangeVisibility(_ notification: Notification) { guard let quickController = notification.object as? QuickTerminalController else { return } self.menuQuickTerminal?.state = if quickController.visible { .on } else { .off } @@ -802,57 +812,14 @@ 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) - } + @objc private func terminalWindowHasBell(_ notification: Notification) { + guard let controller = notification.object as? BaseTerminalController, + let hasBell = notification.userInfo?[Notification.Name.terminalWindowHasBellKey] as? Bool else { return } + windowBellStates[ObjectIdentifier(controller)] = hasBell 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 diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1d5db199c..ebaf5fd23 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -83,6 +83,9 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// Cancellable for aggregating bell state across all surfaces in this controller. + private var bellStateCancellable: AnyCancellable? + /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. var titleOverride: String? { @@ -134,6 +137,9 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) + + // Setup our bell state for the window + setupBellNotificationPublisher() // Setup our notifications for behaviors let center = NotificationCenter.default @@ -222,30 +228,6 @@ 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( @@ -1494,3 +1476,55 @@ extension BaseTerminalController: NSMenuItemValidation { appliedColorScheme = scheme } } + +// MARK: Combine Methods + +extension BaseTerminalController { + /// Publishes an app-wide notification whenever this terminal window's aggregate + /// bell state changes. + private func setupBellNotificationPublisher() { + bellStateCancellable = surfaceValuesPublisher(valueKeyPath: \.bell, publisherKeyPath: \.$bell) + .map { $0.values.contains(true) } + .removeDuplicates() + .sink { [weak self] hasBell in + guard let self else { return } + NotificationCenter.default.post( + name: .terminalWindowBellDidChangeNotification, + object: self, + userInfo: [Notification.Name.terminalWindowHasBellKey: hasBell] + ) + } + } + + /// 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() + } +} + +// MARK: Notifications + +extension Notification.Name { + /// Terminal window aggregate bell state changed. + static let terminalWindowBellDidChangeNotification = Notification.Name("com.mitchellh.ghostty.terminalWindowBellDidChange") + static let terminalWindowHasBellKey = terminalWindowBellDidChangeNotification.rawValue + ".hasBell" +}