close the active window with Alt+Delete, not the whole tab

This commit is contained in:
Andrew Hamilton 2026-08-22 22:03:34 +10:00
parent 118f5c276a
commit 39329347f6
4 changed files with 93 additions and 15 deletions

View file

@ -199,7 +199,7 @@ every surface individually reachable, which is kick's original model.
| `Alt+Down` | Next tab (hold to keep cycling) | | `Alt+Down` | Next tab (hold to keep cycling) |
| `Alt+Shift+Up` | Move the active tab earlier in the list | | `Alt+Shift+Up` | Move the active tab earlier in the list |
| `Alt+Shift+Down` | Move the active tab later in the list | | `Alt+Shift+Down` | Move the active tab later in the list |
| `Alt+Delete` | Close the active tab (SIGTERM, SIGKILL fallback) | | `Alt+Delete` | Close the active window (or the tab when it's the only window) |
| `Alt+Shift+T` | Open a new terminal tab next to the current one | | `Alt+Shift+T` | Open a new terminal tab next to the current one |
| `` Alt+` `` | Cycle the active tab's tab-group windows (hold to keep cycling) | | `` Alt+` `` | Cycle the active tab's tab-group windows (hold to keep cycling) |
@ -247,10 +247,12 @@ Both `Alt+Up/Down` and `Alt+Shift+Up/Down` are grabbed: kick consumes them
so the focused client never receives the chord (e.g. a terminal can't act on so the focused client never receives the chord (e.g. a terminal can't act on
`Shift`+arrow while you reorder). `Shift`+arrow while you reorder).
`Alt+Delete` closes the active tab. The tab disappears immediately (the tab `Alt+Delete` closes the active window. When the tab holds more than one window
to its right becomes active, or the left neighbor if it was the rightmost); (a tab group), only the focused window closes and the tab stays up; when it is
the client's process group gets a `SIGTERM`, escalated to `SIGKILL` after a the tab's only window, the whole tab disappears immediately (the tab to its
short grace if it ignores the signal. A tab that closes on its own (its right becomes active, or the left neighbor if it was the rightmost); the
client's process group gets a `SIGTERM`, escalated to `SIGKILL` after a short
grace if it ignores the signal. A window or tab that closes on its own (its
program exits) instead hands active off to the left neighbor. program exits) instead hands active off to the left neighbor.
Function keys are **not** grabbed: `F9`/`F10`/`F11` (and anything else) are Function keys are **not** grabbed: `F9`/`F10`/`F11` (and anything else) are

56
kick.c
View file

