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...
pull/7101/head
Mitchell Hashimoto 2025-04-15 09:47:52 -07:00
parent 392aab2e4a
commit cc690eddb5
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
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.