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.
This commit is contained in:
Mitchell Hashimoto
2025-07-21 09:32:21 -07:00
committed by GitHub
4 changed files with 337 additions and 13 deletions

View File

@ -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. /// These will be asserted to exist at runtime.
pub const blueprints: []const Blueprint = &.{ 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 = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 2, .name = "surface" },
.{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "window" },

View File

@ -28,6 +28,7 @@ const Common = @import("../class.zig").Common;
const WeakRef = @import("../weak_ref.zig").WeakRef; const WeakRef = @import("../weak_ref.zig").WeakRef;
const Config = @import("config.zig").Config; const Config = @import("config.zig").Config;
const Window = @import("window.zig").Window; const Window = @import("window.zig").Window;
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog;
const log = std.log.scoped(.gtk_ghostty_application); 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. /// only be set by the main loop thread.
running: bool = false, 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. /// If non-null, we're currently showing a config errors dialog.
/// This is a WeakRef because the dialog can close on its own /// This is a WeakRef because the dialog can close on its own
/// outside of our own lifecycle and that's okay. /// 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. // The final cleanup that is always required at the end of running.
defer { defer {
// Ensure our timer source is removed
self.stopQuitTimer();
// Sync any remaining settings // Sync any remaining settings
gio.Settings.sync(); gio.Settings.sync();
@ -378,19 +391,64 @@ pub const Application = extern struct {
if (!config.@"quit-after-last-window-closed") break :q false; if (!config.@"quit-after-last-window-closed") break :q false;
// If the quit timer has expired, quit. // 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. // There's no quit timer running, or it hasn't expired, don't quit.
break :q false; break :q false;
}; };
if (must_quit) { if (must_quit) self.quit();
//self.quit();
priv.running = false;
}
} }
} }
/// 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. /// apprt API to perform an action.
pub fn performAction( pub fn performAction(
self: *Self, self: *Self,
@ -418,6 +476,8 @@ pub const Application = extern struct {
.pwd => Action.pwd(target, value), .pwd => Action.pwd(target, value),
.quit => self.quit(),
.quit_timer => try Action.quitTimer(self, value), .quit_timer => try Action.quitTimer(self, value),
.render => Action.render(self, target), .render => Action.render(self, target),
@ -425,7 +485,6 @@ pub const Application = extern struct {
.set_title => Action.setTitle(target, value), .set_title => Action.setTitle(target, value),
// Unimplemented but todo on gtk-ng branch // Unimplemented but todo on gtk-ng branch
.quit,
.close_window, .close_window,
.toggle_maximize, .toggle_maximize,
.toggle_fullscreen, .toggle_fullscreen,
@ -525,6 +584,51 @@ pub const Application = extern struct {
return &self.private().winproto; 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 // Libghostty Callbacks
@ -744,6 +848,20 @@ pub const Application = extern struct {
//--------------------------------------------------------------- //---------------------------------------------------------------
// Signal Handlers // 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( fn handleStyleManagerDark(
style: *adw.StyleManager, style: *adw.StyleManager,
_: *gobject.ParamSpec, _: *gobject.ParamSpec,
@ -967,14 +1085,9 @@ const Action = struct {
self: *Application, self: *Application,
mode: apprt.action.QuitTimer, mode: apprt.action.QuitTimer,
) !void { ) !void {
// TODO: An actual quit timer implementation. For now, we immediately
// quit on no windows regardless of the config.
switch (mode) { switch (mode) {
.start => { .start => self.startQuitTimer(),
self.private().running = false; .stop => self.stopQuitTimer(),
},
.stop => {},
} }
} }

View File

@ -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;
}

View File

@ -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,
]
}