macOS: Implement basic bell features (no sound) (#7101)

Fixes #7099

This adds basic bell features to macOS to conceptually match the GTK
implementation. When a bell is triggered, macOS will do the following:

  1. Bounce the dock icon once, if the app isn't already in focus.
2. Add a bell emoji (🔔) to the title of the surface that triggered the
bell. This emoji will be removed after the surface is focused or a
keyboard event if the surface is already focused. This behavior matches
iTerm2.

Note that neither of these respect the `system` `bell-features` config
because they're both unobtrusive (the dock icon bounces only once, the
title change is silent and similar to GTK tab attention) and unrelated
to system settings.

This doesn't add an icon badge because macOS's dockTitle.badgeLabel API
wasn't doing anything for me and I wasn't able to fully figure out
why...
This commit is contained in:
Mitchell Hashimoto
2025-04-15 13:24:20 -07:00
committed by GitHub
8 changed files with 95 additions and 3 deletions

View File

@ -186,6 +186,12 @@ class AppDelegate: NSObject,
name: .ghosttyConfigDidChange, name: .ghosttyConfigDidChange,
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: nil
)
// Configure user notifications // Configure user notifications
let actions = [ let actions = [
@ -502,6 +508,11 @@ class AppDelegate: NSObject,
ghosttyConfigDidChange(config: config) ghosttyConfigDidChange(config: config)
} }
@objc private func ghosttyBellDidRing(_ notification: Notification) {
// Bounce the dock icon if we're not focused.
NSApp.requestUserAttention(.informationalRequest)
}
private func ghosttyConfigDidChange(config: Ghostty.Config) { private func ghosttyConfigDidChange(config: Ghostty.Config) {
// Update the config we need to store // Update the config we need to store
self.derivedConfig = DerivedConfig(config) self.derivedConfig = DerivedConfig(config)

View File

@ -538,6 +538,9 @@ extension Ghostty {
case GHOSTTY_ACTION_COLOR_CHANGE: case GHOSTTY_ACTION_COLOR_CHANGE:
colorChange(app, target: target, change: action.action.color_change) colorChange(app, target: target, change: action.action.color_change)
case GHOSTTY_ACTION_RING_BELL:
ringBell(app, target: target)
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
@ -747,6 +750,30 @@ extension Ghostty {
appDelegate.toggleVisibility(self) appDelegate.toggleVisibility(self)
} }
private static func ringBell(
_ app: ghostty_app_t,
target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
// Technically we could still request app attention here but there
// are no known cases where the bell is rang with an app target so
// I think its better to warn.
Ghostty.logger.warning("ring bell does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyBellDidRing,
object: surfaceView
)
default:
assertionFailure()
}
}
private static func moveTab( private static func moveTab(
_ app: ghostty_app_t, _ app: ghostty_app_t,
target: ghostty_target_s, target: ghostty_target_s,

View File

@ -116,6 +116,14 @@ extension Ghostty {
/// details on what each means. We only add documentation if there is a strange conversion /// details on what each means. We only add documentation if there is a strange conversion
/// due to the embedded library and Swift. /// due to the embedded library and Swift.
var bellFeatures: BellFeatures {
guard let config = self.config else { return .init() }
var v: CUnsignedInt = 0
let key = "bell-features"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() }
return .init(rawValue: v)
}
var initialWindow: Bool { var initialWindow: Bool {
guard let config = self.config else { return true } guard let config = self.config else { return true }
var v = true; var v = true;
@ -543,6 +551,12 @@ extension Ghostty.Config {
case download case download
} }
struct BellFeatures: OptionSet {
let rawValue: CUnsignedInt
static let system = BellFeatures(rawValue: 1 << 0)
}
enum MacHidden : String { enum MacHidden : String {
case never case never
case always case always

View File

@ -253,6 +253,9 @@ extension Notification.Name {
/// Resize the window to a default size. /// Resize the window to a default size.
static let ghosttyResetWindowSize = Notification.Name("com.mitchellh.ghostty.resetWindowSize") static let ghosttyResetWindowSize = Notification.Name("com.mitchellh.ghostty.resetWindowSize")
/// Ring the bell
static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing")
} }
// NOTE: I am moving all of these to Notification.Name extensions over time. This // NOTE: I am moving all of these to Notification.Name extensions over time. This

View File

@ -59,6 +59,15 @@ extension Ghostty {
@EnvironmentObject private var ghostty: Ghostty.App @EnvironmentObject private var ghostty: Ghostty.App
var title: String {
var result = surfaceView.title
if (surfaceView.bell) {
result = "🔔 \(result)"
}
return result
}
var body: some View { var body: some View {
let center = NotificationCenter.default let center = NotificationCenter.default
@ -74,7 +83,7 @@ extension Ghostty {
Surface(view: surfaceView, size: geo.size) Surface(view: surfaceView, size: geo.size)
.focused($surfaceFocus) .focused($surfaceFocus)
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title) .focusedValue(\.ghosttySurfaceTitle, title)
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
.focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceView, surfaceView)
.focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize)

View File

@ -63,6 +63,9 @@ extension Ghostty {
/// dynamically updated. Otherwise, the background color is the default background color. /// dynamically updated. Otherwise, the background color is the default background color.
@Published private(set) var backgroundColor: Color? = nil @Published private(set) var backgroundColor: Color? = nil
/// True when the bell is active. This is set inactive on focus or event.
@Published private(set) var bell: Bool = false
// An initial size to request for a window. This will only affect // An initial size to request for a window. This will only affect
// then the view is moved to a new window. // then the view is moved to a new window.
var initialSize: NSSize? = nil var initialSize: NSSize? = nil
@ -190,6 +193,11 @@ extension Ghostty {
selector: #selector(ghosttyColorDidChange(_:)), selector: #selector(ghosttyColorDidChange(_:)),
name: .ghosttyColorDidChange, name: .ghosttyColorDidChange,
object: self) object: self)
center.addObserver(
self,
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: self)
center.addObserver( center.addObserver(
self, self,
selector: #selector(windowDidChangeScreen), selector: #selector(windowDidChangeScreen),
@ -300,9 +308,12 @@ extension Ghostty {
SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused) SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused)
} }
// On macOS 13+ we can store our continuous clock...
if (focused) { if (focused) {
// On macOS 13+ we can store our continuous clock...
focusInstant = ContinuousClock.now focusInstant = ContinuousClock.now
// We unset our bell state if we gained focus
bell = false
} }
} }
@ -556,6 +567,11 @@ extension Ghostty {
} }
} }
@objc private func ghosttyBellDidRing(_ notification: SwiftUI.Notification) {
// Bell state goes to true
bell = true
}
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return } guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return } guard let object = notification.object as? NSWindow, window == object else { return }
@ -855,6 +871,9 @@ extension Ghostty {
return return
} }
// On any keyDown event we unset our bell state
bell = false
// We need to translate the mods (maybe) to handle configs such as option-as-alt // We need to translate the mods (maybe) to handle configs such as option-as-alt
let translationModsGhostty = Ghostty.eventModifierFlags( let translationModsGhostty = Ghostty.eventModifierFlags(
mods: ghostty_surface_key_translation_mods( mods: ghostty_surface_key_translation_mods(

View File

@ -35,6 +35,9 @@ extension Ghostty {
// on supported platforms. // on supported platforms.
@Published var focusInstant: ContinuousClock.Instant? = nil @Published var focusInstant: ContinuousClock.Instant? = nil
/// True when the bell is active. This is set inactive on focus or event.
@Published var bell: Bool = false
// Returns sizing information for the surface. This is the raw C // Returns sizing information for the surface. This is the raw C
// structure because I'm lazy. // structure because I'm lazy.
var surfaceSize: ghostty_surface_size_s? { var surfaceSize: ghostty_surface_size_s? {

View File

@ -1874,7 +1874,13 @@ keybind: Keybinds = .{},
/// for instance under the "Sound > Alert Sound" setting in GNOME, /// for instance under the "Sound > Alert Sound" setting in GNOME,
/// or the "Accessibility > System Bell" settings in KDE Plasma. /// or the "Accessibility > System Bell" settings in KDE Plasma.
/// ///
/// Currently only implemented on Linux. /// On macOS this has no affect.
///
/// On macOS, if the app is unfocused, it will bounce the app icon in the dock
/// once. Additionally, the title of the window with the alerted terminal
/// surface will contain a bell emoji (🔔) until the terminal is focused
/// or a key is pressed. These are not currently configurable since they're
/// considered unobtrusive.
@"bell-features": BellFeatures = .{}, @"bell-features": BellFeatures = .{},
/// Control the in-app notifications that Ghostty shows. /// Control the in-app notifications that Ghostty shows.