From 198258c181807212fb2ac552678c11d61cbd5fe6 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 25 Aug 2026 16:24:00 -0400 Subject: [PATCH] Added a hopeful quit blocking dialog. --- README.md | 32 ++++++++++ src/Settings.zig | 51 ++++++++++++++++ src/SettingsDialog.zig | 78 ++++++++++++++++++++++++ src/Window.zig | 133 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 292 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d97f07..2012feb 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,33 @@ GPU. Here, each frame walks the visible rows, groups cells into runs of identical style, and hands each run to Pango. That is far more work per frame in principle, but a terminal grid is small. +## Quitting + +Closing the window asks first, and does so by default. 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 — so there is +otherwise nothing between a mistyped chord and every shell in every tab exiting. +The dialog names how many tabs are about to go, which is also the quickest way +to notice you are about to close the wrong window. + +**Keep Working** is the default response, and Escape lands on it too: the guard +should not itself be a keystroke to fumble. Pressing the compositor's close +binding again while the question is up dismisses it, which counts as cancelling +— leaning on the key never costs the window. + +Two closes don't ask, because both are already an answer: accepting the dialog, +and closing the last tab, which is a decision to close the window made one tab +at a time. + +**Settings → Quitting** turns the confirmation off, which puts the window back +to closing the moment the window manager says so: + +```json +{ + "confirm_quit": false +} +``` + ## What works - Your login shell (from the passwd database, not `$SHELL`) on a real PTY, @@ -497,6 +524,10 @@ in principle, but a terminal grid is small. to the pane in that direction, `Alt+Shift+J/K` steps through tabs. Every chord in the app can be rebound in `settings.json`. See [Rebinding a shortcut](#rebinding-a-shortcut) +- **A confirmation before the window closes**, on by default, naming how many + tabs would go with it — so a window-manager close binding can't end a session + by accident. Switchable from **Settings → Quitting**. See + [Quitting](#quitting) - **Light and dark schemes**, following the desktop by default and pinnable from the settings page, applying to open tabs immediately — terminal palette included. See [Theme](#theme) @@ -1009,6 +1040,7 @@ The preference lives in `~/.config/playpen/settings.json` (or { "version": 1, "theme": "system", + "confirm_quit": true, "colors": {}, "startup": [] } diff --git a/src/Settings.zig b/src/Settings.zig index 1fc006b..777a804 100644 --- a/src/Settings.zig +++ b/src/Settings.zig @@ -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(); diff --git a/src/SettingsDialog.zig b/src/SettingsDialog.zig index f9361c5..9b21af4 100644 --- a/src/SettingsDialog.zig +++ b/src/SettingsDialog.zig @@ -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 // diff --git a/src/Window.zig b/src/Window.zig index 41a4330..f79ccdb 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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;