macOS: Clear badge icon when no surfaces have an active bell (#11035)

Fixes #8487

I did this by setting up a publisher on `BaseTerminalController` for any
bell state change on any surfaces in the tree (including removing
surfaces). By listening to this event at AppDelegate and reinspecting
all our windows, we can reliably set the badge.

**This also includes a change to show the number of terminals with an
active bell!** We can now determine the number, so we show it!
This commit is contained in:
Mitchell Hashimoto
2026-02-26 10:32:26 -08:00
committed by GitHub
6 changed files with 157 additions and 17 deletions

View File

@@ -18,13 +18,6 @@ A file for [guiding coding agents](https://agents.md/).
- macOS app: `macos/`
- GTK (Linux and FreeBSD) app: `src/apprt/gtk`
## macOS App
- Do not use `xcodebuild`
- Use `zig build` to build the macOS app and any shared Zig code
- Use `zig build run` to build and run the macOS app
- Run Xcode tests using `zig build test`
## Issue and PR Guidelines
- Never create an issue.

View File

@@ -1,3 +1,9 @@
# macOS Ghostty Application
- Use `swiftlint` for formatting and linting Swift code.
- If code outside of this directory is modified, use
`zig build -Demit-macos-app=false` before building the macOS app to update
the underlying Ghostty library.
- Use `xcodebuild` to build the macOS app, do not use `zig build`
(except to build the underlying library as mentioned above).
- Run unit tests directly with `xcodebuild`

View File

@@ -243,6 +243,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(_:)),
@@ -327,9 +333,6 @@ 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)
// First launch stuff
if !applicationHasBecomeActive {
applicationHasBecomeActive = true
@@ -777,18 +780,20 @@ class AppDelegate: NSObject,
if ghostty.config.bellFeatures.contains(.attention) {
// Bounce the dock icon if we're not focused.
NSApp.requestUserAttention(.informationalRequest)
// Handle setting the dock badge based on permissions
ghosttyUpdateBadgeForBell()
}
}
private func ghosttyUpdateBadgeForBell() {
@objc private func terminalWindowHasBell(_ notification: Notification) {
guard notification.object is BaseTerminalController else { return }
syncDockBadge()
}
private func syncDockBadge() {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized:
// Already authorized, check badge setting and set if enabled
// If we're authorized and allow badges, then set the badge.
if settings.badgeSetting == .enabled {
DispatchQueue.main.async {
self.setDockBadge()
@@ -842,7 +847,12 @@ class AppDelegate: NSObject,
_ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config)
}
private func setDockBadge(_ label: String? = "") {
private func setDockBadge() {
let bellCount = NSApp.windows
.compactMap { $0.windowController as? BaseTerminalController }
.reduce(0) { $0 + ($1.bell ? 1 : 0) }
let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && bellCount > 0
let label = wantsBadge ? (bellCount > 99 ? "99+" : String(bellCount)) : nil
NSApp.dockTile.badgeLabel = label
NSApp.dockTile.display()
}
@@ -887,6 +897,9 @@ class AppDelegate: NSObject,
syncMenuShortcuts(config)
TerminalController.all.forEach { $0.relabelTabs() }
// Update our badge since config can change what we show.
syncDockBadge()
// 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
// AppKit mutex on the appearance.

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

@@ -51,6 +51,9 @@ class BaseTerminalController: NSWindowController,
/// Set if the terminal view should show the update overlay.
@Published var updateOverlayIsVisible: Bool = false
/// True when any surface in this controller currently has an active bell.
@Published private(set) var bell: Bool = false
/// Whether the terminal surface should focus when the mouse is over it.
var focusFollowsMouse: Bool {
self.derivedConfig.focusFollowsMouse
@@ -83,6 +86,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? {
@@ -135,6 +141,9 @@ class BaseTerminalController: NSWindowController,
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
center.addObserver(
@@ -1200,6 +1209,17 @@ class BaseTerminalController: NSWindowController,
func windowWillClose(_ notification: Notification) {
guard let window else { return }
// Emit a final bell-state transition so any observers can clear state
// without separately tracking NSWindow lifecycle events.
if bell {
bell = false
NotificationCenter.default.post(
name: .terminalWindowBellDidChangeNotification,
object: self,
userInfo: [Notification.Name.terminalWindowHasBellKey: false]
)
}
// I don't know if this is required anymore. We previously had a ref cycle between
// the view and the window so we had to nil this out to break it but I think this
// may now be resolved. We should verify that no memory leaks and we can remove this.
@@ -1470,3 +1490,57 @@ 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()
.receive(on: DispatchQueue.main)
.sink { [weak self] hasBell in
guard let self else { return }
bell = hasBell
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"
}

View File

@@ -477,7 +477,9 @@ pub fn add(
.freetype = true,
.@"backend-metal" = target.result.os.tag.isDarwin(),
.@"backend-osx" = target.result.os.tag == .macos,
.@"backend-opengl3" = target.result.os.tag != .macos,
// OpenGL3 backend should only be built on non-Apple targets.
// Apple platforms use Metal (and macOS may also use the OSX backend).
.@"backend-opengl3" = !target.result.os.tag.isDarwin(),
})) |dep| {
step.root_module.addImport("dcimgui", dep.module("dcimgui"));
step.linkLibrary(dep.artifact("dcimgui"));