diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 23:05:23 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 23:05:51 +0300 |
| commit | 5bd21b7ed468b5ff4d1a5b2ed7e211f401eb138a (patch) | |
| tree | 672fa4625cca0ecb3e9376fc7f8e33d7b0e9deef /vendor/libghostty-vt/src/apprt | |
| parent | 32abb3776668ee768200019c170956f67fe33a86 (diff) | |
chore: update libghostty vendor
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/apprt')
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_"); |
