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 8172b7490..5ec41afe4 100644
--- a/src/apprt/gtk/Surface.zig
+++ b/src/apprt/gtk/Surface.zig
@@ -1153,6 +1153,56 @@ 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) {
+ 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| c.gtk_media_file_new_for_filename(filename),
+ };
+ _ = 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 a3ee8ccf0..5323bd7a9 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -1721,6 +1721,50 @@ 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. (Currently Linux-only if libcanberra
+/// support has been compiled in.)
+///
+/// * `visual` - Flashes a visiual indication in the surface that triggered
+/// the bell. (Currently not implemented.)
+///
+/// * `notification` - Displays a desktop notification.
+///
+/// * `title` - Will add a visual indicator to the window/tab title.
+///
+/// * `command` - Will run a command (e.g. for haptic feedback or flashing a
+/// physical light).
+///
+/// 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,
@@ -4940,3 +4984,152 @@ 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 parseCLI(self: *BellAudio, alloc: std.mem.Allocator, input: ?[]const u8) !void {
+ const value = input orelse return error.ValueRequired;
+ const key_str = value[0 .. std.mem.indexOfScalar(u8, value, ':') orelse value.len];
+ if (std.meta.stringToEnum(std.meta.Tag(BellAudio), std.mem.trim(u8, key_str, &std.ascii.whitespace))) |key| switch (key) {
+ .bell => {
+ self.* = .{ .bell = {} };
+ },
+ .message => {
+ self.* = .{ .message = {} };
+ },
+ .custom => {
+ if (key_str.len == value.len) return error.ValueRequired;
+ const rest = std.mem.trim(u8, value[key_str.len + 1 ..], &std.ascii.whitespace);
+ if (rest.len == 0) return error.ValueRequired;
+ if (std.fs.path.isAbsolute(rest))
+ self.* = .{
+ .custom = try alloc.dupeZ(u8, rest),
+ }
+ else
+ self.* = .{
+ .custom = try std.fs.path.joinZ(alloc, &.{
+ try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/media" }),
+ rest,
+ }),
+ };
+ },
+ } else {
+ return error.ValueRequired;
+ }
+ }
+
+ 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 "parseCLI" {
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ {
+ var b: BellAudio = undefined;
+ try b.parseCLI(alloc, "bell");
+ try std.testing.expect(b == .bell);
+ }
+ {
+ var b: BellAudio = undefined;
+ try b.parseCLI(alloc, "message");
+ try std.testing.expect(b == .message);
+ }
+ {
+ var b: BellAudio = undefined;
+ try b.parseCLI(alloc, "message:");
+ try std.testing.expect(b == .message);
+ }
+ {
+ var b: BellAudio = undefined;
+ try b.parseCLI(alloc, " message : ");
+ try std.testing.expect(b == .message);
+ }
+ {
+ var b: BellAudio = undefined;
+ try b.parseCLI(alloc, "custom:/tmp/bell.oga");
+ try std.testing.expect(b == .custom);
+ try std.testing.expectEqualStrings("/tmp/bell.oga", b.custom);
+ }
+ {
+ var b: BellAudio = undefined;
+ try b.parseCLI(alloc, " custom : /tmp/bell.oga ");
+ try std.testing.expect(b == .custom);
+ try std.testing.expectEqualStrings("/tmp/bell.oga", b.custom);
+ }
+ {
+ var b: BellAudio = undefined;
+ try std.testing.expectError(error.ValueRequired, b.parseCLI(alloc, " custom : "));
+ }
+ {
+ var b: BellAudio = undefined;
+ try std.testing.expectError(error.ValueRequired, b.parseCLI(alloc, " custom "));
+ }
+ {
+ var b: BellAudio = undefined;
+ try std.testing.expectError(error.ValueRequired, b.parseCLI(alloc, " "));
+ }
+ {
+ var b: BellAudio = undefined;
+ try std.testing.expectError(error.ValueRequired, b.parseCLI(alloc, ""));
+ }
+ {
+ var b: BellAudio = undefined;
+ try std.testing.expectError(error.ValueRequired, b.parseCLI(alloc, null));
+ }
+ }
+
+ 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 {