gpui: Implement audible system bell (#47531)

Ian Chamberlain created

Relates to #5303 and
https://github.com/zed-industries/zed/issues/40826#issuecomment-3684556858
although I haven't found anywhere an actual request for `gpui` itself to
support a system alert sound.

### What

Basically, this PR adds a function that triggers an OS-dependent alert
sound, commonly used by terminal applications for `\a` / `BEL`, and GUI
applications to indicate an action failed in some small way (e.g. no
search results found, unable to move cursor, button disabled).

Also updated the `input` example, which now plays the bell if the user
presses <kbd>backspace</kbd> with nothing behind the cursor to delete,
or <kbd>delete</kbd> with nothing in front of the cursor.

Test with `cargo run --example input --features gpui_platform/font-kit`.

### Why
If this is merged, I plan to take a second step:
- Add a new Zed setting (probably something like
`terminal.audible_bell`)
- If enabled, `printf '\a'`, `tput bel` etc. would call this new API to
play an audible sound

This isn't the super-shiny dream of #5303 but it would allow users to
more easily configure tasks to notify when done. Plus, any TUI/CLI apps
that expect this functionality will work. Also, I think many terminal
users expect something like this (WezTerm, iTerm, etc. almost all
support this).

### Notes
~I was only able to test on macOS and Windows, so if there are any Linux
users who could verify this works for X11 / Wayland that would be a huge
help! If not I can try~

Confirmed Wayland + X11 both working when I ran the example on a NixOS
desktop

Release Notes:

- N/A

Change summary

Cargo.lock                                    | 11 +++++++++++
Cargo.toml                                    |  2 ++
crates/gpui/examples/input.rs                 | 14 ++++++++++++--
crates/gpui/src/platform.rs                   |  2 ++
crates/gpui/src/window.rs                     |  6 ++++++
crates/gpui_linux/src/linux/wayland/client.rs |  4 ++++
crates/gpui_linux/src/linux/wayland/window.rs | 12 ++++++++++++
crates/gpui_linux/src/linux/x11/window.rs     |  5 +++++
crates/gpui_macos/Cargo.toml                  |  1 +
crates/gpui_macos/src/window.rs               |  5 +++++
crates/gpui_windows/src/window.rs             |  9 ++++++++-
11 files changed, 68 insertions(+), 3 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7601,6 +7601,7 @@ dependencies = [
  "media",
  "metal",
  "objc",
+ "objc2-app-kit",
  "parking_lot",
  "pathfinder_geometry",
  "raw-window-handle",
@@ -11211,6 +11212,16 @@ dependencies = [
  "objc2-encode",
 ]
 
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
 [[package]]
 name = "objc2-audio-toolbox"
 version = "0.3.2"

Cargo.toml 🔗

@@ -604,6 +604,7 @@ nbformat = "1.2.0"
 nix = "0.29"
 num-format = "0.4.4"
 objc = "0.2"
+objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] }
 objc2-foundation = { version = "=0.3.2", default-features = false, features = [
     "NSArray",
     "NSAttributedString",
@@ -821,6 +822,7 @@ features = [
     "Win32_System_Com",
     "Win32_System_Com_StructuredStorage",
     "Win32_System_Console",
+    "Win32_System_Diagnostics_Debug",
     "Win32_System_DataExchange",
     "Win32_System_IO",
     "Win32_System_LibraryLoader",

crates/gpui/examples/input.rs 🔗

@@ -85,14 +85,24 @@ impl TextInput {
 
     fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
         if self.selected_range.is_empty() {
-            self.select_to(self.previous_boundary(self.cursor_offset()), cx)
+            let prev = self.previous_boundary(self.cursor_offset());
+            if self.cursor_offset() == prev {
+                window.play_system_bell();
+                return;
+            }
+            self.select_to(prev, cx)
         }
         self.replace_text_in_range(None, "", window, cx)
     }
 
     fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
         if self.selected_range.is_empty() {
-            self.select_to(self.next_boundary(self.cursor_offset()), cx)
+            let next = self.next_boundary(self.cursor_offset());
+            if self.cursor_offset() == next {
+                window.play_system_bell();
+                return;
+            }
+            self.select_to(next, cx)
         }
         self.replace_text_in_range(None, "", window, cx)
     }

crates/gpui/src/platform.rs 🔗

@@ -689,6 +689,8 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
 
     fn update_ime_position(&self, _bounds: Bounds<Pixels>);
 
+    fn play_system_bell(&self) {}
+
     #[cfg(any(test, feature = "test-support"))]
     fn as_test(&mut self) -> Option<&mut TestWindow> {
         None

crates/gpui/src/window.rs 🔗

@@ -5024,6 +5024,12 @@ impl Window {
             .set_tabbing_identifier(tabbing_identifier)
     }
 
+    /// Request the OS to play an alert sound. On some platforms this is associated
+    /// with the window, for others it's just a simple global function call.
+    pub fn play_system_bell(&self) {
+        self.platform_window.play_system_bell()
+    }
+
     /// Toggles the inspector mode on this window.
     #[cfg(any(feature = "inspector", debug_assertions))]
     pub fn toggle_inspector(&mut self, cx: &mut App) {

crates/gpui_linux/src/linux/wayland/client.rs 🔗

@@ -58,6 +58,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{
     zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
 };
 use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
+use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1;
 use wayland_protocols::{
     wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1},
     xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1},
@@ -129,6 +130,7 @@ pub struct Globals {
     pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
     pub gesture_manager: Option<zwp_pointer_gestures_v1::ZwpPointerGesturesV1>,
     pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
+    pub system_bell: Option<xdg_system_bell_v1::XdgSystemBellV1>,
     pub executor: ForegroundExecutor,
 }
 
@@ -170,6 +172,7 @@ impl Globals {
             text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
             gesture_manager: globals.bind(&qh, 1..=3, ()).ok(),
             dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
+            system_bell: globals.bind(&qh, 1..=1, ()).ok(),
             executor,
             qh,
         }
@@ -1069,6 +1072,7 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for WaylandClientStat
 }
 
 delegate_noop!(WaylandClientStatePtr: ignore xdg_activation_v1::XdgActivationV1);
+delegate_noop!(WaylandClientStatePtr: ignore xdg_system_bell_v1::XdgSystemBellV1);
 delegate_noop!(WaylandClientStatePtr: ignore wl_compositor::WlCompositor);
 delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1);
 delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_manager_v1::WpCursorShapeManagerV1);

