confirm-before-quit: ask Y/Enter (quit) or N/Escape (cancel) for Ctrl+Alt+Q and host close when more than one window is open

This commit is contained in:
Andrew Hamilton 2026-08-31 10:48:04 +10:00
parent a8e8106761
commit fd7ed766ef
2 changed files with 271 additions and 16 deletions

193
kick.c
View file

@ -492,6 +492,13 @@ static const char *term_cmd = "alacritty";
static bool term_key_held; /* Alt+Shift+T currently down (repeat latch) */ static bool term_key_held; /* Alt+Shift+T currently down (repeat latch) */
static bool delete_key_held; /* prefix+Delete currently down (repeat latch) */ static bool delete_key_held; /* prefix+Delete currently down (repeat latch) */
static bool f11_key_held; /* prefix+F11 currently down (repeat latch) */ static bool f11_key_held; /* prefix+F11 currently down (repeat latch) */
static bool quit_key_held; /* Ctrl+Alt+Q currently down (repeat latch) */
/* Confirm-before-quit: set when a quit was requested (host window close or
Ctrl+Alt+Q) with more than one live window open. While set, a modal
panel over the composited frame asks Y/Enter (quit) or N/Escape (keep
running) and every key is swallowed so focused clients never see them. */
static bool confirm_quit_pending;
/* The modifier prefix for the tab-management chords: hold = show overlay, /* The modifier prefix for the tab-management chords: hold = show overlay,
Up/Down = cycle, Shift+Up/Down = reorder, Delete = close. Defaults to Up/Down = cycle, Shift+Up/Down = reorder, Delete = close. Defaults to
@ -862,6 +869,22 @@ static bool window_live(struct kick_window *w) {
return false; return false;
} }
/* Live windows across every tab, for the confirm-before-quit count: a window
that holds (or is about to hold) a surface and is not already closing.
Still-black pending placeholders have no surface yet, so they do not count
-- there is nothing to lose by quitting for them. */
static int live_window_count(void) {
int n = 0;
struct kick_tab *t;
wl_list_for_each(t, &server.tabs, link) {
struct kick_window *w;
wl_list_for_each(w, &t->windows, link)
if (window_live(w) && !w->closing)
n++;
}
return n;
}
/* The first (oldest) window of a tab, or NULL. The tab's launcher identity /* The first (oldest) window of a tab, or NULL. The tab's launcher identity
(cmd, pending_claim) lives on it. */ (cmd, pending_claim) lives on it. */
static struct kick_window *tab_first_window(struct kick_tab *tab) { static struct kick_window *tab_first_window(struct kick_tab *tab) {
@ -1929,8 +1952,58 @@ static void overlay_pass_icon_tex(struct wlr_render_pass *pass,
}; };
wlr_render_pass_add_texture(pass, &o); wlr_render_pass_add_texture(pass, &o);
} }
/* Confirm-before-quit modal, drawn in place of the Alt tab list while a quit
is pending: a centered panel with the live-window count and the y/n keys.
Same styling language as the overlay -- same backdrop, rim strokes, and
glyph atlas. */
static void draw_confirm_quit_pass(struct wlr_render_pass *pass) {
int n = live_window_count();
char l1[96], l2[96];
snprintf(l1, sizeof(l1), "Quit kick? %d window%s open.", n,
n == 1 ? "" : "s");
snprintf(l2, sizeof(l2), "[Y]es quit [N]o keep running");
int margin = 8, boxpad = 10;
int row_h = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
int row_gap = 6;
int w1 = overlay_text_width(l1), w2 = overlay_text_width(l2);
int box_w = (w1 > w2 ? w1 : w2) + 2 * boxpad;
int box_h = 2 * row_h + row_gap + 2 * boxpad;
if (box_w > host.width - 2 * margin) box_w = host.width - 2 * margin;
if (box_h > host.height - 2 * margin) box_h = host.height - 2 * margin;
int box_x = (host.width - box_w) / 2;
int box_y = (host.height - box_h) / 2;
int bw = 1;
overlay_pass_rect(pass, box_x + bw, box_y + bw,
box_w - 2 * bw, box_h - 2 * bw,
0.07f, 0.07f, 0.09f, 1.0f);
overlay_pass_rect(pass, box_x, box_y, box_w, bw, 0.45f, 0.45f, 0.48f, 1.0f);
overlay_pass_rect(pass, box_x, box_y + box_h - bw, box_w, bw,
0.45f, 0.45f, 0.48f, 1.0f);
overlay_pass_rect(pass, box_x, box_y, bw, box_h, 0.45f, 0.45f, 0.48f, 1.0f);
overlay_pass_rect(pass, box_x + box_w - bw, box_y, bw, box_h,
0.45f, 0.45f, 0.48f, 1.0f);
int row_inner = box_w - 2 * boxpad;
for (int r = 0; r < 2; r++) {
const char *s = r == 0 ? l1 : l2;
int tw = overlay_text_width(s);
int x = box_x + boxpad + (row_inner - tw) / 2;
int y = box_y + boxpad + r * (row_h + row_gap);
overlay_pass_text(pass, s, x, y + (row_h - host.overlay_font_h) / 2,
0.95f);
}
}
static void draw_tab_overlay_pass(struct wlr_render_pass *pass) { static void draw_tab_overlay_pass(struct wlr_render_pass *pass) {
if (!host.overlay_visible || wl_list_empty(&server.tabs)) return; if (!host.overlay_visible && !confirm_quit_pending) return;
if (confirm_quit_pending) {
draw_confirm_quit_pass(pass);
return;
}
if (wl_list_empty(&server.tabs)) return;
int margin = 8, boxpad = 6; int margin = 8, boxpad = 6;
int icon_sz = OVERLAY_ICON_SZ, icon_gap = 8, member_gap = 12; int icon_sz = OVERLAY_ICON_SZ, icon_gap = 8, member_gap = 12;
@ -5684,11 +5757,54 @@ static void host_xdg_toplevel_configure(void *data, struct xdg_toplevel *t,
host.configured = true; host.configured = true;
} }
/* Common quit tail for the user-requested exits: drop the host window
(nested; guarded no-op standalone), tear every client and window down,
and exit cleanly. */
static void do_quit(void) {
host_teardown_window();
shutdown_cleanup();
exit(0);
}
/* Show or dismiss the confirm-before-quit modal. The panel is drawn by the
same second pass as the Alt overlay, so the same dance applies: while it
is up, direct scanout must yield to the composite path (attach-render
lock) and the swapchain buffers must be forced to repaint, or the modal
would bake into a reused buffer like the overlay does. */
static void set_confirm_quit(bool on) {
if (on == confirm_quit_pending) return;
confirm_quit_pending = on;
if (server.output)
wlr_output_lock_attach_render(server.output,
on || host.overlay_visible);
if (server.standalone)
standalone_overlay_damage();
else if (server.scene_output)
wlr_damage_ring_add_whole(&server.scene_output->damage_ring);
kick_request_redraw();
}
/* A quit was requested (host window close, or Ctrl+Alt+Q). With more than
one live window quitting would lose work, so raise the confirm modal and
wait for Y/N; a single window (or none) has nothing to lose and quits
straight away. */
static void quit_requested(void) {
int n = live_window_count();
kick_log(KICK_LOG_INFO, "kick: quit requested (%d window%s)\n",
n, n == 1 ? "" : "s");
if (n <= 1) {
do_quit();
return;
}
kick_log(KICK_LOG_INFO, "[confirm] quit: %d windows open "
"(Y=quit, N=cancel)\n", n);
set_confirm_quit(true);
}
static void host_xdg_toplevel_close(void *data, struct xdg_toplevel *t) { static void host_xdg_toplevel_close(void *data, struct xdg_toplevel *t) {
(void)data; (void)t; (void)data; (void)t;
kick_log(KICK_LOG_INFO, "kick: window closed\n"); kick_log(KICK_LOG_INFO, "kick: window closed\n");
shutdown_cleanup(); quit_requested();
exit(0);
} }
static const struct xdg_toplevel_listener host_xdg_toplevel_listener = { static const struct xdg_toplevel_listener host_xdg_toplevel_listener = {
@ -5778,9 +5894,7 @@ static void host_frame_configure(struct libdecor_frame *frame,
static void host_frame_close(struct libdecor_frame *frame, void *data) { static void host_frame_close(struct libdecor_frame *frame, void *data) {
(void)frame; (void)data; (void)frame; (void)data;
kick_log(KICK_LOG_INFO, "kick: window closed\n"); kick_log(KICK_LOG_INFO, "kick: window closed\n");
host_teardown_window(); quit_requested();
shutdown_cleanup();
exit(0);
} }
static void host_frame_commit(struct libdecor_frame *frame, void *data) { static void host_frame_commit(struct libdecor_frame *frame, void *data) {
@ -6268,12 +6382,38 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
bool alt = prefix_active(mods_depressed); bool alt = prefix_active(mods_depressed);
if (alt != host.overlay_visible) if (alt != host.overlay_visible)
update_overlay_visible(alt); update_overlay_visible(alt);
bool ctrl = (mods_depressed & (1u << 2)) != 0;
char symbuf[32]; char symbuf[32];
xkb_keysym_get_name(sym, symbuf, sizeof(symbuf)); xkb_keysym_get_name(sym, symbuf, sizeof(symbuf));
kick_log(KICK_LOG_DEBUG, kick_log(KICK_LOG_DEBUG,
"[kbd] key=%u state=%u sym=%s ctrl=%d alt=%d repeat_key=%u\n", "[kbd] key=%u state=%u sym=%s ctrl=%d alt=%d repeat_key=%u\n",
key, state, sym ? symbuf : "-", key, state, sym ? symbuf : "-",
(mods_depressed & (1u << 2)) != 0, alt, host.repeat_key); (mods_depressed & (1u << 2)) != 0, alt, host.repeat_key);
/* Confirm-before-quit modal active: every key is swallowed so focused
clients never see them. Y/Enter quits on the press (the first press
only -- the exit runs before any repeat could re-fire); N/Escape
cancels, and repeats just re-cancel the already-cleared state. */
if (confirm_quit_pending) {
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
bool yes = server.xkb_state
? (sym == XKB_KEY_y || sym == XKB_KEY_Y ||
sym == XKB_KEY_Return || sym == XKB_KEY_KP_Enter)
: (key == 21 || key == 28);
bool no = server.xkb_state
? (sym == XKB_KEY_n || sym == XKB_KEY_N ||
sym == XKB_KEY_Escape)
: (key == 49 || key == 1);
if (yes) {
kick_log(KICK_LOG_INFO, "kick: quit confirmed\n");
set_confirm_quit(false);
do_quit();
} else if (no) {
kick_log(KICK_LOG_INFO, "kick: quit cancelled\n");
set_confirm_quit(false);
}
}
return true;
}
/* Ctrl+Alt+F1..F12 switch virtual terminals in standalone mode. wlroots /* Ctrl+Alt+F1..F12 switch virtual terminals in standalone mode. wlroots
has no built-in handler; the compositor must notice the key and ask the has no built-in handler; the compositor must notice the key and ask the
session (libseat/logind) to activate another VT. Both the press and session (libseat/logind) to activate another VT. Both the press and
@ -6388,6 +6528,27 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
return true; return true;
} }
} }
/* Ctrl+Alt+Q quits kick (both the host close and this chord share
quit_requested): with more than one window open a modal asks before
anything is torn down. Grabbed so the focused client never sees the
chord; a latch drops host-synthesized repeats so one hold can't fire
the request (or the confirm) more than once. */
{ bool is_q = server.xkb_state
? (sym == XKB_KEY_q || sym == XKB_KEY_Q)
: key == 16;
if (alt && ctrl && is_q) {
kbd_repeat_cancel();
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (!quit_key_held) {
quit_key_held = true;
quit_requested();
}
} else {
quit_key_held = false;
}
return true;
}
}
/* prefix+Delete closes the active window (or the whole tab when it is the /* prefix+Delete closes the active window (or the whole tab when it is the
only window). Grabbed so the focused client never sees the key; a latch only window). Grabbed so the focused client never sees the key; a latch
drops host-synthesized repeats so one hold can't fire it per repeat. drops host-synthesized repeats so one hold can't fire it per repeat.
@ -7712,7 +7873,7 @@ static void render_standalone(void) {
dump_scene_buffer(state.buffer); dump_scene_buffer(state.buffer);
} }
if (host.overlay_visible) { if (host.overlay_visible || confirm_quit_pending) {
struct wlr_render_pass *pass = wlr_renderer_begin_buffer_pass( struct wlr_render_pass *pass = wlr_renderer_begin_buffer_pass(
server.renderer, state.buffer, NULL); server.renderer, state.buffer, NULL);
if (pass) { if (pass) {
@ -7794,7 +7955,7 @@ static void render_and_present(void) {
/* Overlay joins the frame before the blit, drawn exactly as standalone /* Overlay joins the frame before the blit, drawn exactly as standalone
draws it -- one renderer for both modes; the blit stays a pure copy. */ draws it -- one renderer for both modes; the blit stays a pure copy. */
if (host.overlay_visible) { if (host.overlay_visible || confirm_quit_pending) {
struct wlr_render_pass *pass = wlr_renderer_begin_buffer_pass( struct wlr_render_pass *pass = wlr_renderer_begin_buffer_pass(
server.renderer, state.buffer, NULL); server.renderer, state.buffer, NULL);
if (pass) { if (pass) {
@ -8300,12 +8461,16 @@ int main(int argc, char *argv[]) {
" Delete Close the active window (or the tab when it is the only window).\n" " Delete Close the active window (or the tab when it is the only window).\n"
" Shift+T Open a new terminal tab " " Shift+T Open a new terminal tab "
"($TERMINAL).\n" "($TERMINAL).\n"
" Left / Right Cycle the active tab's " " Left / Right Cycle the active tab's "
"tab-group windows (Left = previous, Right = next); hold " "tab-group windows (Left = previous, Right = next); hold "
"to keep cycling.\n" "to keep cycling.\n"
" Ctrl+Alt+F1..F12 Switch virtual terminals " " Ctrl+Alt+F1..F12 Switch virtual terminals "
"(standalone only).\n", "(standalone only).\n"
argv[0], argv[0]); " Ctrl+Alt+Q Quit kick: asks for "
"confirmation when more than\n"
" one window is open "
"(Y/Enter quits, N/Escape keeps running).\n",
argv[0], argv[0]);
free(appv); free(appv);
return 0; return 0;
} }

View file

@ -62,13 +62,16 @@ _ensure_built()
# host_kbd_key in kick.c). Names, not numbers, in tests. # host_kbd_key in kick.c). Names, not numbers, in tests.
F = 33 F = 33
F11 = 87 F11 = 87
Q = 16
Y = 21
N = 49
ESC = 1
ENTER = 28
UP = 103 UP = 103
DOWN = 108 DOWN = 108
RIGHT = 106 RIGHT = 106
LEFT = 105 LEFT = 105
DELETE = 111 DELETE = 111
LEFT = 105
RIGHT = 106
KEY_PRESSED = 1 KEY_PRESSED = 1
KEY_RELEASED = 0 KEY_RELEASED = 0
@ -1982,6 +1985,93 @@ def test_alt_delete_closes_active_window_not_tab():
s.proc.terminate() s.proc.terminate()
def _wait_exit(s, code, timeout=10.0):
"""Wait for the compositor process to exit, asserting its status."""
since = time.monotonic()
while time.monotonic() - since < timeout:
rc = s.proc.poll()
if rc is not None:
assert rc == code, \
f"compositor exited with {rc}, expected {code}"
return
time.sleep(0.1)
pytest.fail("compositor never exited (expected returncode "
f"{code}) after quit")
def test_quit_confirm_multi_window():
"""Ctrl+Alt+Q with more than one window open raises the confirm modal
instead of quitting; Y/Enter confirms and quits cleanly with 0."""
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'")
try:
s.wait_any_active()
s.wait_for(r"\[claim\] app=0\b")
s.wait_for(r"\[claim\] app=1\b")
s.chord(CTRL | ALT, Q)
s.wait_for(r"\[confirm\] quit: 2 windows open ")
assert s.proc.poll() is None, \
"Ctrl+Alt+Q quit behind the confirm modal"
s.tap(Y) # confirm
s.wait_for(r"kick: quit confirmed\b")
_wait_exit(s, 0)
assert "kick: quit cancelled" not in s.read_log()
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_quit_cancel_keeps_running():
"""Ctrl+Alt+Q confirmation can be declined: N and Escape both dismiss it,
the compositor keeps running, and a later quit request re-asks."""
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'")
try:
s.wait_any_active()
s.wait_for(r"\[claim\] app=1\b")
s.chord(CTRL | ALT, Q)
s.wait_for(r"\[confirm\] quit: 2 windows open ")
s.tap(N) # decline
s.wait_for(r"kick: quit cancelled")
assert s.proc.poll() is None, "N after Ctrl+Alt+Q quit anyway"
s.chord(CTRL | ALT, Q) # re-ask, decline again via Escape
s.wait_for(r"\[confirm\] quit: 2 windows open ")
s.tap(ESC)
s.wait_for(r"kick: quit cancelled")
assert s.proc.poll() is None, "Escape after Ctrl+Alt+Q quit anyway"
assert s.cycle_ack(+1) is not None, "tab cycling broke after cancel"
assert s.proc.poll() is None, "tab cycling broke after cancelled quit"
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_quit_single_window_no_confirm():
"""With a single window there is nothing to lose, so Ctrl+Alt+Q quits
immediately -- no confirm modal, clean exit 0."""
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
s = _nested_session(f"{WL} '{RED}'")
try:
s.wait_any_active()
s.wait_for(r"\[claim\] app=0\b")
s.chord(CTRL | ALT, Q)
_wait_exit(s, 0)
assert "[confirm] quit:" not in s.read_log(), \
"single-window quit unnecessarily asked for confirmation"
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_alt_left_right_cycle_tab_group_members(): def test_alt_left_right_cycle_tab_group_members():
"""--tab-groups on: Alt+Left / Alt+Right cycle the active tab's stacked """--tab-groups on: Alt+Left / Alt+Right cycle the active tab's stacked
windows (Left = previous, Right = next) and the tab stays up; the chord is windows (Left = previous, Right = next) and the tab stays up; the chord is