mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
gtk: terminal bell
Use GTK's media streaming APIs to implement the terminal bell. By default it's disabled since it's likely that only a small subset of people will want this feature. Currently only audio support is implemented but there's scaffolding for several other methods of alerting the user. NOTE: GTK needs to have access to GStreamer and the "base" and "good" plugins or it crashes when you try to play a sound. I've added the necessary packages to the NixOS developer shell and package but I haven't found a method yet to detect the situation and avoid the crash.
This commit is contained in:
3
media/README.md
Normal file
3
media/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
These files are copied from the xdg-sound-theme, found at:
|
||||||
|
|
||||||
|
https://gitlab.freedesktop.org/xdg/xdg-sound-theme
|
BIN
media/bell.oga
Normal file
BIN
media/bell.oga
Normal file
Binary file not shown.
BIN
media/message.oga
Normal file
BIN
media/message.oga
Normal file
Binary file not shown.
@ -51,6 +51,7 @@
|
|||||||
pandoc,
|
pandoc,
|
||||||
hyperfine,
|
hyperfine,
|
||||||
typos,
|
typos,
|
||||||
|
gst_all_1,
|
||||||
}: let
|
}: let
|
||||||
# See package.nix. Keep in sync.
|
# See package.nix. Keep in sync.
|
||||||
rpathLibs =
|
rpathLibs =
|
||||||
@ -153,6 +154,9 @@ in
|
|||||||
libadwaita
|
libadwaita
|
||||||
gtk4
|
gtk4
|
||||||
glib
|
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
|
# This should be set onto the rpath of the ghostty binary if you want
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
glib,
|
glib,
|
||||||
gtk4,
|
gtk4,
|
||||||
libadwaita,
|
libadwaita,
|
||||||
|
gst_all_1,
|
||||||
wrapGAppsHook4,
|
wrapGAppsHook4,
|
||||||
gsettings-desktop-schemas,
|
gsettings-desktop-schemas,
|
||||||
git,
|
git,
|
||||||
@ -51,6 +52,7 @@
|
|||||||
../conformance
|
../conformance
|
||||||
../images
|
../images
|
||||||
../include
|
../include
|
||||||
|
../media
|
||||||
../pkg
|
../pkg
|
||||||
../src
|
../src
|
||||||
../vendor
|
../vendor
|
||||||
@ -144,6 +146,10 @@ in
|
|||||||
libadwaita
|
libadwaita
|
||||||
gtk4
|
gtk4
|
||||||
glib
|
glib
|
||||||
|
gst_all_1.gstreamer
|
||||||
|
gst_all_1.gst-plugins-base
|
||||||
|
gst_all_1.gst-plugins-good
|
||||||
|
|
||||||
gsettings-desktop-schemas
|
gsettings-desktop-schemas
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -177,6 +183,10 @@ in
|
|||||||
mv "$out/share/ghostty/shell-integration" "$shell_integration/shell-integration"
|
mv "$out/share/ghostty/shell-integration" "$shell_integration/shell-integration"
|
||||||
ln -sf "$shell_integration/shell-integration" "$out/share/ghostty/shell-integration"
|
ln -sf "$shell_integration/shell-integration" "$out/share/ghostty/shell-integration"
|
||||||
echo "$shell_integration" >> "$out/nix-support/propagated-user-env-packages"
|
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 = ''
|
postFixup = ''
|
||||||
|
@ -901,6 +901,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
|||||||
.present_surface => try self.presentSurface(),
|
.present_surface => try self.presentSurface(),
|
||||||
|
|
||||||
.password_input => |v| try self.passwordInput(v),
|
.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", .{});
|
||||||
|
}
|
||||||
|
@ -1153,6 +1153,56 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void {
|
|||||||
c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu)));
|
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 {
|
fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
|
||||||
log.debug("gl surface realized", .{});
|
log.debug("gl surface realized", .{});
|
||||||
|
|
||||||
|
@ -50,6 +50,10 @@ const icons = [_]struct {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub const gresource_xml = comptimeGenerateGResourceXML();
|
pub const gresource_xml = comptimeGenerateGResourceXML();
|
||||||
|
const media = [_][]const u8{
|
||||||
|
"media/bell.oga",
|
||||||
|
"media/message.oga",
|
||||||
|
};
|
||||||
|
|
||||||
fn comptimeGenerateGResourceXML() []const u8 {
|
fn comptimeGenerateGResourceXML() []const u8 {
|
||||||
comptime {
|
comptime {
|
||||||
@ -97,6 +101,23 @@ fn writeGResourceXML(writer: anytype) !void {
|
|||||||
}
|
}
|
||||||
try writer.writeAll(
|
try writer.writeAll(
|
||||||
\\ </gresource>
|
\\ </gresource>
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
try writer.writeAll(
|
||||||
|
\\ <gresource prefix="/com/mitchellh/ghostty/media">
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
for (media) |pathname| {
|
||||||
|
try writer.print(
|
||||||
|
" <file alias=\"{s}\">{s}</file>\n",
|
||||||
|
.{ std.fs.path.basename(pathname), pathname },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try writer.writeAll(
|
||||||
|
\\ </gresource>
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
try writer.writeAll(
|
||||||
\\</gresources>
|
\\</gresources>
|
||||||
\\
|
\\
|
||||||
);
|
);
|
||||||
|
@ -79,6 +79,9 @@ pub const Message = union(enum) {
|
|||||||
/// The terminal has reported a change in the working directory.
|
/// The terminal has reported a change in the working directory.
|
||||||
pwd_change: WriteReq,
|
pwd_change: WriteReq,
|
||||||
|
|
||||||
|
/// Bell
|
||||||
|
bell: void,
|
||||||
|
|
||||||
pub const ReportTitleStyle = enum {
|
pub const ReportTitleStyle = enum {
|
||||||
csi_21_t,
|
csi_21_t,
|
||||||
|
|
||||||
|
@ -1721,6 +1721,50 @@ term: []const u8 = "xterm-ghostty",
|
|||||||
/// Changing this value at runtime works after a small delay.
|
/// Changing this value at runtime works after a small delay.
|
||||||
@"auto-update": AutoUpdate = .check,
|
@"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:<filename>` - 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.
|
/// This is set by the CLI parser for deinit.
|
||||||
_arena: ?ArenaAllocator = null,
|
_arena: ?ArenaAllocator = null,
|
||||||
|
|
||||||
@ -4940,3 +4984,152 @@ test "test entryFormatter" {
|
|||||||
try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
|
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);
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -322,9 +322,8 @@ pub const StreamHandler = struct {
|
|||||||
try self.terminal.printRepeat(count);
|
try self.terminal.printRepeat(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bell(self: StreamHandler) !void {
|
pub fn bell(self: *StreamHandler) !void {
|
||||||
_ = self;
|
self.surfaceMessageWriter(.{ .bell = {} });
|
||||||
log.info("BELL", .{});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn backspace(self: *StreamHandler) !void {
|
pub fn backspace(self: *StreamHandler) !void {
|
||||||
|
Reference in New Issue
Block a user