macos: try to clean up Appdelegate combine mess

This commit is contained in:
Mitchell Hashimoto
2026-02-26 09:46:11 -08:00
parent ea8bf17df8
commit 79ca4daea6
2 changed files with 82 additions and 81 deletions

View File

@@ -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<AnyCancellable> = []
/// 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

View File

@@ -83,6 +83,9 @@ class BaseTerminalController: NSWindowController,
/// The cancellables related to our focused surface.
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
/// 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<Value>(
valueKeyPath: KeyPath<Ghostty.SurfaceView, Value>,
publisherKeyPath: KeyPath<Ghostty.SurfaceView, Published<Value>.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<Value>(
valueKeyPath: KeyPath<Ghostty.SurfaceView, Value>,
publisherKeyPath: KeyPath<Ghostty.SurfaceView, Published<Value>.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"
}