@ -2619,6 +2619,35 @@ static void close_tab(struct kick_app *app) {
wl_event_source_timer_update(app->close_timer, KICK_CLOSE_ESCALATE_MS); wl_event_source_timer_update(app->close_timer, KICK_CLOSE_ESCALATE_MS);
} }
/* Alt+Delete on the active tab closes the active WINDOW, not the whole tab:
the focused stack member (a stacked tab-group window) is asked to close on
its own and the tab stays up; only when the tab holds a single window (its
wrapped main surface) does Delete take the whole tab down. The member's
own destroy path (member_destroy_common) unstacks it and refocuses the tab,
just as if the client had closed it directly. */
static void close_active_window(void) {
struct kick_app *app = server.active_app;
if (!app) return;
struct kick_tab_group_member *m =
(server.active_app == app) ? app->focus_member : NULL;
if (m && !m->is_main) {
/* A stacked tab-group window: ask it to close; its destroy handler
unstacks it and refocuses the tab. Either protocol path works. */
if (m->toplevel) {
wlr_xdg_toplevel_send_close(m->toplevel);
return;
}
#if HAVE_XWAYLAND
if (m->is_x11 && m->xsurface) {
wlr_xwayland_surface_close(m->xsurface);
return;
}
#endif
/* Member has no closable surface: fall through to the whole tab. */
}
close_tab(app);
}
static void handle_request_maximize(struct wl_listener *listener, void *data) { static void handle_request_maximize(struct wl_listener *listener, void *data) {
(void)data; (void)data;
struct kick_app *app = wl_container_of(listener, app, request_maximize); struct kick_app *app = wl_container_of(listener, app, request_maximize);
@ -3565,8 +3594,16 @@ static void handle_xs_request_activate(struct wl_listener *listener,
} }
static void handle_xs_request_close(struct wl_listener *listener, void *data) { static void handle_xs_request_close(struct wl_listener *listener, void *data) {
(void)data; struct wlr_xwayland_surface *xs = data;
struct kick_app *app = wl_container_of(listener, app, xs_request_close); struct kick_app *app = wl_container_of(listener, app, xs_request_close);
/* A stacked tab-group X11 window closing on its own only unstacks -- the
member destroy path handles it -- so leave it be. Only the main
surface's close tears the whole tab down, matching Alt+Delete and the
Wayland behavior. */
struct kick_tab_group_member *sub;
wl_list_for_each(sub, &app->group_members, link)
if (sub->is_x11 && sub->xsurface == xs && !sub->is_main)
return;
close_tab(app); close_tab(app);
} }
@ -6121,11 +6158,12 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
return true; return true;
} }
} }
/* prefix+Delete closes the active tab. Grabbed so the focused client never /* prefix+Delete closes the active window (or the whole tab when it is the
sees the key; a latch drops host-synthesized repeats so one hold can't only window). Grabbed so the focused client never sees the key; a latch
close a tab per repeat. The active surface already moved to the right drops host-synthesized repeats so one hold can't fire it per repeat.
neighbor inside close_active_tab(), so the tab is gone this frame even When the whole tab closes, the active surface moves to the right
before the client process exits. */ neighbor inside close_tab(), so the tab is gone this frame even before
the client process exits. */
{ bool is_delete = server.xkb_state { bool is_delete = server.xkb_state
? (sym == XKB_KEY_Delete) ? (sym == XKB_KEY_Delete)
: key == 111; : key == 111;
@ -6134,7 +6172,7 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (!delete_key_held) { if (!delete_key_held) {
delete_key_held = true; delete_key_held = true;
close_tab(server.active_app); close_active_window();
} }
} else { } else {
delete_key_held = false; delete_key_held = false;
@ -7687,7 +7725,7 @@ int main(int argc, char *argv[]) {
" Up / Down Cycle tabs; hold to keep " " Up / Down Cycle tabs; hold to keep "
"cycling.\n" "cycling.\n"
" Shift+Up / Shift+Down Reorder the active tab.\n" " Shift+Up / Shift+Down Reorder the active tab.\n"
" Delete Close the active tab.\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"
" ` (grave) Cycle the active tab's " " ` (grave) Cycle the active tab's "
@ -8266,7 +8304,7 @@ int main(int argc, char *argv[]) {
const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt"; const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt";
kick_log(KICK_LOG_INFO, "kick: running. " kick_log(KICK_LOG_INFO, "kick: running. "
"%s+Up/Down switches tabs; %s+Shift+Up/Down reorders; " "%s+Up/Down switches tabs; %s+Shift+Up/Down reorders; "
"%s+Delete closes the active tab; " "%s+Delete closes the active window (or the tab when it's the only window); "
"%s+Shift+T opens a terminal (%s).\n", "%s+Shift+T opens a terminal (%s).\n",
prefix, prefix, prefix, prefix, term_cmd); prefix, prefix, prefix, prefix, term_cmd);

View file

@ -1833,6 +1833,34 @@ def test_sub_windows_stack_mode_row_title_follows_top():
s.proc.terminate() s.proc.terminate()
@pytest.mark.flaky
def test_alt_delete_closes_active_window_not_tab():
"""--tab-groups on: Alt+Delete on a tab that holds a stacked window closes
only that window -- the tab stays up (the compositor stays alive, no new
slot is claimed) and the stacked member unstacks instead of the tab being
torn down."""
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'",
extra_args=("--tab-groups", "on"))
try:
s.wait_any_active()
s.wait_for(r"\[sub\] stack app=0\b") # stacked window mapped
claims_before = CLAIM_RE.findall(s.read_log())
s.chord(ALT, DELETE) # close the active window
s.wait_for(r"\[sub\] unstack app=0\b") # only the window closed
time.sleep(0.5)
assert s.proc.poll() is None, \
"Alt+Delete tore the whole tab down instead of just the window"
claims_after = CLAIM_RE.findall(s.read_log())
assert claims_before == claims_after, \
f"Alt+Delete changed the tab set: {claims_before} -> {claims_after}"
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_alt_delete_activates_right_neighbor(): def test_alt_delete_activates_right_neighbor():
"""Alt+Delete closes the active tab, making the tab to its right active """Alt+Delete closes the active tab, making the tab to its right active
(SIGTERM, escalated to SIGKILL only if the client lingers). With three (SIGTERM, escalated to SIGKILL only if the client lingers). With three

View file

@ -310,7 +310,17 @@ static void xdg_toplevel_configure(void *data, struct xdg_toplevel *xp, int32_t
} }
static void xdg_toplevel_close(void *data, struct xdg_toplevel *xp) { static void xdg_toplevel_close(void *data, struct xdg_toplevel *xp) {
(void)data; (void)xp; (void)data;
/* Closing the extra (second, tab-group) toplevel destroys just that
window so the client (and its main window) survive; closing the main
toplevel exits the client. */
if (xp == ex_toplevel) {
if (ex_toplevel) {
xdg_toplevel_destroy(ex_toplevel);
ex_toplevel = NULL;
}
return;
}
exit(0); exit(0);
} }