gtk: use AdwAlertDialog for close dialogs

This commit is contained in:
Leah Amelia Chen
2025-02-13 13:45:32 +01:00
parent e07b6fdf6b
commit 23d2d4ec70
7 changed files with 201 additions and 204 deletions

View File

@ -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(&gtkQuitConfirmation),
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

View File

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

View File

@ -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(&gtkCloseConfirmation), 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));
}

View File

@ -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(&gtkTabCloseConfirmation), 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", .{});

View File

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

View File

@ -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(&gtkCloseConfirmation), 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(

View File

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