aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/apprt
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:05:23 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:05:51 +0300
commit5bd21b7ed468b5ff4d1a5b2ed7e211f401eb138a (patch)
tree672fa4625cca0ecb3e9376fc7f8e33d7b0e9deef /vendor/libghostty-vt/src/apprt
parent32abb3776668ee768200019c170956f67fe33a86 (diff)
chore: update libghostty vendor
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/apprt')
-rw-r--r--vendor/libghostty-vt/src/apprt/embedded.zig1
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/App.zig2
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/application.zig12
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig165
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface.zig84
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/window.zig87
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ipc/toggle_quick_terminal.zig24
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/media.zig91
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp2
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp1
-rw-r--r--vendor/libghostty-vt/src/apprt/ipc.zig4
11 files changed, 387 insertions, 86 deletions
diff --git a/vendor/libghostty-vt/src/apprt/embedded.zig b/vendor/libghostty-vt/src/apprt/embedded.zig
index 730913eb..7310159c 100644
--- a/vendor/libghostty-vt/src/apprt/embedded.zig
+++ b/vendor/libghostty-vt/src/apprt/embedded.zig
@@ -336,6 +336,7 @@ pub const App = struct {
) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
switch (action) {
.new_window => return false,
+ .toggle_quick_terminal => return false,
}
}
};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/App.zig b/vendor/libghostty-vt/src/apprt/gtk/App.zig
index 39c13c19..8a7b3a8e 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/App.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/App.zig
@@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig");
const Application = @import("class/application.zig").Application;
const Surface = @import("Surface.zig");
const ipcNewWindow = @import("ipc/new_window.zig").newWindow;
+const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal;
const log = std.log.scoped(.gtk);
@@ -84,6 +85,7 @@ pub fn performIpc(
) !bool {
switch (action) {
.new_window => return try ipcNewWindow(alloc, target, value),
+ .toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target),
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig
index 873674ce..107510b4 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig
@@ -1419,6 +1419,7 @@ pub const Application = extern struct {
.init("present-surface", actionPresentSurface, t_variant_type),
.init("quit", actionQuit, null),
.init("reload-config", actionReloadConfig, null),
+ .init("toggle-quick-terminal", actionToggleQuickTerminal, null),
};
ext.actions.add(Self, self, &actions);
@@ -1669,6 +1670,17 @@ pub const Application = extern struct {
};
}
+ fn actionToggleQuickTerminal(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv = self.private();
+ priv.core_app.performAction(self.rt(), .toggle_quick_terminal) catch |err| {
+ log.warn("error toggling quick terminal err={}", .{err});
+ };
+ }
+
fn actionQuit(
_: *gio.SimpleAction,
_: ?*glib.Variant,
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
index 311fbd8a..5aef53d2 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
@@ -158,6 +158,13 @@ pub const SplitTree = extern struct {
/// used to debounce updates.
rebuild_source: ?c_uint = null,
+ /// The source that we use to restore focus. With enough nested
+ /// splits, some surfaces might initially be allocated a width or
+ /// height of 0 which causes them to get unmapped and lose focus.
+ /// We can reliably restore focus to the last focused surface only
+ /// once it is mapped again.
+ restore_focus_source: ?c_uint = null,
+
/// Used to store state about a pending surface close for the
/// close dialog.
pending_close: ?Surface.Tree.Node.Handle,
@@ -415,6 +422,13 @@ pub const SplitTree = extern struct {
self,
.{ .detail = "focused" },
);
+ _ = gobject.Object.signals.notify.connect(
+ surface,
+ *Self,
+ propSurfaceMapped,
+ self,
+ .{ .detail = "mapped" },
+ );
}
}
@@ -571,6 +585,12 @@ pub const SplitTree = extern struct {
}
priv.rebuild_source = null;
}
+ if (priv.restore_focus_source) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove restore_focus source", .{});
+ }
+ priv.restore_focus_source = null;
+ }
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
@@ -766,6 +786,24 @@ pub const SplitTree = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
}
+ fn propSurfaceMapped(
+ surface: *Surface,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ if (!surface.getMapped()) return;
+
+ // We could add the idle callback only if this is actually the last
+ // focused surface. But we can avoid that check because usually all
+ // the surfaces get mapped at once, so the idle callback will run
+ // only once anyway.
+ const priv = self.private();
+ if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd(
+ onRestoreFocus,
+ self,
+ );
+ }
+
fn propTree(
self: *Self,
_: *gobject.ParamSpec,
@@ -779,14 +817,20 @@ pub const SplitTree = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec);
self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec);
- // If we were planning a rebuild, always remove that so we can
- // start from a clean slate.
+ // If we were planning a rebuild or focus restore, always remove
+ // that so we can start from a clean slate.
if (priv.rebuild_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove rebuild source", .{});
}
priv.rebuild_source = null;
}
+ if (priv.restore_focus_source) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove restore_focus source", .{});
+ }
+ priv.restore_focus_source = null;
+ }
// If we transitioned to an empty tree, clear immediately instead of
// waiting for an idle callback. Delaying teardown can keep the last
@@ -842,6 +886,26 @@ pub const SplitTree = extern struct {
return 0;
}
+ fn onRestoreFocus(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+
+ // Always mark our source as null since we're done.
+ const priv = self.private();
+ priv.restore_focus_source = null;
+
+ // If we have a last-focused surface and it is mapped, restore focus
+ // to it. Depending on the available size, the surface might already
+ // have focus because it never got unmapped. In that case grabbing
+ // focus will have no effect.
+ if (priv.last_focused.get()) |v| {
+ defer v.unref();
+ if (v.getMapped()) {
+ v.grabFocus();
+ }
+ }
+ return 0;
+ }
+
/// Builds the widget tree associated with a surface split tree.
///
/// Returned widgets are expected to be attached to a parent by the caller.
@@ -1044,6 +1108,12 @@ const SplitTreeSplit = extern struct {
/// Source to handle repositioning the split when properties change.
idle: ?c_uint = null,
+ /// Whether the max-position/position property of the gtk.Paned widget
+ /// changed. We use these to distinguish between a resize and the user
+ /// manually moving the split divider. See the "on-idle" function.
+ max_changed: bool = false,
+ pos_changed: bool = false,
+
// Template bindings
paned: *gtk.Paned,
@@ -1083,21 +1153,37 @@ const SplitTreeSplit = extern struct {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
- fn refresh(self: *Self) void {
- const priv = self.private();
- if (priv.idle == null) priv.idle = glib.idleAdd(
- onIdle,
- self,
- );
- }
-
+ // We need to keep the split ratios from the tree datastructure and
+ // widget tree in sync. Using the max-position and position properties
+ // of the gtk.Paned widget, we can distinguish a resize from a manual
+ // update (e.g. the user dragging the divider).If max-position changes,
+ // we always have a widget resize. Usually position will change as well
+ // but it might not if the size change is small enough. If only position
+ // changes, we have a manual human update.
+ //
+ // This is a hack, it relies on the timing of property notifcations.
+ // From looking at the GTK source code, it should not be possible that
+ // we interpret a position change from a resize as a manual update.
+ // When a gtk.Paned is resized, internally the gtk_paned_calc_position
+ // function will change both max-position and position and synchronously
+ // call our propMaxPosition and propPosition functions. I.e. when the
+ // widget is resized, it should not be possible for onIdle to run before
+ // we have been notified of both property changes.
fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
const paned = priv.paned;
- // Our idle source is always over
- priv.idle = null;
+ // Clear source and fields at the end. Otherwise if setPosition is
+ // called below, propPosition is triggered and would add another
+ // idle callback before this one is finished.
+ defer priv.idle = null;
+ defer priv.max_changed = false;
+ defer priv.pos_changed = false;
+
+ if (!priv.max_changed and !priv.pos_changed) {
+ return 0;
+ }
// Get our split. This is the most dangerous part of this entire
// widget. We assume that this widget is always a child of a
@@ -1132,16 +1218,6 @@ const SplitTreeSplit = extern struct {
);
break :max gobject.ext.Value.get(&val, c_int);
};
- const pos_set: bool = max: {
- var val = gobject.ext.Value.new(c_int);
- defer val.unset();
- gobject.Object.getProperty(
- paned.as(gobject.Object),
- "position-set",
- &val,
- );
- break :max gobject.ext.Value.get(&val, c_int) != 0;
- };
// We don't actually use min, but we don't expect this to ever
// be non-zero, so let's add an assert to ensure that.
@@ -1172,51 +1248,51 @@ const SplitTreeSplit = extern struct {
return 0;
}
- // If we're out of bounds, then we need to either set the position
- // to what we expect OR update our expected ratio.
-
- // If we've never set the position, then we set it to the desired.
- if (!pos_set) {
+ if (priv.max_changed) {
+ // Widget got resized, update position to match desired ratio.
+ // Note that if max-position is small, it might not be possible
+ // to accurately set the desired ratio. E.g. with max-position=2
+ // you can only have ratios 0, 0.5 and 1.
const desired_pos: c_int = desired_pos: {
const max_f64: f64 = @floatFromInt(max);
break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio));
};
paned.setPosition(desired_pos);
- return 0;
+ } else {
+ // If only position changed, this is a manual human update and
+ // we need to write our update back to the tree.
+ tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
}
-
- // If we've set the position, then this is a manual human update
- // and we need to write our update back to the tree.
- tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
-
return 0;
}
//---------------------------------------------------------------
// Signal handlers
- fn propPosition(
- _: *gtk.Paned,
- _: *gobject.ParamSpec,
- self: *Self,
- ) callconv(.c) void {
- self.refresh();
- }
-
fn propMaxPosition(
_: *gtk.Paned,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
- self.refresh();
+ const priv = self.private();
+ priv.max_changed = true;
+ if (priv.idle == null) priv.idle = glib.idleAdd(
+ onIdle,
+ self,
+ );
}
- fn propMinPosition(
+ fn propPosition(
_: *gtk.Paned,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
- self.refresh();
+ const priv = self.private();
+ priv.pos_changed = true;
+ if (priv.idle == null) priv.idle = glib.idleAdd(
+ onIdle,
+ self,
+ );
}
//---------------------------------------------------------------
@@ -1275,7 +1351,6 @@ const SplitTreeSplit = extern struct {
// Template Callbacks
class.bindTemplateCallback("notify_max_position", &propMaxPosition);
- class.bindTemplateCallback("notify_min_position", &propMinPosition);
class.bindTemplateCallback("notify_position", &propPosition);
// Virtual methods
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
index 179c779d..629866dc 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
@@ -169,6 +169,24 @@ pub const Surface = extern struct {
);
};
+ pub const mapped = struct {
+ pub const name = "mapped";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ bool,
+ .{
+ .default = false,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "mapped",
+ ),
+ },
+ );
+ };
+
pub const @"min-size" = struct {
pub const name = "min-size";
const impl = gobject.ext.defineProperty(
@@ -592,11 +610,15 @@ pub const Surface = extern struct {
/// focus events.
focused: bool = true,
+ /// Whether the GLArea widget is mapped. Some operations like grabbing
+ /// focus only work if a widget is mapped.
+ mapped: bool = false,
+
/// Whether this surface is "zoomed" or not. A zoomed surface
/// shows up taking the full bounds of a split view.
zoom: bool = false,
- /// The GLAarea that renders the actual surface. This is a binding
+ /// The GLArea that renders the actual surface. This is a binding
/// to the template so it doesn't have to be unrefed manually.
gl_area: *gtk.GLArea,
@@ -652,6 +674,12 @@ pub const Surface = extern struct {
// false by a parent widget.
bell_ringing: bool = false,
+ // The audio bell's MediaFile, reused across bells so we don't leak a
+ // GStreamer pipeline (and its GL threads) on every ring. Built lazily
+ // on the first audio bell and rebuilt when `bell-audio-path` changes;
+ // unref'd on dispose. See ringBell and media.zig.
+ bell_media: ?*gtk.MediaFile = null,
+
/// True if this surface is in an error state. This is currently
/// a simple boolean with no additional information on WHAT the
/// error state is, because we don't yet need it or use it. For now,
@@ -1768,6 +1796,7 @@ pub const Surface = extern struct {
priv.mouse_shape = .text;
priv.mouse_hidden = false;
priv.focused = true;
+ priv.mapped = false;
priv.size = .{ .width = 0, .height = 0 };
priv.vadj_signal_group = null;
@@ -1831,6 +1860,11 @@ pub const Surface = extern struct {
priv.config = null;
}
+ if (priv.bell_media) |v| {
+ v.unref();
+ priv.bell_media = null;
+ }
+
if (priv.vadj_signal_group) |group| {
group.setTarget(null);
group.as(gobject.Object).unref();
@@ -2019,6 +2053,11 @@ pub const Surface = extern struct {
return self.private().focused;
}
+ /// Returns true if the GLArea of this surface is mapped.
+ pub fn getMapped(self: *Self) bool {
+ return self.private().mapped;
+ }
+
/// Change the configuration for this surface.
pub fn setConfig(self: *Self, config: *Config) void {
const priv = self.private();
@@ -2458,8 +2497,15 @@ pub const Surface = extern struct {
1.0,
);
- const media_file = media.fromFilename(path) orelse break :audio;
- media.playMediaFile(media_file, volume, required);
+ // Reuse one MediaFile per surface (rebuilt only when the path
+ // changes) so each bell replays the same pipeline instead of
+ // leaking a fresh one. Assign unconditionally: bellMediaFile frees
+ // any stale MediaFile and returns the current slot value (possibly
+ // null if the path is now inaccessible), so priv.bell_media never
+ // dangles.
+ priv.bell_media = media.bellMediaFile(priv.bell_media, path, required);
+ const media_file = priv.bell_media orelse break :audio;
+ media.playBell(media_file, volume);
}
}
@@ -3250,6 +3296,35 @@ pub const Surface = extern struct {
priv.im_context.as(gtk.IMContext).setClientWidget(null);
}
+ fn glareaMap(
+ _: *gtk.GLArea,
+ self: *Self,
+ ) callconv(.c) void {
+ self.updateMapped(true);
+ self.updateOcclusion(true);
+ }
+
+ fn glareaUnmap(
+ _: *gtk.GLArea,
+ self: *Self,
+ ) callconv(.c) void {
+ self.updateMapped(false);
+ self.updateOcclusion(false);
+ }
+
+ fn updateMapped(self: *Self, mapped: bool) void {
+ const priv = self.private();
+ priv.mapped = mapped;
+ self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec);
+ }
+
+ fn updateOcclusion(self: *Self, visible: bool) void {
+ const surface = self.core() orelse return;
+ surface.occlusionCallback(visible) catch |err| {
+ log.warn("error in occlusion callback err={}", .{err});
+ };
+ }
+
fn glareaRender(
_: *gtk.GLArea,
_: *gdk.GLContext,
@@ -3560,6 +3635,8 @@ pub const Surface = extern struct {
class.bindTemplateCallback("drop", &dtDrop);
class.bindTemplateCallback("gl_realize", &glareaRealize);
class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);
+ class.bindTemplateCallback("gl_map", &glareaMap);
+ class.bindTemplateCallback("gl_unmap", &glareaUnmap);
class.bindTemplateCallback("gl_render", &glareaRender);
class.bindTemplateCallback("gl_resize", &glareaResize);
class.bindTemplateCallback("im_preedit_start", &imPreeditStart);
@@ -3592,6 +3669,7 @@ pub const Surface = extern struct {
properties.@"error".impl,
properties.@"font-size-request".impl,
properties.focused.impl,
+ properties.mapped.impl,
properties.@"key-sequence".impl,
properties.@"key-table".impl,
properties.@"min-size".impl,
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
index 0c8dfaa7..2a1a5435 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
@@ -220,6 +220,9 @@ pub const Window = extern struct {
/// behaves slightly differently under certain scenarios.
quick_terminal: bool = false,
+ /// Timeout source to react to this window becoming (in)active.
+ handle_active_state_source: ?c_uint = null,
+
/// The window decoration override. If this is not set then we'll
/// inherit whatever the config has. This allows overriding the
/// config on a per-window basis.
@@ -855,6 +858,38 @@ pub const Window = extern struct {
}
}
+ /// Callback to handle this window becoming active or inactive.
+ /// Triggered by propIsActive with a timeout to debounce temporary
+ /// changes in active state.
+ fn handleActiveState(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const priv = self.private();
+ priv.handle_active_state_source = null;
+
+ // Hide quick-terminal if set to autohide
+ if (self.isQuickTerminal()) {
+ if (self.getConfig()) |cfg| {
+ if (cfg.get().@"quick-terminal-autohide" and
+ self.as(gtk.Window).isActive() == 0 and
+ self.as(gtk.Widget).isVisible() == 1)
+ {
+ self.toggleVisibility();
+ }
+ }
+ }
+
+ // Don't change urgency if we're not the active window.
+ if (self.as(gtk.Window).isActive() == 0) return 0;
+
+ self.winproto().setUrgent(false) catch |err| {
+ log.warn(
+ "winproto failed to reset urgency={}",
+ .{err},
+ );
+ };
+ return 0;
+ }
+
//---------------------------------------------------------------
// Properties
@@ -1076,27 +1111,34 @@ pub const Window = extern struct {
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
- // Hide quick-terminal if set to autohide
- if (self.isQuickTerminal()) {
- if (self.getConfig()) |cfg| {
- if (cfg.get().@"quick-terminal-autohide" and
- self.as(gtk.Window).isActive() == 0 and
- self.as(gtk.Widget).isVisible() == 1)
- {
- self.toggleVisibility();
- }
- }
- }
-
- // Don't change urgency if we're not the active window.
- if (self.as(gtk.Window).isActive() == 0) return;
+ const priv = self.private();
- self.winproto().setUrgent(false) catch |err| {
- log.warn(
- "winproto failed to reset urgency={}",
- .{err},
+ // Use a timeout callback to wait for focus state to settle,
+ // because depending on the windowing backend the window might
+ // become inactive and immediately active again. This happens
+ // e.g. on Wayland when opening a context menu or a submenu
+ // inside a context menu.
+ if (priv.handle_active_state_source == null) {
+ priv.handle_active_state_source = glib.timeoutAddFull(
+ // Use priority of an idle callback instead of the higher
+ // default timeout priority. This allows us to use a shorter
+ // timeout duration.
+ glib.PRIORITY_DEFAULT_IDLE,
+ // 50ms was chosen to be conservative. From testing we know
+ // that, depending on the backend and system performance, a
+ // shorter timeout or just an idle callback can be enough for
+ // the focus to settle. On the other hand a delay of e.g. 10ms
+ // does not work reliably on some slow systems. The downside
+ // of a high value is that some operations in handleActiveState,
+ // e.g. hiding the quick-terminal, will be visibly delayed.
+ // However, 50ms should barely be noticeable. We can change
+ // this in the future if necessary.
+ 50,
+ handleActiveState,
+ self,
+ null,
);
- };
+ }
}
fn propGdkSurfaceDims(
@@ -1215,6 +1257,13 @@ pub const Window = extern struct {
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
+ if (priv.handle_active_state_source) |v| {
+ if (glib.Source.remove(v) == 0) {
+ log.warn("unable to remove handle active state source", .{});
+ }
+ priv.handle_active_state_source = null;
+ }
+
priv.command_palette.set(null);
if (priv.config) |v| {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ipc/toggle_quick_terminal.zig b/vendor/libghostty-vt/src/apprt/gtk/ipc/toggle_quick_terminal.zig
new file mode 100644
index 00000000..702ad3df
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/ipc/toggle_quick_terminal.zig
@@ -0,0 +1,24 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const apprt = @import("../../../apprt.zig");
+const DBus = @import("DBus.zig");
+
+/// Use a D-Bus method call to toggle the quick terminal on GTK.
+///
+/// `ghostty +toggle-quick-terminal` is equivalent to the following command
+/// (on a release build):
+///
+/// ```sh
+/// gdbus call --session \
+/// --dest com.mitchellh.ghostty \
+/// --object-path /com/mitchellh/ghostty \
+/// --method org.gtk.Actions.Activate \
+/// toggle-quick-terminal [] []
+/// ```
+pub fn toggleQuickTerminal(alloc: Allocator, target: apprt.ipc.Target) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool {
+ var dbus = try DBus.init(alloc, target, "toggle-quick-terminal");
+ defer dbus.deinit(alloc);
+ try dbus.send();
+ return true;
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/media.zig b/vendor/libghostty-vt/src/apprt/gtk/media.zig
index 1015c933..7883bf37 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/media.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/media.zig
@@ -44,9 +44,38 @@ pub fn fromResource(path: [:0]const u8) ?*gtk.MediaFile {
return gtk.MediaFile.newForResource(path);
}
-pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) void {
- // If the audio file is marked as required, we'll emit an error if
- // there was a problem playing it. Otherwise there will be silence.
+/// Get-or-create a reusable bell MediaFile targeting `path`.
+///
+/// `current` is the surface's currently-cached MediaFile (or null). If it
+/// already targets `path` it is returned unchanged; otherwise it is unref'd and
+/// a fresh MediaFile is built for `path`. Returns null (after freeing `current`)
+/// if `path` is inaccessible, leaving the caller's slot empty.
+///
+/// Reusing one MediaFile per surface is what prevents the GStreamer pipeline
+/// leak: `gtk.MediaFile.newForFilename` spins up a full pipeline (and, via the
+/// GTK4 GStreamer backend's GL sink, gstglcontext/gldisplay-event threads) that
+/// is never torn down on the happy path, so allocating one per bell leaked a
+/// pipeline + its threads on every ring. See the caller in surface.zig.
+pub fn bellMediaFile(
+ current: ?*gtk.MediaFile,
+ path: [:0]const u8,
+ required: bool,
+) ?*gtk.MediaFile {
+ if (current) |media_file| {
+ if (isForPath(media_file, path)) return media_file;
+ media_file.unref();
+ }
+
+ const media_file = fromFilename(path) orelse return null;
+
+ // If the audio file is marked as required, we'll emit an error if there
+ // was a problem playing it. Otherwise there will be silence. We connect
+ // this once, here, because the MediaFile is reused across bells.
+ //
+ // NOTE: we intentionally do NOT connect notify::ended to unref. The
+ // MediaFile is owned by the surface and replayed via `seek(0)` for every
+ // bell; unref'ing on `ended` is precisely what previously discarded (and
+ // leaked) a pipeline per ring.
if (required) {
_ = gobject.Object.signals.notify.connect(
media_file,
@@ -57,21 +86,27 @@ pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) vo
);
}
- // Watch for the "ended" signal so that we can clean up after
- // ourselves.
- _ = gobject.Object.signals.notify.connect(
- media_file,
- ?*anyopaque,
- mediaFileEnded,
- null,
- .{ .detail = "ended" },
- );
+ return media_file;
+}
+/// (Re)play `media_file` at `volume`. `seek(0)` rewinds first so that a
+/// previously-ended stream plays again; without it playback only ever happens
+/// once (see #8957). Safe on a freshly-created stream as well.
+pub fn playBell(media_file: *gtk.MediaFile, volume: f64) void {
const media_stream = media_file.as(gtk.MediaStream);
media_stream.setVolume(volume);
+ media_stream.seek(0);
media_stream.play();
}
+/// Whether `media_file` was created for `path`.
+fn isForPath(media_file: *gtk.MediaFile, path: [:0]const u8) bool {
+ const file = media_file.getFile() orelse return false;
+ const cur = file.getPath() orelse return false;
+ defer glib.free(cur);
+ return std.mem.eql(u8, std.mem.span(cur), path);
+}
+
fn mediaFileError(
media_file: *gtk.MediaFile,
_: *gobject.ParamSpec,
@@ -93,10 +128,30 @@ fn mediaFileError(
});
}
-fn mediaFileEnded(
- media_file: *gtk.MediaFile,
- _: *gobject.ParamSpec,
- _: ?*anyopaque,
-) callconv(.c) void {
- media_file.unref();
+test "bellMediaFile reuses one MediaFile per path" {
+ // Regression guard for the audio-bell thread leak: each bell must replay a
+ // single cached MediaFile, not allocate a fresh GStreamer pipeline (which
+ // leaked gstglcontext/gldisplay-event threads) per ring. We assert the
+ // reuse contract of bellMediaFile directly; this needs no display and no
+ // playback (MediaFile is lazy), only that the path comparison drives reuse.
+ const testing = std.testing;
+
+ // The files need not exist: MediaFile only records the path until played.
+ const path_a: [:0]const u8 = "/tmp/ghostty-bell-test-a.oga";
+ const path_b: [:0]const u8 = "/tmp/ghostty-bell-test-b.oga";
+
+ var current = bellMediaFile(null, path_a, false) orelse return error.SkipZigTest;
+ const first = current;
+ try testing.expect(isForPath(current, path_a));
+
+ // Same path => identical object (the leak regression is rebuilding here).
+ current = bellMediaFile(current, path_a, false).?;
+ try testing.expectEqual(first, current);
+
+ // Changed path => rebuilt object targeting the new path (old one freed).
+ current = bellMediaFile(current, path_b, false) orelse return error.SkipZigTest;
+ try testing.expect(isForPath(current, path_b));
+ try testing.expect(!isForPath(current, path_a));
+
+ current.unref();
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp
index 794ea180..2f01c48c 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp
@@ -23,6 +23,8 @@ Overlay terminal_page {
GLArea gl_area {
realize => $gl_realize();
unrealize => $gl_unrealize();
+ map => $gl_map();
+ unmap => $gl_unmap();
render => $gl_render();
resize => $gl_resize();
hexpand: true;
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp
index 182919f4..521665d1 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp
@@ -13,7 +13,6 @@ template $GhosttySplitTreeSplit: Adw.Bin {
Adw.Bin {
Paned paned {
notify::max-position => $notify_max_position();
- notify::min-position => $notify_min_position();
notify::position => $notify_position();
}
}
diff --git a/vendor/libghostty-vt/src/apprt/ipc.zig b/vendor/libghostty-vt/src/apprt/ipc.zig
index b37647e0..dda794ae 100644
--- a/vendor/libghostty-vt/src/apprt/ipc.zig
+++ b/vendor/libghostty-vt/src/apprt/ipc.zig
@@ -73,6 +73,9 @@ pub const Action = union(enum) {
/// The arguments to pass to Ghostty as the command.
new_window: NewWindow,
+ /// Toggle the quick terminal.
+ toggle_quick_terminal: void,
+
pub const NewWindow = struct {
/// A list of command arguments to launch in the new window. If this is
/// `null` the command configured in the config or the user's default
@@ -113,6 +116,7 @@ pub const Action = union(enum) {
/// Sync with: ghostty_ipc_action_tag_e
pub const Key = enum(c_int) {
new_window,
+ toggle_quick_terminal,
test "ghostty.h Action.Key" {
try lib.checkGhosttyHEnum(Key, "GHOSTTY_IPC_ACTION_");