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 {