diff --git a/media/README.md b/media/README.md
new file mode 100644
index 000000000..3d1be07bf
--- /dev/null
+++ b/media/README.md
@@ -0,0 +1,3 @@
+These files are copied from the xdg-sound-theme, found at:
+
+https://gitlab.freedesktop.org/xdg/xdg-sound-theme
diff --git a/media/bell.oga b/media/bell.oga
new file mode 100644
index 000000000..144d2b367
Binary files /dev/null and b/media/bell.oga differ
diff --git a/media/message.oga b/media/message.oga
new file mode 100644
index 000000000..e96c3a19e
Binary files /dev/null and b/media/message.oga differ
diff --git a/nix/devShell.nix b/nix/devShell.nix
index 7f0e206b7..3d186297f 100644
--- a/nix/devShell.nix
+++ b/nix/devShell.nix
@@ -51,6 +51,7 @@
pandoc,
hyperfine,
typos,
+ gst_all_1,
}: let
# See package.nix. Keep in sync.
rpathLibs =
@@ -153,6 +154,9 @@ in
libadwaita
gtk4
glib
+ gst_all_1.gstreamer
+ gst_all_1.gst-plugins-base
+ gst_all_1.gst-plugins-good
];
# This should be set onto the rpath of the ghostty binary if you want
diff --git a/nix/package.nix b/nix/package.nix
index 889eb978f..e4477035a 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -17,6 +17,7 @@
glib,
gtk4,
libadwaita,
+ gst_all_1,
wrapGAppsHook4,
gsettings-desktop-schemas,
git,
@@ -51,6 +52,7 @@
../conformance
../images
../include
+ ../media
../pkg
../src
../vendor
@@ -144,6 +146,10 @@ in
libadwaita
gtk4
glib
+ gst_all_1.gstreamer
+ gst_all_1.gst-plugins-base
+ gst_all_1.gst-plugins-good
+
gsettings-desktop-schemas
];
@@ -177,6 +183,10 @@ in
mv "$out/share/ghostty/shell-integration" "$shell_integration/shell-integration"
ln -sf "$shell_integration/shell-integration" "$out/share/ghostty/shell-integration"
echo "$shell_integration" >> "$out/nix-support/propagated-user-env-packages"
+
+ echo "gst_all_1.gstreamer" >> "$out/nix-support/propagated-user-env-packages"
+ echo "gst_all_1.gst-plugins-base" >> "$out/nix-support/propagated-user-env-packages"
+ echo "gst_all_1.gst-plugins-good" >> "$out/nix-support/propagated-user-env-packages"
'';
postFixup = ''
diff --git a/src/Surface.zig b/src/Surface.zig
index fbb589638..e497d005e 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -901,6 +901,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.present_surface => try self.presentSurface(),
.password_input => |v| try self.passwordInput(v),
+
+ .bell => try self.bell(),
}
}
@@ -4472,3 +4474,9 @@ fn presentSurface(self: *Surface) !void {
{},
);
}
+
+fn bell(self: *Surface) !void {
+ if (@hasDecl(apprt.Surface, "bell")) {
+ try self.rt_surface.bell();
+ } else log.warn("runtime doesn't support bell", .{});
+}
diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig
index 22124becd..15ee64c2f 100644
--- a/src/apprt/gtk/Surface.zig
+++ b/src/apprt/gtk/Surface.zig
@@ -1160,6 +1160,77 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void {
c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu)));
}
+pub fn bell(self: *Surface) !void {
+ if (self.app.config.@"bell-features".audio) audio: {
+ const stream = switch (self.app.config.@"bell-audio") {
+ .bell => c.gtk_media_file_new_for_resource("/com/mitchellh/ghostty/media/bell.oga"),
+ .message => c.gtk_media_file_new_for_resource("/com/mitchellh/ghostty/media/message.oga"),
+ .custom => |filename| stream: {
+ var arena = std.heap.ArenaAllocator.init(self.app.core_app.alloc);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+ const pathname = pathname: {
+ if (std.fs.path.isAbsolute(filename))
+ break :pathname try alloc.dupeZ(u8, filename)
+ else
+ break :pathname try std.fs.path.joinZ(alloc, &.{
+ try internal_os.xdg.config(
+ alloc,
+ .{ .subdir = "ghostty/media" },
+ ),
+ filename,
+ });
+ };
+ std.fs.accessAbsoluteZ(pathname, .{ .mode = .read_only }) catch {
+ log.warn("unable to find sound file: {s}", .{filename});
+ break :audio;
+ };
+ break :stream c.gtk_media_file_new_for_filename(pathname);
+ },
+ };
+ _ = c.g_signal_connect_data(
+ stream,
+ "notify::error",
+ c.G_CALLBACK(>kStreamError),
+ stream,
+ null,
+ c.G_CONNECT_DEFAULT,
+ );
+ _ = c.g_signal_connect_data(
+ stream,
+ "notify::ended",
+ c.G_CALLBACK(>kStreamEnded),
+ stream,
+ null,
+ c.G_CONNECT_DEFAULT,
+ );
+ c.gtk_media_stream_set_volume(stream, 1.0);
+ c.gtk_media_stream_play(stream);
+ }
+ if (self.app.config.@"bell-features".visual) {
+ log.warn("visual bell is not supported", .{});
+ }
+ if (self.app.config.@"bell-features".notification) {
+ log.warn("notification bell is not supported", .{});
+ }
+ if (self.app.config.@"bell-features".title) {
+ log.warn("title bell is not supported", .{});
+ }
+ if (self.app.config.@"bell-features".command) {
+ log.warn("command bell is not supported", .{});
+ }
+}
+
+fn gtkStreamError(stream: ?*c.GObject) callconv(.C) void {
+ const err = c.gtk_media_stream_get_error(@ptrCast(stream));
+ if (err) |e|
+ log.err("error playing bell: {s} {d} {s}", .{ c.g_quark_to_string(e.*.domain), e.*.code, e.*.message });
+}
+
+fn gtkStreamEnded(stream: ?*c.GObject) callconv(.C) void {
+ c.g_object_unref(stream);
+}
+
fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
log.debug("gl surface realized", .{});
diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig
index db987cbea..13e21a93b 100644
--- a/src/apprt/gtk/gresource.zig
+++ b/src/apprt/gtk/gresource.zig
@@ -50,6 +50,10 @@ const icons = [_]struct {
};
pub const gresource_xml = comptimeGenerateGResourceXML();
+const media = [_][]const u8{
+ "media/bell.oga",
+ "media/message.oga",
+};
fn comptimeGenerateGResourceXML() []const u8 {
comptime {
@@ -97,6 +101,23 @@ fn writeGResourceXML(writer: anytype) !void {
}
try writer.writeAll(
\\
+ \\
+ );
+ try writer.writeAll(
+ \\
+ \\
+ );
+ for (media) |pathname| {
+ try writer.print(
+ " {s}\n",
+ .{ std.fs.path.basename(pathname), pathname },
+ );
+ }
+ try writer.writeAll(
+ \\
+ \\
+ );
+ try writer.writeAll(
\\
\\
);
diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig
index 58faa9633..7aa4004e8 100644
--- a/src/apprt/surface.zig
+++ b/src/apprt/surface.zig
@@ -79,6 +79,9 @@ pub const Message = union(enum) {
/// The terminal has reported a change in the working directory.
pwd_change: WriteReq,
+ /// Bell
+ bell: void,
+
pub const ReportTitleStyle = enum {
csi_21_t,
diff --git a/src/config/Config.zig b/src/config/Config.zig
index 6e569e795..15ad39b2c 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -1736,6 +1736,51 @@ term: []const u8 = "xterm-ghostty",
/// Changing this value at runtime works after a small delay.
@"auto-update": AutoUpdate = .check,
+/// Bell features to enable if bell support is available in your runtime. The
+/// format of this is a list of features to enable separated by commas. If you
+/// prefix a feature with `no-` then it is disabled. If you omit a feature, its
+/// default value is used, so you must explicitly disable features you don't
+/// want.
+///
+/// Available features:
+///
+/// * `audio` - Play an audible sound. (GTK only).
+///
+/// * `visual` - Flashes a visual indication in the surface that triggered
+/// the bell. (Currently not implemented.)
+///
+/// * `notification` - Displays a desktop notification. (Currently not
+/// implemented.)
+///
+/// * `title` - Will add a visual indicator to the window/tab title.
+/// (Currently not implemented.)
+///
+/// * `command` - Will run a command (e.g. for haptic feedback or flashing a
+/// physical light). (Currently not implemented.)
+///
+/// Example: `audio`, `no-audio`, `visual`, `no-visual`, `notification`, `no-notification`
+///
+/// By default, no bell features are enabled.
+@"bell-features": BellFeatures = .{},
+
+/// If `audio` is an enabled bell feature, this determines whether to use an
+/// internal audio file or whether to use a custom file on disk.
+///
+/// * `bell` - A simple bell sound.
+///
+/// * `message` - Another bell sound.
+///
+/// * `custom:` - The filename of an audio file to play as the bell.
+/// If the filename is not an absolute pathname the directory `~/.config/
+/// ghostty/media` will be searched for the file.
+///
+/// The default value is `bell`
+@"bell-audio": BellAudio = .{ .bell = {} },
+
+/// If `command` is an enabled bell feature, the command to be run. By default,
+/// this value is unset and no command will run.
+@"bell-command": ?[:0]const u8 = null,
+
/// This is set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null,
@@ -4955,3 +5000,62 @@ test "test entryFormatter" {
try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualStrings("a = 584y 49w 23h 34m 33s 709ms 551µs 615ns\n", buf.items);
}
+
+/// Bell features
+pub const BellFeatures = packed struct {
+ audio: bool = false,
+ visual: bool = false,
+ notification: bool = false,
+ title: bool = false,
+ command: bool = false,
+};
+
+pub const BellAudio = union(enum) {
+ bell: void,
+ message: void,
+ custom: [:0]const u8,
+
+ pub fn formatEntry(self: BellAudio, formatter: anytype) !void {
+ switch (self) {
+ .bell, .message => try formatter.formatEntry([]const u8, @tagName(self)),
+ .custom => |filename| {
+ var buf: [std.fs.max_path_bytes + 7]u8 = undefined;
+ try formatter.formatEntry(
+ []const u8,
+ std.fmt.bufPrint(
+ &buf,
+ "custom:{s}",
+ .{filename},
+ ) catch return error.OutOfMemory,
+ );
+ },
+ }
+ }
+
+ test "test formatEntry 1" {
+ var buf = std.ArrayList(u8).init(std.testing.allocator);
+ defer buf.deinit();
+
+ var b: BellAudio = .{ .bell = {} };
+ try b.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
+ try std.testing.expectEqualStrings("a = bell\n", buf.items);
+ }
+
+ test "test formatEntry 2" {
+ var buf = std.ArrayList(u8).init(std.testing.allocator);
+ defer buf.deinit();
+
+ var b: BellAudio = .{ .message = {} };
+ try b.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
+ try std.testing.expectEqualStrings("a = message\n", buf.items);
+ }
+
+ test "test formatEntry 3" {
+ var buf = std.ArrayList(u8).init(std.testing.allocator);
+ defer buf.deinit();
+
+ var b: BellAudio = .{ .custom = "custom.oga" };
+ try b.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
+ try std.testing.expectEqualStrings("a = custom:custom.oga\n", buf.items);
+ }
+};
diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig
index 37d176de3..ac3a4b4b7 100644
--- a/src/termio/stream_handler.zig
+++ b/src/termio/stream_handler.zig
@@ -322,9 +322,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(.{ .bell = {} });
}
pub fn backspace(self: *StreamHandler) !void {