diff --git a/include/ghostty.h b/include/ghostty.h index 2dc1bffef..f30275b2c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -601,6 +601,7 @@ typedef enum { GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, + GHOSTTY_ACTION_RING_BELL, } ghostty_action_tag_e; typedef union { diff --git a/src/Surface.zig b/src/Surface.zig index da8662040..b9eb9e14a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -930,6 +930,16 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .present_surface => try self.presentSurface(), .password_input => |v| try self.passwordInput(v), + + .ring_bell => { + _ = self.rt_app.performAction( + .{ .surface = self }, + .ring_bell, + {}, + ) catch |err| { + log.warn("apprt failed to ring bell={}", .{err}); + }; + }, } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 30cb2fa5e..30cbfb1e1 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -244,6 +244,8 @@ pub const Action = union(Key) { /// Closes the currently focused window. close_window, + ring_bell, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -287,6 +289,7 @@ pub const Action = union(Key) { reload_config, config_change, close_window, + ring_bell, }; /// Sync with: ghostty_action_u diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 998f88022..c5ee802c4 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -246,6 +246,7 @@ pub const App = struct { .toggle_maximize, .prompt_title, .reset_window_size, + .ring_bell, => { log.info("unimplemented action={}", .{action}); return false; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ddee49459..a14383ca3 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -484,6 +484,7 @@ pub fn performAction( .prompt_title => try self.promptTitle(target), .toggle_quick_terminal => return try self.toggleQuickTerminal(), .secure_input => self.setSecureInput(target, value), + .ring_bell => try self.ringBell(target), // Unimplemented .close_all_windows, @@ -775,6 +776,13 @@ fn toggleQuickTerminal(self: *App) !bool { return true; } +fn ringBell(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |surface| try surface.rt_surface.ringBell(), + } +} + fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { switch (mode) { .start => self.startQuitTimer(), diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index fe05fa63b..e99fe29ce 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2439,3 +2439,25 @@ pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void { .toggle => self.is_secure_input = !self.is_secure_input, } } + +pub fn ringBell(self: *Surface) !void { + const features = self.app.config.@"bell-features"; + const window = self.container.window() orelse { + log.warn("failed to ring bell: surface is not attached to any window", .{}); + return; + }; + + // System beep + if (features.system) system: { + const surface = window.window.as(gtk.Native).getSurface() orelse break :system; + surface.beep(); + } + + // Mark tab as needing attention + if (self.container.tab()) |tab| tab: { + const page = window.notebook.getTabPage(tab) orelse break :tab; + + // Need attention if we're not the currently selected tab + if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); + } +} diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig index 85a9bbcb2..ddd0951d2 100644 --- a/src/apprt/gtk/TabView.zig +++ b/src/apprt/gtk/TabView.zig @@ -114,9 +114,12 @@ pub fn gotoNthTab(self: *TabView, position: c_int) bool { return true; } +pub fn getTabPage(self: *TabView, tab: *Tab) ?*adw.TabPage { + return self.tab_view.getPage(tab.box.as(gtk.Widget)); +} + pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); - return self.tab_view.getPagePosition(page); + return self.tab_view.getPagePosition(self.getTabPage(tab) orelse return null); } pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool { @@ -161,17 +164,16 @@ pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void { } pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); - _ = self.tab_view.reorderPage(page, position); + _ = self.tab_view.reorderPage(self.getTabPage(tab) orelse return, position); } pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); + const page = self.getTabPage(tab) orelse return; page.setTitle(title.ptr); } pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); + const page = self.getTabPage(tab) orelse return; page.setTooltip(tooltip.ptr); } @@ -203,8 +205,7 @@ pub fn closeTab(self: *TabView, tab: *Tab) void { if (n > 1) self.forcing_close = false; } - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); - self.tab_view.closePage(page); + if (self.getTabPage(tab)) |page| self.tab_view.closePage(page); // If we have no more tabs we close the window if (self.nPages() == 0) { @@ -260,6 +261,11 @@ fn adwTabViewCreateWindow( fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.C) void { const page = self.tab_view.getSelectedPage() orelse return; + + // If the tab was previously marked as needing attention + // (e.g. due to a bell character), we now unmark that + page.setNeedsAttention(@intFromBool(false)); + const title = page.getTitle(); self.window.setTitle(std.mem.span(title)); } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index f3fd71432..6de41c544 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -81,6 +81,9 @@ pub const Message = union(enum) { /// The terminal has reported a change in the working directory. pwd_change: WriteReq, + /// The terminal encountered a bell character. + ring_bell, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/config/Config.zig b/src/config/Config.zig index a0d9275e9..f648e8a28 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1861,6 +1861,22 @@ keybind: Keybinds = .{}, /// open terminals. @"custom-shader-animation": CustomShaderAnimation = .true, +/// The list of enabled features that are activated after encountering +/// a bell character. +/// +/// Valid values are: +/// +/// * `system` (default) +/// +/// Instructs the system to notify the user using built-in system functions. +/// This could result in an audiovisual effect, a notification, or something +/// else entirely. Changing these effects require altering system settings: +/// for instance under the "Sound > Alert Sound" setting in GNOME, +/// or the "Accessibility > System Bell" settings in KDE Plasma. +/// +/// Currently only implemented on Linux. +@"bell-features": BellFeatures = .{}, + /// Control the in-app notifications that Ghostty shows. /// /// On Linux (GTK), in-app notifications show up as toasts. Toasts appear @@ -5691,6 +5707,11 @@ pub const AppNotifications = packed struct { @"clipboard-copy": bool = true, }; +/// See bell-features +pub const BellFeatures = packed struct { + system: bool = false, +}; + /// See mouse-shift-capture pub const MouseShiftCapture = enum { false, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 43d2888d2..299c7cd45 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -325,9 +325,8 @@ pub const StreamHandler = struct { try self.terminal.printRepeat(count); } - pub fn bell(self: StreamHandler) !void { - _ = self; - log.info("BELL", .{}); + pub fn bell(self: *StreamHandler) !void { + self.surfaceMessageWriter(.ring_bell); } pub fn backspace(self: *StreamHandler) !void {