macos: use combine to coalesce bell values

This commit is contained in:
Mitchell Hashimoto
2026-02-26 09:16:03 -08:00
parent d05fb652ed
commit ea8bf17df8
3 changed files with 188 additions and 28 deletions

View File

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

View File

@@ -1,4 +1,5 @@
import AppKit
import Combine
/// SplitTree represents a tree of views that can be divided.
struct SplitTree<ViewType: NSView & Codable & Identifiable> {
@@ -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<Value>(
valueKeyPath: KeyPath<ViewType, Value>,
publisherKeyPath: KeyPath<ViewType, Published<Value>.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 {

View File

@@ -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<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(