From 23d2d4ec7010c72edc854b4cc472aad08d10abec Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 13 Feb 2025 13:45:32 +0100 Subject: [PATCH] gtk: use AdwAlertDialog for close dialogs --- src/apprt/gtk/App.zig | 69 ++-------------- src/apprt/gtk/CloseDialog.zig | 151 ++++++++++++++++++++++++++++++++++ src/apprt/gtk/Surface.zig | 65 +++------------ src/apprt/gtk/Tab.zig | 53 +++--------- src/apprt/gtk/TabView.zig | 1 + src/apprt/gtk/Window.zig | 61 ++++---------- src/apprt/gtk/adwaita.zig | 5 ++ 7 files changed, 201 insertions(+), 204 deletions(-) create mode 100644 src/apprt/gtk/CloseDialog.zig diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index daeaea583..ba456c7a6 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -37,6 +37,7 @@ const Surface = @import("Surface.zig"); const Window = @import("Window.zig"); const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig"); const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); +const CloseDialog = @import("CloseDialog.zig"); const Split = @import("Split.zig"); const c = @import("c.zig").c; const i18n = @import("i18n.zig"); @@ -45,6 +46,7 @@ const inspector = @import("inspector.zig"); const key = @import("key.zig"); const winproto = @import("winproto.zig"); const testing = std.testing; +const adwaita = @import("adwaita.zig"); const log = std.log.scoped(.gtk); @@ -1428,61 +1430,19 @@ fn quit(self: *App) void { // If we're already not running, do nothing. if (!self.running) return; - // If we have no toplevel windows, then we're done. - const list = c.gtk_window_list_toplevels(); - if (list == null) { - self.running = false; - return; - } - c.g_list_free(list); - // If the app says we don't need to confirm, then we can quit now. if (!self.core_app.needsConfirmQuit()) { self.quitNow(); return; } - // If we have windows, then we want to confirm that we want to exit. - const alert = c.gtk_message_dialog_new( - null, - c.GTK_DIALOG_MODAL, - c.GTK_MESSAGE_QUESTION, - c.GTK_BUTTONS_YES_NO, - "Quit Ghostty?", - ); - c.gtk_message_dialog_format_secondary_text( - @ptrCast(alert), - "All active terminal sessions will be terminated.", - ); - - // We want the "yes" to appear destructive. - const yes_widget = c.gtk_dialog_get_widget_for_response( - @ptrCast(alert), - c.GTK_RESPONSE_YES, - ); - c.gtk_widget_add_css_class(yes_widget, "destructive-action"); - - // We want the "no" to be the default action - c.gtk_dialog_set_default_response( - @ptrCast(alert), - c.GTK_RESPONSE_NO, - ); - - _ = c.g_signal_connect_data( - alert, - "response", - c.G_CALLBACK(>kQuitConfirmation), - self, - null, - c.G_CONNECT_DEFAULT, - ); - - c.gtk_widget_show(alert); + CloseDialog.show(.{ .app = self }) catch |err| { + log.err("failed to open close dialog={}", .{err}); + }; } /// This immediately destroys all windows, forcing the application to quit. -fn quitNow(self: *App) void { - _ = self; +pub fn quitNow(self: *App) void { const list = c.gtk_window_list_toplevels(); defer c.g_list_free(list); c.g_list_foreach(list, struct { @@ -1493,23 +1453,8 @@ fn quitNow(self: *App) void { c.gtk_window_destroy(window); } }.callback, null); -} -fn gtkQuitConfirmation( - alert: *c.GtkMessageDialog, - response: c.gint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *App = @ptrCast(@alignCast(ud orelse return)); - - // Close the alert window - c.gtk_window_destroy(@ptrCast(alert)); - - // If we didn't confirm then we're done - if (response != c.GTK_RESPONSE_YES) return; - - // Force close all open windows - self.quitNow(); + self.running = false; } /// This is called by the `activate` signal. This is sent on program startup and diff --git a/src/apprt/gtk/CloseDialog.zig b/src/apprt/gtk/CloseDialog.zig new file mode 100644 index 000000000..2077f9b76 --- /dev/null +++ b/src/apprt/gtk/CloseDialog.zig @@ -0,0 +1,151 @@ +const CloseDialog = @This(); +const std = @import("std"); + +const gobject = @import("gobject"); +const gio = @import("gio"); +const adw = @import("adw"); +const gtk = @import("gtk"); + +const App = @import("App.zig"); +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Surface = @import("Surface.zig"); +const adwaita = @import("adwaita.zig"); +const i18n = @import("i18n.zig"); + +const log = std.log.scoped(.close_dialog); + +// We don't fall back to the GTK Message/AlertDialogs since +// we don't plan to support libadw < 1.2 as of time of writing +// TODO: Switch to just adw.AlertDialog when we drop Debian 12 support +const DialogType = if (adwaita.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; + +/// Open the dialog when the user requests to close a window/tab/split/etc. +/// but there's still one or more running processes inside the target that +/// cannot be closed automatically. We then ask the user whether they want +/// to terminate existing processes. +pub fn show(target: Target) !void { + // If we don't have a possible window to ask the user, + // in most situations (e.g. when a split isn't attached to a window) + // we should just close unconditionally. + const dialog_window = target.dialogWindow() orelse { + target.close(); + return; + }; + + const dialog = switch (DialogType) { + adw.AlertDialog => adw.AlertDialog.new(target.title(), target.body()), + adw.MessageDialog => adw.MessageDialog.new(dialog_window, target.title(), target.body()), + else => unreachable, + }; + + // AlertDialog and MessageDialog have essentially the same API, + // so we can cheat a little here + dialog.addResponse("cancel", i18n._("Cancel")); + dialog.setCloseResponse("cancel"); + + dialog.addResponse("close", i18n._("Close")); + dialog.setResponseAppearance("close", .destructive); + + // Need a stable pointer + const target_ptr = try target.allocator().create(Target); + target_ptr.* = target; + + _ = DialogType.signals.response.connect(dialog, *Target, responseCallback, target_ptr, .{}); + + switch (DialogType) { + adw.AlertDialog => dialog.as(adw.Dialog).present(dialog_window.as(gtk.Widget)), + adw.MessageDialog => dialog.as(gtk.Window).present(), + else => unreachable, + } +} + +fn responseCallback( + _: *DialogType, + response: [*:0]const u8, + target: *Target, +) callconv(.C) void { + const alloc = target.allocator(); + defer alloc.destroy(target); + + if (std.mem.orderZ(u8, response, "close") == .eq) target.close(); +} + +/// 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 = union(enum) { + app: *App, + window: *Window, + tab: *Tab, + surface: *Surface, + + pub fn title(self: Target) [*:0]const u8 { + return switch (self) { + .app => i18n._("Quit Ghostty?"), + .window => i18n._("Close Window?"), + .tab => i18n._("Close Tab?"), + .surface => i18n._("Close Split?"), + }; + } + + pub fn body(self: Target) [*:0]const u8 { + return switch (self) { + .app => i18n._("All terminal sessions will be terminated."), + .window => i18n._("All terminal sessions in this window will be terminated."), + .tab => i18n._("All terminal sessions in this tab will be terminated."), + .surface => i18n._("The currently running process in this split will be terminated."), + }; + } + + pub fn dialogWindow(self: Target) ?*gtk.Window { + return switch (self) { + .app => { + // Find the currently focused window. We don't store this + // anywhere inside the App structure for some reason, so + // we have to query every single open window and see which + // one is active (focused and receiving keyboard input) + const list = gtk.Window.listToplevels(); + defer list.free(); + + const focused = list.findCustom(null, findActiveWindow); + return @ptrCast(@alignCast(focused.f_data)); + }, + .window => |v| @ptrCast(v.window), + .tab => |v| @ptrCast(v.window.window), + .surface => |v| surface: { + const window_ = v.container.window() orelse return null; + break :surface @ptrCast(window_.window); + }, + }; + } + + fn allocator(self: Target) std.mem.Allocator { + return switch (self) { + .app => |v| v.core_app.alloc, + .window => |v| v.app.core_app.alloc, + .tab => |v| v.window.app.core_app.alloc, + .surface => |v| v.app.core_app.alloc, + }; + } + + fn close(self: Target) void { + switch (self) { + .app => |v| v.quitNow(), + .window => |v| gtk.Window.destroy(@ptrCast(v.window)), + .tab => |v| v.remove(), + .surface => |v| v.container.remove(), + } + } +}; + +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/Surface.zig b/src/apprt/gtk/Surface.zig index 9795762ea..ae3ca12d6 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -30,6 +30,7 @@ const Menu = @import("menu.zig").Menu; const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); const ResizeOverlay = @import("ResizeOverlay.zig"); const URLWidget = @import("URLWidget.zig"); +const CloseDialog = @import("CloseDialog.zig"); const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig").c; @@ -663,54 +664,22 @@ pub fn redraw(self: *Surface) void { } /// Close this surface. -pub fn close(self: *Surface, processActive: bool) void { +pub fn close(self: *Surface, process_active: bool) void { + self.closeWithConfirmation(process_active, .{ .surface = self }); +} + +/// Close this surface. +pub fn closeWithConfirmation(self: *Surface, process_active: bool, target: CloseDialog.Target) void { self.setSplitZoom(false); - // If we're not part of a window hierarchy, we never confirm - // so we can just directly remove ourselves and exit. - const window = self.container.window() orelse { - self.container.remove(); - return; - }; - - // If we have no process active we can just exit immediately. - if (!processActive) { + if (!process_active) { self.container.remove(); return; } - // Setup our basic message - const alert = c.gtk_message_dialog_new( - window.window, - c.GTK_DIALOG_MODAL, - c.GTK_MESSAGE_QUESTION, - c.GTK_BUTTONS_YES_NO, - "Close this terminal?", - ); - c.gtk_message_dialog_format_secondary_text( - @ptrCast(alert), - "There is still a running process in the terminal. " ++ - "Closing the terminal will kill this process. " ++ - "Are you sure you want to close the terminal?\n\n" ++ - "Click 'No' to cancel and return to your terminal.", - ); - - // We want the "yes" to appear destructive. - const yes_widget = c.gtk_dialog_get_widget_for_response( - @ptrCast(alert), - c.GTK_RESPONSE_YES, - ); - c.gtk_widget_add_css_class(yes_widget, "destructive-action"); - - // We want the "no" to be the default action - c.gtk_dialog_set_default_response( - @ptrCast(alert), - c.GTK_RESPONSE_NO, - ); - - _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kCloseConfirmation), self, null, c.G_CONNECT_DEFAULT); - - c.gtk_widget_show(alert); + CloseDialog.show(target) catch |err| { + log.err("failed to open close dialog={}", .{err}); + }; } pub fn controlInspector( @@ -2081,18 +2050,6 @@ pub fn dimSurface(self: *Surface) void { c.gtk_overlay_add_overlay(self.overlay, self.unfocused_widget.?); } -fn gtkCloseConfirmation( - alert: *c.GtkMessageDialog, - response: c.gint, - ud: ?*anyopaque, -) callconv(.C) void { - c.gtk_window_destroy(@ptrCast(alert)); - if (response == c.GTK_RESPONSE_YES) { - const self = userdataSelf(ud.?); - self.container.remove(); - } -} - fn userdataSelf(ud: *anyopaque) *Surface { return @ptrCast(@alignCast(ud)); } diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 214928790..78a6253d7 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -13,6 +13,8 @@ const CoreSurface = @import("../../Surface.zig"); const Surface = @import("Surface.zig"); const Window = @import("Window.zig"); const c = @import("c.zig").c; +const adwaita = @import("adwaita.zig"); +const CloseDialog = @import("CloseDialog.zig"); const log = std.log.scoped(.gtk); @@ -132,54 +134,23 @@ fn needsConfirm(elem: Surface.Container.Elem) bool { /// Close the tab, asking for confirmation if any surface requests it. pub fn closeWithConfirmation(tab: *Tab) void { switch (tab.elem) { - .surface => |s| s.close(s.core_surface.needsConfirmQuit()), + .surface => |s| s.closeWithConfirmation( + s.core_surface.needsConfirmQuit(), + .{ .tab = tab }, + ), .split => |s| { - if (needsConfirm(s.top_left) or needsConfirm(s.bottom_right)) { - const alert = c.gtk_message_dialog_new( - tab.window.window, - c.GTK_DIALOG_MODAL, - c.GTK_MESSAGE_QUESTION, - c.GTK_BUTTONS_YES_NO, - "Close this tab?", - ); - c.gtk_message_dialog_format_secondary_text( - @ptrCast(alert), - "All terminal sessions in this tab will be terminated.", - ); - - // We want the "yes" to appear destructive. - const yes_widget = c.gtk_dialog_get_widget_for_response( - @ptrCast(alert), - c.GTK_RESPONSE_YES, - ); - c.gtk_widget_add_css_class(yes_widget, "destructive-action"); - - // We want the "no" to be the default action - c.gtk_dialog_set_default_response( - @ptrCast(alert), - c.GTK_RESPONSE_NO, - ); - - _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kTabCloseConfirmation), tab, null, c.G_CONNECT_DEFAULT); - c.gtk_widget_show(alert); + if (!needsConfirm(s.top_left) and !needsConfirm(s.bottom_right)) { + tab.remove(); return; } - tab.remove(); + + CloseDialog.show(.{ .tab = tab }) catch |err| { + log.err("failed to open close dialog={}", .{err}); + }; }, } } -fn gtkTabCloseConfirmation( - alert: *c.GtkMessageDialog, - response: c.gint, - ud: ?*anyopaque, -) callconv(.C) void { - const tab: *Tab = @ptrCast(@alignCast(ud)); - c.gtk_window_destroy(@ptrCast(alert)); - if (response != c.GTK_RESPONSE_YES) return; - tab.remove(); -} - fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { _ = v; log.debug("tab box destroy", .{}); diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig index defdedace..bb93765ee 100644 --- a/src/apprt/gtk/TabView.zig +++ b/src/apprt/gtk/TabView.zig @@ -252,6 +252,7 @@ fn adwClosePage( const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0)); self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close)); if (!self.forcing_close) tab.closeWithConfirmation(); + return 1; } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index a031998ce..0e0f71662 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -31,6 +31,7 @@ const adwaita = @import("adwaita.zig"); const gtk_key = @import("key.zig"); const TabView = @import("TabView.zig"); const HeaderBar = @import("headerbar.zig"); +const CloseDialog = @import("CloseDialog.zig"); const version = @import("version.zig"); const winproto = @import("winproto.zig"); const i18n = @import("i18n.zig"); @@ -824,11 +825,7 @@ pub fn close(self: *Window) void { window.destroy(); } -fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { - _ = v; - log.debug("window close request", .{}); - const self = userdataSelf(ud.?); - +pub fn closeWithConfirmation(self: *Window) void { // If none of our surfaces need confirmation, we can just exit. for (self.app.core_app.surfaces.items) |surface| { if (surface.container.window()) |window| { @@ -837,51 +834,21 @@ fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { } } else { self.close(); - return true; + return; } - // Setup our basic message - const alert = c.gtk_message_dialog_new( - self.window, - c.GTK_DIALOG_MODAL, - c.GTK_MESSAGE_QUESTION, - c.GTK_BUTTONS_YES_NO, - "Close this window?", - ); - c.gtk_message_dialog_format_secondary_text( - @ptrCast(alert), - "All terminal sessions in this window will be terminated.", - ); - - // We want the "yes" to appear destructive. - const yes_widget = c.gtk_dialog_get_widget_for_response( - @ptrCast(alert), - c.GTK_RESPONSE_YES, - ); - c.gtk_widget_add_css_class(yes_widget, "destructive-action"); - - // We want the "no" to be the default action - c.gtk_dialog_set_default_response( - @ptrCast(alert), - c.GTK_RESPONSE_NO, - ); - - _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kCloseConfirmation), self, null, c.G_CONNECT_DEFAULT); - - c.gtk_widget_show(alert); - return true; + CloseDialog.show(.{ .window = self }) catch |err| { + log.err("failed to open close dialog={}", .{err}); + }; } -fn gtkCloseConfirmation( - alert: *c.GtkMessageDialog, - response: c.gint, - ud: ?*anyopaque, -) callconv(.C) void { - c.gtk_window_destroy(@ptrCast(alert)); - if (response == c.GTK_RESPONSE_YES) { - const self = userdataSelf(ud.?); - self.close(); - } +fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { + _ = v; + log.debug("window close request", .{}); + const self = userdataSelf(ud.?); + + self.closeWithConfirmation(); + return true; } /// "destroy" signal for the window @@ -978,7 +945,7 @@ fn gtkActionClose( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - self.close(); + self.closeWithConfirmation(); } fn gtkActionNewWindow( diff --git a/src/apprt/gtk/adwaita.zig b/src/apprt/gtk/adwaita.zig index 885627fa4..67f2dae61 100644 --- a/src/apprt/gtk/adwaita.zig +++ b/src/apprt/gtk/adwaita.zig @@ -56,3 +56,8 @@ test "versionAtLeast" { try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1)); } + +// Whether AdwDialog, AdwAlertDialog, etc. are supported (1.5+) +pub fn supportsDialogs() bool { + return versionAtLeast(1, 5, 0); +}