gpui: Implement audible system bell (#47531)

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
This commit is contained in:
Ian Chamberlain 2026-03-31 19:50:01 -07:00 committed by GitHub
parent 4087d9f2ca
commit 971775e3b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 68 additions and 3 deletions

11
Cargo.lock generated
View file

@ -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"

View file

@ -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",

View file

@ -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)
}

View file

@ -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

View file

@ -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) {

View file

@ -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);

View file

@ -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>) {

View file

@ -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);
}
}

View file

@ -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"

View file

@ -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();

View file

@ -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)]