Added a hopeful quit blocking dialog.
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user