apprt/gtk-ng: child exited overlay (#8044)

This ports the child exited overlay.

We're able to use Zig comptime and Blueprint templates to use the same
Surface blueprint for this even if libadwaita is too old to support
banners (< 1.3) by inheriting from `gtk.Widget` instead and not
instantiating the blueprint. Its a bit noisy to maintain the `noop`
version but we should be able to test that compilation in CI (we do via
Debian 12).
This commit is contained in:
Mitchell Hashimoto
2025-07-23 16:01:35 -07:00
committed by GitHub
9 changed files with 410 additions and 1 deletions

View File

@ -39,6 +39,7 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" },
.{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 2, .name = "surface" },
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
.{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "window" },
}; };

View File

@ -485,6 +485,8 @@ pub const Application = extern struct {
.set_title => Action.setTitle(target, value), .set_title => Action.setTitle(target, value),
.show_child_exited => return Action.showChildExited(target, value),
.show_gtk_inspector => Action.showGtkInspector(), .show_gtk_inspector => Action.showGtkInspector(),
// Unimplemented but todo on gtk-ng branch // Unimplemented but todo on gtk-ng branch
@ -514,7 +516,6 @@ pub const Application = extern struct {
.ring_bell, .ring_bell,
.toggle_command_palette, .toggle_command_palette,
.open_url, .open_url,
.show_child_exited,
.close_all_windows, .close_all_windows,
.float_window, .float_window,
.toggle_visibility, .toggle_visibility,
@ -1141,6 +1142,16 @@ const Action = struct {
} }
} }
pub fn showChildExited(
target: apprt.Target,
value: apprt.surface.Message.ChildExited,
) bool {
return switch (target) {
.app => false,
.surface => |v| v.rt_surface.surface.childExited(value),
};
}
pub fn showGtkInspector() void { pub fn showGtkInspector() void {
gtk.Window.setInteractiveDebugging(@intFromBool(true)); gtk.Window.setInteractiveDebugging(@intFromBool(true));
} }

View File

