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 {