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
+51
View File
@@ -128,6 +128,15 @@ arena: std.heap.ArenaAllocator,
theme: Theme = .system,
/// Whether closing the window asks first.
///
/// On by default, which is the opposite of what a terminal usually does — and
/// deliberately so. A window here is not one shell but a whole arrangement of
/// them, and under a tiling compositor the binding that closes it sits a
/// modifier away from the ones that move focus. There is nothing else between
/// a mistyped chord and every shell in every tab exiting.
confirm_quit: bool = true,
/// Changes to the palette. Empty means every colour is at its default, which is
/// the state anyone who never opens the colour editor stays in — and it is the
/// reason the defaults can be retuned in a later version and still reach them.
@@ -246,6 +255,10 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
}
}
if (root.get("confirm_quit")) |value| {
if (value == .bool) self.confirm_quit = value.bool;
}
if (root.get("colors")) |value| self.parseColors(value);
if (root.get("tint")) |value| self.parseTint(value);
@@ -595,6 +608,8 @@ fn serialize(self: *Settings) SaveError![]u8 {
try json.write(format_version);
try json.objectField("theme");
try json.write(@tagName(self.theme));
try json.objectField("confirm_quit");
try json.write(self.confirm_quit);
// Only the colours that have been changed, and only the schemes that have
// any. The object itself is written even when empty, for the same reason
@@ -731,6 +746,42 @@ fn forTesting() Settings {
return .{ .arena = .init(std.testing.allocator) };
}
test "confirming a quit is on unless the file turns it off" {
var settings = forTesting();
defer settings.arena.deinit();
// Nothing said about it: on. That is what anyone who never opens the
// settings page gets, and it is the half of this that has to be right.
try std.testing.expect(settings.confirm_quit);
try settings.parse(
\\{"theme": "dark", "confirm_quit": false}
);
try std.testing.expect(!settings.confirm_quit);
// And it goes back out. Saving rewrites the whole file, so a flag that
// didn't come out of `serialize` would be handed back by the next theme
// change — with a confirmation dialog someone had switched off.
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"confirm_quit\": false") != null);
}
test "a confirm_quit we can't read leaves the confirmation up" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"confirm_quit": "no", "theme": "dark"}
);
// Failing safe is the only direction worth failing in here, and the rest of
// the file still lands.
try std.testing.expect(settings.confirm_quit);
try std.testing.expectEqual(Theme.dark, settings.theme);
}
test "palette overrides are read per scheme" {
var settings = forTesting();
defer settings.arena.deinit();
+78
View File
@@ -105,6 +105,10 @@ theme_buttons: [std.enums.values(Settings.Theme).len]*gtk.ToggleButton,
/// changes which palette it is editing, so it has to be told.
colors: *PaletteEditor,
/// The confirm-before-quitting toggle. Held only so the switch can be built in
/// one place and read in another; nothing re-checks it.
confirm_quit: *gtk.Switch,
/// Container the startup rows live in, so rows can be added and removed after
/// the page is already on screen.
startup_box: *gtk.Box,
@@ -140,6 +144,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo
.ctx = opts.ctx,
.theme_buttons = undefined,
.colors = undefined,
.confirm_quit = gtk.Switch.new(),
.startup_box = gtk.Box.new(.vertical, 6),
.error_label = gtk.Label.new(null),
};
@@ -160,6 +165,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo
content.as(gtk.Widget).addCssClass("playpen-dialog-content");
content.append(self.buildAppearance());
content.append(self.buildQuitting());
content.append(self.buildStartup());
self.error_label.setXalign(0);
@@ -324,6 +330,78 @@ fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c)
self.colors.refresh();
}
// -------------------------------------------------------------------------
// Quitting
/// The Quitting section: whether closing the window asks first.
///
/// Above the startup list rather than below it. That list grows with the number
/// of tabs someone opens at launch, and a single-row group underneath it would
/// be reachable only by scrolling past every one of them.
fn buildQuitting(self: *SettingsDialog) *gtk.Widget {
const group = gtk.Box.new(.vertical, 10);
group.as(gtk.Widget).addCssClass("playpen-settings-group");
const title = gtk.Label.new("Quitting");
title.setXalign(0);
title.as(gtk.Widget).addCssClass("playpen-settings-title");
group.append(title.as(gtk.Widget));
const row = gtk.Box.new(.horizontal, 12);
const labels = gtk.Box.new(.vertical, 2);
labels.as(gtk.Widget).setHexpand(1);
labels.as(gtk.Widget).setValign(.center);
const name = gtk.Label.new("Confirm before closing the window");
name.setXalign(0);
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
labels.append(name.as(gtk.Widget));
const hint_label = gtk.Label.new(
"Closing the window exits every shell in every tab. Off, the window " ++
"goes the moment the window manager says so.",
);
hint_label.setXalign(0);
hint_label.setWrap(1);
hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
labels.append(hint_label.as(gtk.Widget));
row.append(labels.as(gtk.Widget));
self.confirm_quit.setActive(@intFromBool(Settings.get().confirm_quit));
self.confirm_quit.as(gtk.Widget).setValign(.center);
_ = gobject.Object.signals.notify.connect(
self.confirm_quit,
*SettingsDialog,
&onConfirmQuitChanged,
self,
.{ .detail = "active" },
);
row.append(self.confirm_quit.as(gtk.Widget));
group.append(row.as(gtk.Widget));
return group.as(gtk.Widget);
}
/// Straight to the file, as the theme picker does, rather than through
/// `persist`: this is one flag, and `commit` would re-read every startup row to
/// save it.
fn onConfirmQuitChanged(
toggle: *gtk.Switch,
_: *gobject.ParamSpec,
self: *SettingsDialog,
) callconv(.c) void {
Settings.get().confirm_quit = toggle.getActive() != 0;
Settings.get().save() catch {
self.showError("Could not write the settings file.");
return;
};
self.showError(null);
}
// -------------------------------------------------------------------------
// Startup
//
+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;