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
+32
View File
@@ -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": []
}
+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;