apprt/gtk-ng: child exited overlay

This commit is contained in:
Mitchell Hashimoto
2025-07-23 13:38:59 -07:00
parent f7424c8a76
commit cd664078ad
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 = "resize-overlay" },
.{ .major = 1, .minor = 2, .name = "surface" },
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
.{ .major = 1, .minor = 5, .name = "window" },
};

View File

@ -485,6 +485,8 @@ pub const Application = extern struct {
.set_title => Action.setTitle(target, value),
.show_child_exited => return Action.showChildExited(target, value),
.show_gtk_inspector => Action.showGtkInspector(),
// Unimplemented but todo on gtk-ng branch
@ -514,7 +516,6 @@ pub const Application = extern struct {
.ring_bell,
.toggle_command_palette,
.open_url,
.show_child_exited,
.close_all_windows,
.float_window,
.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 {
gtk.Window.setInteractiveDebugging(@intFromBool(true));
}

View File

@ -21,6 +21,7 @@ const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
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 name = "focused";
const impl = gobject.ext.defineProperty(
@ -284,7 +305,11 @@ pub const Surface = extern struct {
/// True when we have a precision scroll in progress
precision_scroll: bool = false,
/// True when the child has exited.
child_exited: bool = false,
// Template binds
child_exited_overlay: *ChildExited,
drop_target: *gtk.DropTarget,
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 {
return self.private().cgroup_path;
}
@ -1015,6 +1060,14 @@ pub const Surface = extern struct {
//---------------------------------------------------------------
// Signal Handlers
fn childExitedClose(
_: *ChildExited,
self: *Self,
) callconv(.c) void {
// This closes the surface with no confirmation.
self.close(false);
}
fn dtDrop(
_: *gtk.DropTarget,
value: *gobject.Value,
@ -1762,6 +1815,7 @@ pub const Surface = extern struct {
fn init(class: *Class) callconv(.C) void {
gobject.ext.ensureType(ResizeOverlay);
gobject.ext.ensureType(ChildExited);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
@ -1775,6 +1829,7 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("gl_area", .{});
class.bindTemplateChildPrivate("url_left", .{});
class.bindTemplateChildPrivate("url_right", .{});
class.bindTemplateChildPrivate("child_exited_overlay", .{});
class.bindTemplateChildPrivate("resize_overlay", .{});
class.bindTemplateChildPrivate("drop_target", .{});
class.bindTemplateChildPrivate("im_context", .{});
@ -1802,6 +1857,7 @@ pub const Surface = extern struct {
class.bindTemplateCallback("im_commit", &imCommit);
class.bindTemplateCallback("url_mouse_enter", &ecUrlMouseEnter);
class.bindTemplateCallback("url_mouse_leave", &ecUrlMouseLeave);
class.bindTemplateCallback("child_exited_close", &childExitedClose);
class.bindTemplateCallback("notify_config", &propConfig);
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
@ -1810,6 +1866,7 @@ pub const Surface = extern struct {
// Properties
gobject.ext.registerProperties(class, &.{
properties.config.impl,
properties.@"child-exited".impl,
properties.focused.impl,
properties.@"mouse-shape".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 {
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;
}
[overlay]
$GhosttySurfaceChildExited child_exited_overlay {
visible: bind template.child-exited;
close-request => $child_exited_close();
}
[overlay]
$GhosttyResizeOverlay resize_overlay {
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 apprt = @import("../apprt.zig");
const build_config = @import("../build_config.zig");
const App = @import("../App.zig");
const Surface = @import("../Surface.zig");
const renderer = @import("../renderer.zig");
@ -107,6 +108,18 @@ pub const Message = union(enum) {
pub const ChildExited = extern struct {
exit_code: u32,
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
}
{
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
Memcheck:Leak