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