mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
macos: use combine to coalesce bell values
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user