@ -21,6 +21,7 @@ const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application; const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config; const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const log = std.log.scoped(.gtk_ghostty_surface); const log = std.log.scoped(.gtk_ghostty_surface);
@ -57,6 +58,26 @@ pub const Surface = extern struct {
); );
}; };
pub const @"child-exited" = struct {
pub const name = "child-exited";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.nick = "Child Exited",
.blurb = "True when the child process has exited.",
.default = false,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"child_exited",
),
},
);
};
pub const focused = struct { pub const focused = struct {
pub const name = "focused"; pub const name = "focused";
const impl = gobject.ext.defineProperty( const impl = gobject.ext.defineProperty(
@ -284,7 +305,11 @@ pub const Surface = extern struct {
/// True when we have a precision scroll in progress /// True when we have a precision scroll in progress
precision_scroll: bool = false, precision_scroll: bool = false,
/// True when the child has exited.
child_exited: bool = false,
// Template binds // Template binds
child_exited_overlay: *ChildExited,
drop_target: *gtk.DropTarget, drop_target: *gtk.DropTarget,
pub var offset: c_int = 0; pub var offset: c_int = 0;
@ -634,6 +659,26 @@ pub const Surface = extern struct {
); );
} }
pub fn childExited(
self: *Self,
data: apprt.surface.Message.ChildExited,
) bool {
// If we have the noop child exited overlay then we don't do anything
// for child exited. The false return will force libghostty to show
// the normal text-based message.
if (comptime @hasDecl(ChildExited, "noop")) {
return false;
}
const priv = self.private();
priv.child_exited = true;
priv.child_exited_overlay.setData(&data);
self.as(gobject.Object).notifyByPspec(
properties.@"child-exited".impl.param_spec,
);
return true;
}
pub fn cgroupPath(self: *Self) ?[]const u8 { pub fn cgroupPath(self: *Self) ?[]const u8 {
return self.private().cgroup_path; return self.private().cgroup_path;
} }
@ -1015,6 +1060,14 @@ pub const Surface = extern struct {
//--------------------------------------------------------------- //---------------------------------------------------------------
// Signal Handlers // Signal Handlers
fn childExitedClose(
_: *ChildExited,
self: *Self,
) callconv(.c) void {
// This closes the surface with no confirmation.
self.close(false);
}
fn dtDrop( fn dtDrop(
_: *gtk.DropTarget, _: *gtk.DropTarget,
value: *gobject.Value, value: *gobject.Value,
@ -1762,6 +1815,7 @@ pub const Surface = extern struct {
fn init(class: *Class) callconv(.C) void { fn init(class: *Class) callconv(.C) void {
gobject.ext.ensureType(ResizeOverlay); gobject.ext.ensureType(ResizeOverlay);
gobject.ext.ensureType(ChildExited);
gtk.Widget.Class.setTemplateFromResource( gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class), class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{ comptime gresource.blueprint(.{
@ -1775,6 +1829,7 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("gl_area", .{}); class.bindTemplateChildPrivate("gl_area", .{});
class.bindTemplateChildPrivate("url_left", .{}); class.bindTemplateChildPrivate("url_left", .{});
class.bindTemplateChildPrivate("url_right", .{}); class.bindTemplateChildPrivate("url_right", .{});
class.bindTemplateChildPrivate("child_exited_overlay", .{});
class.bindTemplateChildPrivate("resize_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{});
class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("drop_target", .{});
class.bindTemplateChildPrivate("im_context", .{}); class.bindTemplateChildPrivate("im_context", .{});
@ -1802,6 +1857,7 @@ pub const Surface = extern struct {
class.bindTemplateCallback("im_commit", &imCommit); class.bindTemplateCallback("im_commit", &imCommit);
class.bindTemplateCallback("url_mouse_enter", &ecUrlMouseEnter); class.bindTemplateCallback("url_mouse_enter", &ecUrlMouseEnter);
class.bindTemplateCallback("url_mouse_leave", &ecUrlMouseLeave); class.bindTemplateCallback("url_mouse_leave", &ecUrlMouseLeave);
class.bindTemplateCallback("child_exited_close", &childExitedClose);
class.bindTemplateCallback("notify_config", &propConfig); class.bindTemplateCallback("notify_config", &propConfig);
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
@ -1810,6 +1866,7 @@ pub const Surface = extern struct {
// Properties // Properties
gobject.ext.registerProperties(class, &.{ gobject.ext.registerProperties(class, &.{
properties.config.impl, properties.config.impl,
properties.@"child-exited".impl,
properties.focused.impl, properties.focused.impl,
properties.@"mouse-shape".impl, properties.@"mouse-shape".impl,
properties.@"mouse-hidden".impl, properties.@"mouse-hidden".impl,

View File

@ -0,0 +1,272 @@
const std = @import("std");
const assert = std.debug.assert;
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const adw_version = @import("../adw_version.zig");
const apprt = @import("../../../apprt.zig");
const gresource = @import("../build/gresource.zig");
const i18n = @import("../../../os/main.zig").i18n;
const Common = @import("../class.zig").Common;
const log = std.log.scoped(.gtk_ghostty_surface_child_exited);
pub const SurfaceChildExited = if (adw_version.supportsBanner())
SurfaceChildExitedBanner
else
SurfaceChildExitedNoop;
/// Child exited overlay based on adw.Banner introduced in
/// Adwaita 1.3.
const SurfaceChildExitedBanner = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySurfaceChildExited",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const data = struct {
pub const name = "data";
const impl = gobject.ext.defineProperty(
name,
Self,
?*apprt.surface.Message.ChildExited,
.{
.nick = "Data",
.blurb = "The child exit data.",
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"data",
),
},
);
};
};
pub const signals = struct {
/// Emitted when the banner would like to be closed.
pub const @"close-request" = struct {
pub const name = "close-request";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
};
const Private = struct {
/// The child exited data sent by the apprt.
data: ?*apprt.surface.Message.ChildExited = null,
// Template bindings
banner: *adw.Banner,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.C) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
pub fn setData(
self: *Self,
data_: ?*const apprt.surface.Message.ChildExited,
) void {
const priv = self.private();
if (priv.data) |v| glib.ext.destroy(v);
const data = data_ orelse {
priv.data = null;
return;
};
const ptr = glib.ext.create(apprt.surface.Message.ChildExited);
ptr.* = data.*;
priv.data = ptr;
self.as(gobject.Object).notifyByPspec(properties.data.impl.param_spec);
}
//---------------------------------------------------------------
// Signal handlers
fn propData(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
const banner = priv.banner;
const data = priv.data orelse {
// Not localized on purpose.
banner.as(adw.Banner).setTitle("This is a bug in Ghostty. Please report it.");
return;
};
if (data.exit_code == 0) {
banner.as(adw.Banner).setTitle(i18n._("Command succeeded"));
self.as(gtk.Widget).addCssClass("normal");
self.as(gtk.Widget).removeCssClass("abnormal");
} else {
banner.as(adw.Banner).setTitle(i18n._("Command failed"));
self.as(gtk.Widget).removeCssClass("normal");
self.as(gtk.Widget).addCssClass("abnormal");
}
}
fn closeButtonClicked(
_: *adw.Banner,
self: *Self,
) callconv(.c) void {
signals.@"close-request".impl.emit(
self,
null,
.{},
null,
);
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.C) void {
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
fn finalize(self: *Self) callconv(.C) void {
const priv = self.private();
if (priv.data) |v| {
glib.ext.destroy(v);
priv.data = null;
}
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.C) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 3,
.name = "surface-child-exited",
}),
);
// Template bindings
class.bindTemplateChildPrivate("banner", .{});
class.bindTemplateCallback("clicked", &closeButtonClicked);
class.bindTemplateCallback("notify_data", &propData);
// Properties
gobject.ext.registerProperties(class, &.{
properties.data.impl,
});
// Signals
signals.@"close-request".impl.register(.{});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};
/// Empty widget that does nothing if we don't have a new enough
/// Adwaita version to support the child exited banner.
const SurfaceChildExitedNoop = extern struct {
/// Can be detected at comptime
pub const noop = true;
const Self = @This();
parent_instance: Parent,
pub const Parent = gtk.Widget;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySurfaceChildExited",
.classInit = &Class.init,
.parent_class = &Class.parent,
});
pub const signals = struct {
pub const @"close-request" = struct {
pub const name = "close-request";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
};
pub fn setData(
self: *Self,
_: ?*const apprt.surface.Message.ChildExited,
) void {
signals.@"close-request".impl.emit(
self,
null,
.{},
null,
);
}
const C = Common(Self, null);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.C) void {
_ = class;
signals.@"close-request".impl.register(.{});
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@ -69,3 +69,18 @@ label.url-overlay.right {
.clipboard-confirmation-dialog .clipboard-contents.blurred { .clipboard-confirmation-dialog .clipboard-contents.blurred {
filter: blur(5px); filter: blur(5px);
} }
/*
* Child Exited Overlay
*/
.child-exited.normal revealer widget {
background-color: rgba(38, 162, 105, 0.5);
/* after GTK 4.16 is a requirement, switch to the following:
/* background-color: color-mix(in srgb, var(--success-bg-color), transparent 50%); */
}
.child-exited.abnormal revealer widget {
background-color: rgba(192, 28, 40, 0.5);
/* after GTK 4.16 is a requirement, switch to the following:
/* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */
}

View File

@ -25,6 +25,12 @@ template $GhosttySurface: Adw.Bin {
use-es: false; use-es: false;
} }
[overlay]
$GhosttySurfaceChildExited child_exited_overlay {
visible: bind template.child-exited;
close-request => $child_exited_close();
}
[overlay] [overlay]
$GhosttyResizeOverlay resize_overlay { $GhosttyResizeOverlay resize_overlay {
styles [ styles [

View File

@ -0,0 +1,22 @@
using Gtk 4.0;
using Adw 1;
template $GhosttySurfaceChildExited: Adw.Bin {
styles [
"child-exited",
]
notify::data => $notify_data();
Adw.Bin {
Adw.Banner banner {
button-clicked => $clicked();
revealed: true;
// Not localized on purpose because it should never be seen.
title: "This is a bug in Ghostty. Please report it.";
button-label: _("Close");
halign: fill;
valign: end;
}
}
}

View File

@ -2,6 +2,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const build_config = @import("../build_config.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const Surface = @import("../Surface.zig"); const Surface = @import("../Surface.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
@ -107,6 +108,18 @@ pub const Message = union(enum) {
pub const ChildExited = extern struct { pub const ChildExited = extern struct {
exit_code: u32, exit_code: u32,
runtime_ms: u64, runtime_ms: u64,
/// Make this a valid gobject if we're in a GTK environment.
pub const getGObjectType = switch (build_config.app_runtime) {
.gtk,
.@"gtk-ng",
=> @import("gobject").ext.defineBoxed(
ChildExited,
.{ .name = "GhosttyApprtChildExited" },
),
.none => void,
};
}; };
}; };

View File

@ -82,6 +82,18 @@
fun:main fun:main
} }
{
VMware Graphics Driver
Memcheck:Leak
match-leak-kinds: possible
...
fun:vmw_fence_create
fun:vmw_ioctl_command
fun:vmw_swc_flush
fun:svga_context_flush
...
}
{ {
GSK Renderer GPU Stuff GSK Renderer GPU Stuff
Memcheck:Leak Memcheck:Leak