summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2025-07-21 09:32:21 -0700
committerGitHub <noreply@github.com>2025-07-21 09:32:21 -0700
commit55a384e16575150666d92290f2715f55915d2d0e (patch)
treed1982866c810313429867aeccb5d8f184e6c085a
parent805c8601a9a071d3029af18d5a1e58120d5c880e (diff)
parent2333815b6c424564ee2860fffb675c02d22853f3 (diff)
apprt/gtk-ng: implement quit timer, close app confirmation (#8006)
This PR tidies up our quit logic to match the GTK implementation, respecting quit delays and also showing a confirmation dialog if required. There shouldn't be anything surprising here, its mostly a copy/paste of the old logic with very small tweaks to fit the new style.
-rw-r--r--src/apprt/gtk-ng/build/gresource.zig1
-rw-r--r--src/apprt/gtk-ng/class/application.zig139
-rw-r--r--src/apprt/gtk-ng/class/close_confirmation_dialog.zig200
-rw-r--r--src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp10
4 files changed, 337 insertions, 13 deletions
diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig
index 103e8a063..dd9bf7de9 100644
--- a/src/apprt/gtk-ng/build/gresource.zig
+++ b/src/apprt/gtk-ng/build/gresource.zig
@@ -30,6 +30,7 @@ pub const icon_sizes: []const comptime_int = &.{ 16, 32, 128, 256, 512, 1024 };
///
/// These will be asserted to exist at runtime.
pub const blueprints: []const Blueprint = &.{
+ .{ .major = 1, .minor = 2, .name = "close-confirmation-dialog" },
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "surface" },
.{ .major = 1, .minor = 5, .name = "window" },
diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig
index 8865fd137..22a27c57d 100644
--- a/src/apprt/gtk-ng/class/application.zig
+++ b/src/apprt/gtk-ng/class/application.zig
@@ -28,6 +28,7 @@ const Common = @import("../class.zig").Common;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const Config = @import("config.zig").Config;
const Window = @import("window.zig").Window;
+const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog;
const log = std.log.scoped(.gtk_ghostty_application);
@@ -110,6 +111,15 @@ pub const Application = extern struct {
/// only be set by the main loop thread.
running: bool = false,
+ /// The timer used to quit the application after the last window is
+ /// closed. Even if there is no quit delay set, this is the state
+ /// used to determine to close the app.
+ quit_timer: union(enum) {
+ off,
+ active: c_uint,
+ expired,
+ } = .off,
+
/// If non-null, we're currently showing a config errors dialog.
/// This is a WeakRef because the dialog can close on its own
/// outside of our own lifecycle and that's okay.
@@ -309,6 +319,9 @@ pub const Application = extern struct {
// The final cleanup that is always required at the end of running.
defer {
+ // Ensure our timer source is removed
+ self.stopQuitTimer();
+
// Sync any remaining settings
gio.Settings.sync();
@@ -378,19 +391,64 @@ pub const Application = extern struct {
if (!config.@"quit-after-last-window-closed") break :q false;
// If the quit timer has expired, quit.
- // if (self.quit_timer == .expired) break :q true;
+ if (priv.quit_timer == .expired) break :q true;
// There's no quit timer running, or it hasn't expired, don't quit.
break :q false;
};
- if (must_quit) {
- //self.quit();
- priv.running = false;
- }
+ if (must_quit) self.quit();
}
}
+ /// Quit the application. This will start the process to stop the
+ /// run loop. It will not `posix.exit`.
+ pub fn quit(self: *Self) void {
+ const priv = self.private();
+
+ // If our run loop has already exited then we are done.
+ if (!priv.running) return;
+
+ // If our core app doesn't need to confirm quit then we
+ // can exit immediately.
+ if (!priv.core_app.needsConfirmQuit()) {
+ self.quitNow();
+ return;
+ }
+
+ // Show a confirmation dialog
+ const dialog: *CloseConfirmationDialog = .new(.app);
+
+ // Connect to the reload signal so we know to reload our config.
+ _ = CloseConfirmationDialog.signals.@"close-request".connect(
+ dialog,
+ *Application,
+ handleCloseConfirmation,
+ self,
+ .{},
+ );
+
+ // Show it
+ dialog.present();
+ }
+
+ fn quitNow(self: *Self) void {
+ // Get all our windows and destroy them, forcing them to
+ // free their memory.
+ const list = gtk.Window.listToplevels();
+ defer list.free();
+ list.foreach(struct {
+ fn callback(data: ?*anyopaque, _: ?*anyopaque) callconv(.c) void {
+ const ptr = data orelse return;
+ const window: *gtk.Window = @ptrCast(@alignCast(ptr));
+ window.destroy();
+ }
+ }.callback, null);
+
+ // Trigger our runloop exit.
+ self.private().running = false;
+ }
+
/// apprt API to perform an action.
pub fn performAction(
self: *Self,
@@ -418,6 +476,8 @@ pub const Application = extern struct {
.pwd => Action.pwd(target, value),
+ .quit => self.quit(),
+
.quit_timer => try Action.quitTimer(self, value),
.render => Action.render(self, target),
@@ -425,7 +485,6 @@ pub const Application = extern struct {
.set_title => Action.setTitle(target, value),
// Unimplemented but todo on gtk-ng branch
- .quit,
.close_window,
.toggle_maximize,
.toggle_fullscreen,
@@ -525,6 +584,51 @@ pub const Application = extern struct {
return &self.private().winproto;
}
+ /// This will get called when there are no more open surfaces.
+ fn startQuitTimer(self: *Self) void {
+ const priv = self.private();
+ const config = priv.config.get();
+
+ // Cancel any previous timer.
+ self.stopQuitTimer();
+
+ // This is a no-op unless we are configured to quit after last window is closed.
+ if (!config.@"quit-after-last-window-closed") return;
+
+ // If a delay is configured, set a timeout function to quit after the delay.
+ if (config.@"quit-after-last-window-closed-delay") |v| {
+ priv.quit_timer = .{
+ .active = glib.timeoutAdd(
+ v.asMilliseconds(),
+ handleQuitTimerExpired,
+ self,
+ ),
+ };
+ } else {
+ // If no delay is configured, treat it as expired.
+ priv.quit_timer = .expired;
+ }
+ }
+
+ /// This will get called when a new surface gets opened.
+ fn stopQuitTimer(self: *Self) void {
+ const priv = self.private();
+ switch (priv.quit_timer) {
+ .off => {},
+ .expired => priv.quit_timer = .off,
+ .active => |source| {
+ if (glib.Source.remove(source) == 0) {
+ log.warn(
+ "unable to remove quit timer source={d}",
+ .{source},
+ );
+ }
+
+ priv.quit_timer = .off;
+ },
+ }
+ }
+
//---------------------------------------------------------------
// Libghostty Callbacks
@@ -744,6 +848,20 @@ pub const Application = extern struct {
//---------------------------------------------------------------
// Signal Handlers
+ fn handleCloseConfirmation(
+ _: *CloseConfirmationDialog,
+ self: *Self,
+ ) callconv(.c) void {
+ self.quitNow();
+ }
+
+ fn handleQuitTimerExpired(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud));
+ const priv = self.private();
+ priv.quit_timer = .expired;
+ return 0;
+ }
+
fn handleStyleManagerDark(
style: *adw.StyleManager,
_: *gobject.ParamSpec,
@@ -967,14 +1085,9 @@ const Action = struct {
self: *Application,
mode: apprt.action.QuitTimer,
) !void {
- // TODO: An actual quit timer implementation. For now, we immediately
- // quit on no windows regardless of the config.
switch (mode) {
- .start => {
- self.private().running = false;
- },
-
- .stop => {},
+ .start => self.startQuitTimer(),
+ .stop => self.stopQuitTimer(),
}
}
diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig
new file mode 100644
index 000000000..a5c6a3c22
--- /dev/null
+++ b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig
@@ -0,0 +1,200 @@
+const std = @import("std");
+const adw = @import("adw");
+const gobject = @import("gobject");
+const gtk = @import("gtk");
+
+const gresource = @import("../build/gresource.zig");
+const i18n = @import("../../../os/main.zig").i18n;
+const adw_version = @import("../adw_version.zig");
+const Common = @import("../class.zig").Common;
+const Config = @import("config.zig").Config;
+const Dialog = @import("dialog.zig").Dialog;
+
+const log = std.log.scoped(.gtk_ghostty_config_errors_dialog);
+
+pub const CloseConfirmationDialog = extern struct {
+ const Self = @This();
+ parent_instance: Parent,
+ pub const Parent = Dialog;
+ pub const getGObjectType = gobject.ext.defineClass(Self, .{
+ .name = "GhosttyCloseConfirmationDialog",
+ .instanceInit = &init,
+ .classInit = &Class.init,
+ .parent_class = &Class.parent,
+ .private = .{ .Type = Private, .offset = &Private.offset },
+ });
+
+ pub const properties = struct {
+ pub const target = struct {
+ pub const name = "target";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ Target,
+ .{
+ .nick = "Target",
+ .blurb = "The target for this close confirmation.",
+ .default = .app,
+ .accessor = gobject.ext.privateFieldAccessor(
+ Self,
+ Private,
+ &Private.offset,
+ "target",
+ ),
+ },
+ );
+ };
+ };
+
+ 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,
+ );
+ };
+ };
+
+ const Private = struct {
+ target: Target,
+ pub var offset: c_int = 0;
+ };
+
+ pub fn new(target: Target) *Self {
+ return gobject.ext.newInstance(Self, .{
+ .target = target,
+ });
+ }
+
+ fn init(self: *Self, _: *Class) callconv(.C) void {
+ gtk.Widget.initTemplate(self.as(gtk.Widget));
+
+ // Setup our title/body text.
+ const priv = self.private();
+ self.as(Dialog.Parent).setHeading(priv.target.title());
+ self.as(Dialog.Parent).setBody(priv.target.body());
+ }
+
+ pub fn present(self: *Self) void {
+ const priv = self.private();
+ self.as(Dialog).present(priv.target.dialogParent());
+ }
+
+ pub fn close(self: *Self) void {
+ self.as(Dialog).close();
+ }
+
+ fn response(
+ self: *Self,
+ response_id: [*:0]const u8,
+ ) callconv(.C) void {
+ if (std.mem.orderZ(u8, response_id, "close") != .eq) return;
+ signals.@"close-request".impl.emit(
+ self,
+ null,
+ .{},
+ null,
+ );
+ }
+
+ 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),
+ );
+ }
+
+ 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 {
+ gobject.ext.ensureType(Dialog);
+ gtk.Widget.Class.setTemplateFromResource(
+ class.as(gtk.Widget.Class),
+ comptime gresource.blueprint(.{
+ .major = 1,
+ .minor = 2,
+ .name = "close-confirmation-dialog",
+ }),
+ );
+
+ // Properties
+ gobject.ext.registerProperties(class, &.{
+ properties.target.impl,
+ });
+
+ // Signals
+ signals.@"close-request".impl.register(.{});
+
+ // Virtual methods
+ gobject.Object.virtual_methods.dispose.implement(class, &dispose);
+ Dialog.virtual_methods.response.implement(class, &response);
+ }
+
+ pub const as = C.Class.as;
+ };
+};
+
+/// The target of a close dialog.
+///
+/// This is here so that we can consolidate all logic related to
+/// prompting the user and closing windows/tabs/surfaces/etc.
+/// together into one struct that is the sole source of truth.
+pub const Target = enum(c_int) {
+ app,
+
+ pub fn title(self: Target) [*:0]const u8 {
+ return switch (self) {
+ .app => i18n._("Quit Ghostty?"),
+ };
+ }
+
+ pub fn body(self: Target) [*:0]const u8 {
+ return switch (self) {
+ .app => i18n._("All terminal sessions will be terminated."),
+ };
+ }
+
+ pub fn dialogParent(self: Target) ?*gtk.Widget {
+ return switch (self) {
+ .app => {
+ // Find the currently focused window.
+ const list = gtk.Window.listToplevels();
+ defer list.free();
+ const focused = list.findCustom(null, findActiveWindow);
+ return @ptrCast(@alignCast(focused.f_data));
+ },
+ };
+ }
+
+ pub const getGObjectType = gobject.ext.defineEnum(
+ Target,
+ .{ .name = "GhosttyCloseConfirmationDialogTarget" },
+ );
+};
+
+fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int {
+ const window: *gtk.Window = @ptrCast(@alignCast(@constCast(data orelse return -1)));
+
+ // Confusingly, `isActive` returns 1 when active,
+ // but we want to return 0 to indicate equality.
+ // Abusing integers to be enums and booleans is a terrible idea, C.
+ return if (window.isActive() != 0) 0 else -1;
+}
diff --git a/src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp b/src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp
new file mode 100644
index 000000000..c2dcbadbd
--- /dev/null
+++ b/src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp
@@ -0,0 +1,10 @@
+using Gtk 4.0;
+// This is unused but if we remove it we get a blueprint-compiler error.
+using Adw 1;
+
+template $GhosttyCloseConfirmationDialog: $GhosttyDialog {
+ responses [
+ cancel: _("Cancel"),
+ close: _("Close") destructive,
+ ]
+}