mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-08 04:44:27 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user