From fd7ed766ef866d369838284fe74f0b850769f65f Mon Sep 17 00:00:00 2001 From: Andrew Hamilton Date: Mon, 31 Aug 2026 10:48:04 +1000 Subject: [PATCH] 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 --- kick.c | 193 ++++++++++++++++++++++++++++++++++++++++---- tests/test_smoke.py | 94 ++++++++++++++++++++- 2 files changed, 271 insertions(+), 16 deletions(-) diff --git a/kick.c b/kick.c index cb572ee..55d5d25 100644 --- a/kick.c +++ b/kick.c @@ -492,6 +492,13 @@ static const char *term_cmd = "alacritty"; 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 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, 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; } +/* 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 (cmd, pending_claim) lives on it. */ 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); } + +/* 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) { - 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 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; } +/* 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) { (void)data; (void)t; kick_log(KICK_LOG_INFO, "kick: window closed\n"); - shutdown_cleanup(); - exit(0); + quit_requested(); } 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) { (void)frame; (void)data; kick_log(KICK_LOG_INFO, "kick: window closed\n"); - host_teardown_window(); - shutdown_cleanup(); - exit(0); + quit_requested(); } 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); if (alt != host.overlay_visible) update_overlay_visible(alt); + bool ctrl = (mods_depressed & (1u << 2)) != 0; char symbuf[32]; xkb_keysym_get_name(sym, symbuf, sizeof(symbuf)); kick_log(KICK_LOG_DEBUG, "[kbd] key=%u state=%u sym=%s ctrl=%d alt=%d repeat_key=%u\n", key, state, sym ? symbuf : "-", (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 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 @@ -6388,6 +6528,27 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state, 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 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. @@ -7712,7 +7873,7 @@ static void render_standalone(void) { 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( server.renderer, state.buffer, NULL); if (pass) { @@ -7794,7 +7955,7 @@ static void render_and_present(void) { /* Overlay joins the frame before the blit, drawn exactly as standalone 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( server.renderer, state.buffer, NULL); 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" " Shift+T Open a new terminal tab " "($TERMINAL).\n" - " Left / Right Cycle the active tab's " - "tab-group windows (Left = previous, Right = next); hold " - "to keep cycling.\n" - " Ctrl+Alt+F1..F12 Switch virtual terminals " - "(standalone only).\n", - argv[0], argv[0]); +" Left / Right Cycle the active tab's " + "tab-group windows (Left = previous, Right = next); hold " + "to keep cycling.\n" + " Ctrl+Alt+F1..F12 Switch virtual terminals " + "(standalone only).\n" + " 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); return 0; } diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a3d59d0..1aa0d39 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -62,13 +62,16 @@ _ensure_built() # host_kbd_key in kick.c). Names, not numbers, in tests. F = 33 F11 = 87 +Q = 16 +Y = 21 +N = 49 +ESC = 1 +ENTER = 28 UP = 103 DOWN = 108 RIGHT = 106 LEFT = 105 DELETE = 111 -LEFT = 105 -RIGHT = 106 KEY_PRESSED = 1 KEY_RELEASED = 0 @@ -1982,6 +1985,93 @@ def test_alt_delete_closes_active_window_not_tab(): 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(): """--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