crates/gpui_linux/src/linux/wayland/window.rs 🔗

@@ -1479,6 +1479,18 @@ impl PlatformWindow for WaylandWindow {
     fn gpu_specs(&self) -> Option<GpuSpecs> {
         self.borrow().renderer.gpu_specs().into()
     }
+
+    fn play_system_bell(&self) {
+        let state = self.borrow();
+        let surface = if state.surface_state.toplevel().is_some() {
+            Some(&state.surface)
+        } else {
+            None
+        };
+        if let Some(bell) = state.globals.system_bell.as_ref() {
+            bell.ring(surface);
+        }
+    }
 }
 
 fn update_window(mut state: RefMut<WaylandWindowState>) {

crates/gpui_linux/src/linux/x11/window.rs 🔗

@@ -1846,4 +1846,9 @@ impl PlatformWindow for X11Window {
     fn gpu_specs(&self) -> Option<GpuSpecs> {
         self.0.state.borrow().renderer.gpu_specs().into()
     }
+
+    fn play_system_bell(&self) {
+        // Volume 0% means don't increase or decrease from system volume
+        let _ = self.0.xcb.bell(0);
+    }
 }

crates/gpui_macos/Cargo.toml 🔗

@@ -48,6 +48,7 @@ mach2.workspace = true
 media.workspace = true
 metal.workspace = true
 objc.workspace = true
+objc2-app-kit.workspace = true
 parking_lot.workspace = true
 pathfinder_geometry = "0.5"
 raw-window-handle = "0.6"

crates/gpui_macos/src/window.rs 🔗

@@ -49,6 +49,7 @@ use objc::{
     runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES},
     sel, sel_impl,
 };
+use objc2_app_kit::NSBeep;
 use parking_lot::Mutex;
 use raw_window_handle as rwh;
 use smallvec::SmallVec;
@@ -1676,6 +1677,10 @@ impl PlatformWindow for MacWindow {
         }
     }
 
+    fn play_system_bell(&self) {
+        unsafe { NSBeep() }
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     fn render_to_image(&self, scene: &gpui::Scene) -> Result<RgbaImage> {
         let mut this = self.0.lock();

crates/gpui_windows/src/window.rs 🔗

@@ -20,7 +20,9 @@ use windows::{
         Foundation::*,
         Graphics::Dwm::*,
         Graphics::Gdi::*,
-        System::{Com::*, LibraryLoader::*, Ole::*, SystemServices::*},
+        System::{
+            Com::*, Diagnostics::Debug::MessageBeep, LibraryLoader::*, Ole::*, SystemServices::*,
+        },
         UI::{Controls::*, HiDpi::*, Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*},
     },
     core::*,
@@ -950,6 +952,11 @@ impl PlatformWindow for WindowsWindow {
 
         self.0.update_ime_position(self.0.hwnd, caret_position);
     }
+
+    fn play_system_bell(&self) {
+        // MB_OK: The sound specified as the Windows Default Beep sound.
+        let _ = unsafe { MessageBeep(MB_OK) };
+    }
 }
 
 #[implement(IDropTarget)]