Skip to content

Commit 2bcd76c

Browse files
authored
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...
2 parents 392aab2 + cc690ed commit 2bcd76c

File tree

8 files changed

+95
-3
lines changed

8 files changed

+95
-3
lines changed

Diff for: macos/Sources/App/macOS/AppDelegate.swift

+11
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ class AppDelegate: NSObject,
186186
name: .ghosttyConfigDidChange,
187187
object: nil
188188
)
189+
NotificationCenter.default.addObserver(
190+
self,
191+
selector: #selector(ghosttyBellDidRing(_:)),
192+
name: .ghosttyBellDidRing,
193+
object: nil
194+
)
189195

190196
// Configure user notifications
191197
let actions = [
@@ -502,6 +508,11 @@ class AppDelegate: NSObject,
502508
ghosttyConfigDidChange(config: config)
503509
}
504510

511+
@objc private func ghosttyBellDidRing(_ notification: Notification) {
512+
// Bounce the dock icon if we're not focused.
513+
NSApp.requestUserAttention(.informationalRequest)
514+
}
515+
505516
private func ghosttyConfigDidChange(config: Ghostty.Config) {
506517
// Update the config we need to store
507518
self.derivedConfig = DerivedConfig(config)

Diff for: macos/Sources/Ghostty/Ghostty.App.swift

+27
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,9 @@ extension Ghostty {
538538
case GHOSTTY_ACTION_COLOR_CHANGE:
539539
colorChange(app, target: target, change: action.action.color_change)
540540

541+
case GHOSTTY_ACTION_RING_BELL:
542+
ringBell(app, target: target)
543+
541544
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
542545
fallthrough
543546
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
@@ -747,6 +750,30 @@ extension Ghostty {
747750
appDelegate.toggleVisibility(self)
748751
}
749752

753+
private static func ringBell(
754+
_ app: ghostty_app_t,
755+
target: ghostty_target_s) {
756+
switch (target.tag) {
757+
case GHOSTTY_TARGET_APP:
758+
// Technically we could still request app attention here but there
759+
// are no known cases where the bell is rang with an app target so
760+
// I think its better to warn.
761+
Ghostty.logger.warning("ring bell does nothing with an app target")
762+
return
763+
764+
case GHOSTTY_TARGET_SURFACE:
765+
guard let surface = target.target.surface else { return }
766+
guard let surfaceView = self.surfaceView(from: surface) else { return }
767+
NotificationCenter.default.post(
768+
name: .ghosttyBellDidRing,
769+
object: surfaceView
770+
)
771+
772+
default:
773+
assertionFailure()
774+
}
775+
}
776+
750777
private static func moveTab(
751778
_ app: ghostty_app_t,
752779
target: ghostty_target_s,

Diff for: macos/Sources/Ghostty/Ghostty.Config.swift

+14
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ extension Ghostty {
116116
/// details on what each means. We only add documentation if there is a strange conversion
117117
/// due to the embedded library and Swift.
118118

119+
var bellFeatures: BellFeatures {
120+
guard let config = self.config else { return .init() }
121+
var v: CUnsignedInt = 0
122+
let key = "bell-features"
123+
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() }
124+
return .init(rawValue: v)
125+
}
126+
119127
var initialWindow: Bool {
120128
guard let config = self.config else { return true }
121129
var v = true;
@@ -543,6 +551,12 @@ extension Ghostty.Config {
543551
case download
544552
}
545553

554+
struct BellFeatures: OptionSet {
555+
let rawValue: CUnsignedInt
556+
557+
static let system = BellFeatures(rawValue: 1 << 0)
558+
}
559+
546560
enum MacHidden : String {
547561
case never
548562
case always

Diff for: macos/Sources/Ghostty/Package.swift

+3
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ extension Notification.Name {
253253

254254
/// Resize the window to a default size.
255255
static let ghosttyResetWindowSize = Notification.Name("com.mitchellh.ghostty.resetWindowSize")
256+
257+
/// Ring the bell
258+
static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing")
256259
}
257260

258261
// NOTE: I am moving all of these to Notification.Name extensions over time. This

Diff for: macos/Sources/Ghostty/SurfaceView.swift

+10-1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ extension Ghostty {
5959

6060
@EnvironmentObject private var ghostty: Ghostty.App
6161

62+
var title: String {
63+
var result = surfaceView.title
64+
if (surfaceView.bell) {
65+
result = "🔔 \(result)"
66+
}
67+
68+
return result
69+
}
70+
6271
var body: some View {
6372
let center = NotificationCenter.default
6473

@@ -74,7 +83,7 @@ extension Ghostty {
7483

7584
Surface(view: surfaceView, size: geo.size)
7685
.focused($surfaceFocus)
77-
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title)
86+
.focusedValue(\.ghosttySurfaceTitle, title)
7887
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
7988
.focusedValue(\.ghosttySurfaceView, surfaceView)
8089
.focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize)

Diff for: macos/Sources/Ghostty/SurfaceView_AppKit.swift

+20-1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ extension Ghostty {
6363
/// dynamically updated. Otherwise, the background color is the default background color.
6464
@Published private(set) var backgroundColor: Color? = nil
6565

66+
/// True when the bell is active. This is set inactive on focus or event.
67+
@Published private(set) var bell: Bool = false
68+
6669
// An initial size to request for a window. This will only affect
6770
// then the view is moved to a new window.
6871
var initialSize: NSSize? = nil
@@ -190,6 +193,11 @@ extension Ghostty {
190193
selector: #selector(ghosttyColorDidChange(_:)),
191194
name: .ghosttyColorDidChange,
192195
object: self)
196+
center.addObserver(
197+
self,
198+
selector: #selector(ghosttyBellDidRing(_:)),
199+
name: .ghosttyBellDidRing,
200+
object: self)
193201
center.addObserver(
194202
self,
195203
selector: #selector(windowDidChangeScreen),
@@ -300,9 +308,12 @@ extension Ghostty {
300308
SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused)
301309
}
302310

303-
// On macOS 13+ we can store our continuous clock...
304311
if (focused) {
312+
// On macOS 13+ we can store our continuous clock...
305313
focusInstant = ContinuousClock.now
314+
315+
// We unset our bell state if we gained focus
316+
bell = false
306317
}
307318
}
308319

@@ -556,6 +567,11 @@ extension Ghostty {
556567
}
557568
}
558569

570+
@objc private func ghosttyBellDidRing(_ notification: SwiftUI.Notification) {
571+
// Bell state goes to true
572+
bell = true
573+
}
574+
559575
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
560576
guard let window = self.window else { return }
561577
guard let object = notification.object as? NSWindow, window == object else { return }
@@ -855,6 +871,9 @@ extension Ghostty {
855871
return
856872
}
857873

874+
// On any keyDown event we unset our bell state
875+
bell = false
876+
858877
// We need to translate the mods (maybe) to handle configs such as option-as-alt
859878
let translationModsGhostty = Ghostty.eventModifierFlags(
860879
mods: ghostty_surface_key_translation_mods(

Diff for: macos/Sources/Ghostty/SurfaceView_UIKit.swift

+3
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ extension Ghostty {
3535
// on supported platforms.
3636
@Published var focusInstant: ContinuousClock.Instant? = nil
3737

38+
/// True when the bell is active. This is set inactive on focus or event.
39+
@Published var bell: Bool = false
40+
3841
// Returns sizing information for the surface. This is the raw C
3942
// structure because I'm lazy.
4043
var surfaceSize: ghostty_surface_size_s? {

Diff for: src/config/Config.zig

+7-1
Original file line numberDiff line numberDiff line change
@@ -1874,7 +1874,13 @@ keybind: Keybinds = .{},
18741874
/// for instance under the "Sound > Alert Sound" setting in GNOME,
18751875
/// or the "Accessibility > System Bell" settings in KDE Plasma.
18761876
///
1877-
/// Currently only implemented on Linux.
1877+
/// On macOS this has no affect.
1878+
///
1879+
/// On macOS, if the app is unfocused, it will bounce the app icon in the dock
1880+
/// once. Additionally, the title of the window with the alerted terminal
1881+
/// surface will contain a bell emoji (🔔) until the terminal is focused
1882+
/// or a key is pressed. These are not currently configurable since they're
1883+
/// considered unobtrusive.
18781884
@"bell-features": BellFeatures = .{},
18791885

18801886
/// Control the in-app notifications that Ghostty shows.

0 commit comments

Comments
 (0)