macOS: Implement basic bell features (no sound)

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.

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 09:47:52 -07:00
parent 392aab2e4a
commit cc690eddb5
8 changed files with 95 additions and 3 deletions

View File

@@ -186,6 +186,12 @@ class AppDelegate: NSObject,
name: .ghosttyConfigDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: nil
)
// Configure user notifications
let actions = [
@@ -502,6 +508,11 @@ class AppDelegate: NSObject,
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) {
// Update the config we need to store
self.derivedConfig = DerivedConfig(config)

View File

@@ -538,6 +538,9 @@ extension Ghostty {
case GHOSTTY_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:
fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
@@ -747,6 +750,30 @@ extension Ghostty {
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(
_ app: ghostty_app_t,
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
/// 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 {
guard let config = self.config else { return true }
var v = true;
@@ -543,6 +551,12 @@ extension Ghostty.Config {
case download
}
struct BellFeatures: OptionSet {
let rawValue: CUnsignedInt
static let system = BellFeatures(rawValue: 1 << 0)
}
enum MacHidden : String {
case never
case always

View File

@@ -253,6 +253,9 @@ extension Notification.Name {
/// Resize the window to a default size.
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

View File

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

View File

@@ -63,6 +63,9 @@ extension Ghostty {
/// dynamically updated. Otherwise, the background color is the default background color.
@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
// then the view is moved to a new window.
var initialSize: NSSize? = nil
@@ -190,6 +193,11 @@ extension Ghostty {
selector: #selector(ghosttyColorDidChange(_:)),
name: .ghosttyColorDidChange,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: self)
center.addObserver(
self,
selector: #selector(windowDidChangeScreen),
@@ -300,9 +308,12 @@ extension Ghostty {
SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused)
}
// On macOS 13+ we can store our continuous clock...
if (focused) {
// On macOS 13+ we can store our continuous clock...
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) {
guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return }
@@ -855,6 +871,9 @@ extension Ghostty {
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
let translationModsGhostty = Ghostty.eventModifierFlags(
mods: ghostty_surface_key_translation_mods(

View File

@@ -35,6 +35,9 @@ extension Ghostty {
// on supported platforms.
@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
// structure because I'm lazy.
var surfaceSize: ghostty_surface_size_s? {

View File

@@ -1874,7 +1874,13 @@ keybind: Keybinds = .{},
/// for instance under the "Sound > Alert Sound" setting in GNOME,
/// 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 = .{},
/// Control the in-app notifications that Ghostty shows.