diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 7d5e7cd25..9d866d734 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index ddb954e04..dfd066870 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 20a43aa2b..d146477dc 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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 diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index cda4b557e..3afca56aa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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 diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index beae50331..7eebd3ef1 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 230d3a9e2..1269f2314 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -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( diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 8ac08d0bd..8d5b3038f 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -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? { diff --git a/src/config/Config.zig b/src/config/Config.zig index f648e8a28..d57ed161b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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.