Added a hopeful quit blocking dialog.

This commit is contained in:
Greyson Parrelli
2026-08-25 16:24:00 -04:00
parent 3721ec38ba
commit 198258c181
4 changed files with 292 additions and 2 deletions
+131 -2
View File
@@ -106,6 +106,21 @@ updating: bool = false,
/// doesn't try to close a tab we're already destroying.
closing: bool = false,
/// Set once quitting has been settled, so the `close-request` that follows goes
/// straight through instead of asking the same question twice. See `quit`.
quit_confirmed: bool = false,
/// Set while the confirmation is on screen.
///
/// Belt and braces. libadwaita gets to a close-request before this does while
/// one of its dialogs is open, and answers it by closing the dialog — so a
/// second press of the window-manager binding cancels the question rather than
/// reaching here at all. This is what catches it if that ever stops being true,
/// because the alternative is a second dialog stacked on the first. It stays in
/// step either way: every route out of the dialog emits `response`, including
/// the one libadwaita takes.
confirming_quit: bool = false,
/// Saved tab templates, read from the config file at startup.
layouts: Layouts,
@@ -413,6 +428,17 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
);
window.as(gtk.Widget).addController(keys.as(gtk.EventController));
// Asking before the window goes away. This has to be `close-request`
// rather than `destroy`: it is the one signal that can still say no, and
// by the time `destroy` arrives the decision has been made.
_ = gtk.Window.signals.close_request.connect(
window,
*Window,
&onCloseRequest,
self,
.{},
);
// Free our own state once GTK is done with the window. Doing this on
// `destroy` rather than `close-request` means no further events can
// arrive for widgets whose user data we're about to free.
@@ -1517,8 +1543,10 @@ fn closeTab(self: *Window, tab: *Tab) void {
self.discardTab(tab);
if (self.tabs.items.len == 0) {
// Teardown of our own state happens in onDestroy.
self.window.as(gtk.Window).close();
// Teardown of our own state happens in onDestroy. Without asking: the
// last tab closing *is* the answer to the question, and there is
// nothing left for a confirmation to offer to keep.
self.quit();
return;
}
@@ -1800,6 +1828,107 @@ fn onViewEmpty(ctx: ?*anyopaque) void {
tab.window.closeTab(tab);
}
// -------------------------------------------------------------------------
// Quitting
//
// A window here is not a shell but an arrangement of them — tabs, splits, and
// whatever each one is in the middle of — and closing it exits every one of
// those at once. That is a lot to hang off a single keystroke, which under a
// tiling compositor is exactly where it hangs: niri's close binding is a
// modifier away from the ones that move focus between windows, and it does not
// ask. So we do.
/// The window has been asked to close. Returning non-zero keeps it.
///
/// Three ways through here. Confirmation switched off, or a decision already
/// taken, and the close goes ahead. Otherwise the question goes up and the
/// window stays until it is answered.
///
/// Note that a close arriving *while* the question is up does not reach this at
/// all — see `confirming_quit`. It dismisses the dialog, which is a cancel, so
/// leaning on the binding never costs the window.
fn onCloseRequest(_: *adw.ApplicationWindow, self: *Window) callconv(.c) c_int {
if (self.quit_confirmed) return 0;
if (!Settings.get().confirm_quit) return 0;
// Already asked, and still waiting for the answer. Unreachable in practice;
// see the field.
if (self.confirming_quit) return 1;
self.confirming_quit = true;
self.askBeforeQuitting();
return 1;
}
/// Close the window without asking.
///
/// For the paths where the question has already been answered — the
/// confirmation being accepted, and the last tab closing, which is a decision
/// to close this window made one tab at a time.
fn quit(self: *Window) void {
self.quit_confirmed = true;
self.window.as(gtk.Window).close();
}
/// Put the question up: an Adwaita alert dialog over the window it is about.
fn askBeforeQuitting(self: *Window) void {
const dialog = adw.AlertDialog.new("Quit Playpen?", null);
// The count is the reason for asking at all. "Quit?" over one shell is a
// shrug; over nine tabs of work it is the whole point of the dialog, and
// it is also the quickest way to notice you are about to close the wrong
// window. The buffer is sized so the format cannot fail, but the fallback
// says the same thing without the number rather than nothing at all.
var buf: [160]u8 = undefined;
const body: [:0]const u8 = std.fmt.bufPrintZ(
&buf,
"{d} tab{s} will close, and every shell in {s} will exit.",
.{
self.tabs.items.len,
if (self.tabs.items.len == 1) "" else "s",
if (self.tabs.items.len == 1) "it" else "them",
},
) catch "Every tab will close, and every shell in them will exit.";
dialog.setBody(body.ptr);
dialog.addResponse("cancel", "Keep Working");
dialog.addResponse("quit", "Quit");
// Destructive, and *not* the default: Escape and Enter both have to land on
// keeping the window, or the dialog is one more keystroke to fumble rather
// than a guard against fumbling one.
dialog.setResponseAppearance("quit", .destructive);
dialog.setDefaultResponse("cancel");
dialog.setCloseResponse("cancel");
_ = adw.AlertDialog.signals.response.connect(
dialog,
*Window,
&onQuitResponse,
self,
.{},
);
dialog.as(adw.Dialog).present(self.window.as(gtk.Widget));
}
/// The question was answered — by a button, by Escape, or by the dialog being
/// dismissed, which `close_response` has already turned into "cancel".
fn onQuitResponse(_: *adw.AlertDialog, response: [*:0]u8, self: *Window) callconv(.c) void {
self.confirming_quit = false;
if (!std.mem.eql(u8, std.mem.span(response), "quit")) return;
// Not from here: this runs while the dialog is closing, and destroying the
// window it is parented to out from under it is how that turns into a
// crash. One trip back through the main loop and the dialog is gone.
_ = glib.idleAddOnce(&onQuitIdle, self);
}
fn onQuitIdle(data: ?*anyopaque) callconv(.c) void {
const self: *Window = @ptrCast(@alignCast(data.?));
self.quit();
}
/// GTK has finished with the window: release everything we allocated.
fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
if (self.closing) return;