Alt+Tab / Alt+1 walks a window-level MRU stack, commits on release

Prefix+Tab and prefix+1 preview the session's windows most-recently-used
first: each press steps to the next window after the shown one in server.mru
order, wrapping at the bottom, without mutating the stack until the modifier
is released. Releasing after navigating commits the shown window to the
front; a bare hold leaves the order untouched. Focus changes with the
modifier up move the entered window to the front immediately.

Alt+1 is the chord that survives hosts like GNOME, which grab Alt+Tab,
Alt+Esc and Alt+Backtick (the latter via the metacity-era switch_group
default) before a nested compositor ever sees them; Alt+Tab is kept for
hosts that leave it free. Windows join the stack least-recent on allocation
and are removed on teardown; reachable stops mirror cycle_tab (live or
pending-claim, not closing). Holding the chord repeats via the existing
synthesized-repeat machinery.

Tests cover stepping, commit-on-release, the Alt+1 synonym, and hold-to-
repeat.
This commit is contained in:
Andrew Hamilton 2026-09-06 23:52:03 +10:00
parent ca7f1f2852
commit 1f35f90a95
3 changed files with 274 additions and 6 deletions

View file

@ -200,6 +200,7 @@ every surface individually reachable, which is kick's original model.
| `Alt` (hold) | Show the tab-title overlay (centered) |
| `Alt+Up` | Previous tab (hold to keep cycling) |
| `Alt+Down` | Next tab (hold to keep cycling) |
| `Alt+Tab` / `Alt+1` | Walk windows most-recently-used first (hold to keep walking; the stack commits on release) |
| `Alt+Shift+Up` | Move the active tab earlier in the list |
| `Alt+Shift+Down` | Move the active tab later in the list |
| `Alt+Delete` | Close the active window (or the tab when it's the only window) |
@ -209,7 +210,8 @@ every surface individually reachable, which is kick's original model.
The tab-management prefix is `Alt` by default. Pass `--modifier super` to
use `Super` (Windows/Meta key, Mod4) instead: `Super` hold shows the
overlay, `Super+Up/Down` cycle, `Super+Shift+Up/Down` reorder,
overlay, `Super+Up/Down` cycle, `Super+Tab`/`Super+1` walk recent windows,
`Super+Shift+Up/Down` reorder,
`Super+Delete` closes, and `Super+Shift+T` opens a new terminal -- the
new-tab chord follows the prefix like every other binding.
The `Super` variant is meant for standalone/DRM and labwc-style hosts
@ -232,6 +234,21 @@ highlighted like a cursor: `Alt+Up`/`Alt+Down` switch tabs and
`Alt+Left`/`Alt+Right` move between windows within the active tab, so the
highlight walks through every window in the session.
`Alt+Tab` (and its synonym `Alt+1`, the chord that survives hosts like
GNOME which grab `Alt+Tab`, `Alt+Esc` *and* `Alt+Backtick` -- the last from
the metacity-era `switch_group` default) walk the session's
windows most-recently-used first: each press previews the next window after
the one you are looking at, wrapping at the bottom, and holding the chord
keeps stepping via the same synthesized repeat as tab cycling. While the
modifier is held the ordering does not change — every focus change is only a
preview — so the stack itself updates on release: releasing after moving
commits the shown window to the front of the MRU order. A bare hold that
never navigates leaves the order untouched. With the modifier released,
focus changes (e.g. switching to a window) promote that window to the front
immediately. Like every navigation chord, `Alt+Tab`/`Alt+1` are grabbed
so the focused client never sees them; `Shift` is ignored (there is no
reverse direction).
`Alt+Shift+T` launches a fresh terminal (`$TERMINAL`, default
`alacritty`) as a new tab placed immediately to the right of the current tab,
and it becomes the active tab once it maps (the one exception to the

191
kick.c
View file

@ -302,6 +302,15 @@ struct kick_window {
window the tab is currently displaying, so this cache lets the overlay
log one line per actual change. */
char row_title[128];
/* Node in the global MRU window stack (server.mru, most-recent first).
Every window joins at the tail on allocation and bubbles to the front
when it becomes current -- except while the tab-management prefix is
held (overlay visible), when focus changes are only previews and the
commit happens on prefix release. mru_linked guards removal: a window
is linked from window_alloc until it is freed. */
struct wl_list mru_link;
bool mru_linked;
};
struct kick_server {
@ -494,6 +503,18 @@ struct kick_server {
/* Tabs, in overlay/cycle order: a list of kick_tab, each holding its
window list. */
struct wl_list tabs;
/* MRU window stack, most-recent first: a list of kick_window.mru_link
covering every live window (plus still-black pending placeholders, so
Alt+Tab/Alt+1 can land on them like Alt+Up/Down can). prefix+Tab/1 walk
down this order without mutating it; the shown window moves to the
front on prefix release (or immediately when focus changes with the
prefix up). */
struct wl_list mru;
/* The window that was current when the prefix went down (overlay shown),
and whether the user navigated anywhere since. On prefix release a
navigated session commits the then-current window to the MRU front. */
struct kick_window *mru_alt_anchor;
bool mru_alt_navigated;
/* wlr-layer-shell (waybar / panels). Layer surfaces render into their
own always-on-top scene tree (layer_tree) instead of being tabs, so a
@ -668,9 +689,10 @@ struct kick_host {
int repeat_dir;
/* What each repeat tick should do: REPEAT_CYCLE switches tabs,
REPEAT_REORDER moves the active tab within the list, REPEAT_SUBCYCLE
rotates the active tab's stacked windows. */
rotates the active tab's stacked windows, REPEAT_MRU steps down the
MRU window stack. */
enum { REPEAT_CYCLE, REPEAT_REORDER, REPEAT_SUBCYCLE,
REPEAT_MEMBER_MOVE } repeat_kind;
REPEAT_MEMBER_MOVE, REPEAT_MRU } repeat_kind;
};
static struct kick_server server;
@ -893,6 +915,12 @@ static int cmd_in_path(const char *cmd);
static void insert_tab_after(struct kick_tab *tab, struct kick_tab *anchor);
static void launch_client(struct kick_window *w);
static void launch_all_cli(void);
/* MRU window-stack bodies live with the focus helpers; called from
window_alloc/teardown, window_enter and the prefix+Tab/1 key handling. */
static void mru_move_to_front(struct kick_window *w);
static void mru_remove(struct kick_window *w);
static struct kick_window *mru_current(void);
static void mru_step(void);
#if HAVE_XWAYLAND
/* XWayland surface handlers (defined with the other toplevel handlers). */
@ -1278,6 +1306,9 @@ static void shutdown_cleanup(void) {
free(tab);
}
wl_list_init(&server.tabs);
wl_list_init(&server.mru);
server.mru_alt_anchor = NULL;
server.mru_alt_navigated = false;
/* Detach + free the standalone native keyboard devices. */
struct standalone_kbdev *kd, *kd_tmp;
@ -2877,6 +2908,16 @@ static void window_enter(struct kick_window *w) {
lock surface (which is focused directly, outside the tab model); the
lock restore path clears session_locked before refocusing. */
if (!w || server.session_locked) return;
/* MRU bookkeeping: with the prefix up the entered window is the new most
recent; with the prefix held it is only a preview -- the stack must
not move until the prefix is released, but the session counts as
navigated when the preview leaves its anchor. */
if (host.overlay_visible) {
if (w != server.mru_alt_anchor)
server.mru_alt_navigated = true;
} else {
mru_move_to_front(w);
}
struct kick_tab *tab = w->tab;
/* A genuine no-op: already focused AND already drawn. The black ->
mapped promotion below is the one same-window case with something
@ -3007,6 +3048,7 @@ static void remove_tab(struct kick_tab *tab) {
if (w->is_x11)
window_detach_x11_listeners(w);
#endif
mru_remove(w);
free(w->cmd);
free(w);
}
@ -3054,6 +3096,7 @@ static void window_destroy_common(struct kick_window *w) {
if (w->icon_wlr)
wlr_texture_destroy(w->icon_wlr);
icon_proto_release(w);
mru_remove(w);
free(w);
if (wl_list_empty(&tab->windows)) {
drop_tab(tab);
@ -3692,6 +3735,11 @@ static struct kick_window *window_alloc(struct kick_tab *tab) {
if (!w) return NULL;
w->tab = tab;
wl_list_insert(tab->windows.prev, &w->link);
/* New windows start least-recent: background arrivals must not hijack
Alt+Tab. The window bubbles to the front when it becomes current
(window_enter with the prefix up). */
wl_list_insert(server.mru.prev, &w->mru_link);
w->mru_linked = true;
return w;
}
@ -5081,6 +5129,78 @@ static void handle_shape_set(struct wl_listener *listener, void *data) {
apply_cursor_shape(ev->shape);
}
/* ===================== MRU window stack =====================
Alt+Tab / Alt+1 walk the windows most-recently-used-first: each press
previews the next window after the shown one in server.mru order, wrapping
at the bottom, without mutating the stack. The shown window moves to the
front when the prefix is released (see update_overlay_visible), or
immediately when focus changes with the prefix up (window_enter). */
static void mru_move_to_front(struct kick_window *w) {
if (!w || !w->mru_linked) return;
if (server.mru.next == &w->mru_link) return; /* already most recent */
wl_list_remove(&w->mru_link);
wl_list_insert(&server.mru, &w->mru_link);
}
static void mru_remove(struct kick_window *w) {
if (!w || !w->mru_linked) return;
wl_list_remove(&w->mru_link);
w->mru_linked = false;
/* The anchor is only compared by pointer on prefix release; never
dereferenced, but clear it so a destroyed anchor can't masquerade as
"no navigation happened" logic elsewhere. */
if (server.mru_alt_anchor == w)
server.mru_alt_anchor = NULL;
}
/* The window the user is currently looking at: the focused window of the
active tab (a still-black pending placeholder when the tab awaits its
client). */
static struct kick_window *mru_current(void) {
return tab_current_window(server.active_tab);
}
/* Whether a window is a reachable Alt+Tab stop: it holds (or awaits) a
surface and is not being closed. Mirrors tab_overlay_row/cycle_tab so
Alt+Tab lands everywhere Alt+Up/Down can. */
static bool mru_reachable(struct kick_window *w) {
if (!w || w->closing) return false;
return window_live(w) || w->pending_claim;
}
/* Preview the next window after the shown one in MRU order, wrapping to the
most recent. With a single reachable window this re-shows it (a no-op
that still counts as navigation, so release commits it to the front). */
static void mru_step(void) {
if (wl_list_empty(&server.mru)) return;
struct kick_window *cur = mru_current();
struct kick_window *order[512];
int n = 0;
struct kick_window *w;
wl_list_for_each(w, &server.mru, mru_link) {
if (n < 512 && mru_reachable(w))
order[n++] = w;
}
if (n == 0) return;
int idx = -1;
for (int i = 0; i < n; i++) {
if (order[i] == cur) { idx = i; break; }
}
/* idx < 0 (shown window not in the stack) falls through to order[0]:
the most recent window. Otherwise the first press from the top lands
on order[1], the second-from-top, as specified. */
struct kick_window *target = order[(idx + 1) % n];
if (target->tab != server.active_tab)
set_active_tab(target->tab);
/* set_active_tab enters the tab's current window, which may be a
different stacked member; pin the exact MRU target. Harmless when
target is already shown (window_enter no-ops on focused+enabled). */
window_enter(target);
server.mru_alt_navigated = true;
kick_log(KICK_LOG_INFO, "[mru] preview app=%d\n", tab_index(target->tab));
}
/* Switch to the next live tab in `dir` direction (Alt+Down: +1), wrapping
around the end of the tab list, skipping tabs that are mid-close. With
only the current tab live, does nothing. */
@ -5201,7 +5321,9 @@ static int kbd_repeat_cb(void *data) {
struct kick_window *w = tab_current_window(server.active_tab);
if (w)
reorder_window(w, host.repeat_dir);
} else
} else if (host.repeat_kind == REPEAT_MRU)
mru_step();
else
cycle_tab(host.repeat_dir);
if (host.repeat_timer)
wl_event_source_timer_update(host.repeat_timer,
@ -7960,6 +8082,27 @@ static void handle_window_meta(struct wl_listener *l, void *data) {
static void update_overlay_visible(bool alt) {
if (alt == host.overlay_visible) return;
if (alt) {
/* Prefix went down: snapshot the shown window. Every focus change
from here until release is a preview -- window_enter leaves the
MRU stack alone -- and marks the session navigated. */
server.mru_alt_anchor = mru_current();
server.mru_alt_navigated = false;
} else {
/* Prefix released: the window we are on becomes most recent, but
only if we actually moved. A bare prefix tap/hold that never
navigates must not reshuffle the stack. */
if (server.mru_alt_navigated) {
struct kick_window *cur = mru_current();
if (cur) {
mru_move_to_front(cur);
kick_log(KICK_LOG_INFO, "[mru] commit app=%d\n",
tab_index(cur->tab));
}
}
server.mru_alt_anchor = NULL;
server.mru_alt_navigated = false;
}
host.overlay_visible = alt;
/* The overlay draws as a second pass on top of the composited frame, so
while it is visible direct scanout must yield to the composite path:
@ -8141,6 +8284,36 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
}
return true;
}
/* prefix+Tab / prefix+1 walk the MRU window stack one step per press
(hold to keep walking via REPEAT_MRU): each press previews the next
window after the shown one, wrapping at the bottom, without touching
the stack itself -- the shown window moves to the front on prefix
release. Shift is ignored (no reverse direction): with Shift held
this still walks forward so no stray Tab/1 leaks to the client
mid-menu. Alt+1 is the reliable MRU chord on hosts that grab the
switcher keys: GNOME takes Alt+Tab, Alt+Esc AND Alt+Backtick (the
metacity-era switch_group default) before a nested compositor ever
sees them, but leaves prefix+1 free; Alt+Tab is kept for hosts that
don't grab it. Grabbed so the focused client never sees the chord. */
{
bool is_tab = server.xkb_state
? (sym == XKB_KEY_Tab || sym == XKB_KEY_ISO_Left_Tab)
: key == 15;
bool is_one = server.xkb_state
? (sym == XKB_KEY_1)
: key == 2;
if (alt && (is_tab || is_one)) {
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (host.repeat_key != key) {
mru_step();
kbd_repeat_start(key, 1, REPEAT_MRU);
}
} else {
kbd_repeat_cancel();
}
return true;
}
}
/* Alt+Left / Alt+Right rotate the active tab's windows: Right
steps forward to the next window, Left steps backward to the previous
one, wrapping at the ends. Grabbed (consumed) so the focused client
@ -10848,6 +11021,10 @@ int main(int argc, char *argv[]) {
"Keys (hold the modifier):\n"
" Up / Down Cycle tabs; hold to keep "
"cycling.\n"
" Tab / 1 Walk windows most-recently-used first; "
"hold to keep\n"
" walking. The stack only changes on "
"release.\n"
" Shift+Up / Shift+Down Reorder 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 "
@ -11603,6 +11780,9 @@ int main(int argc, char *argv[]) {
handle_new_toplevel puts each client on its own slot no matter which
connects first). */
wl_list_init(&server.tabs);
wl_list_init(&server.mru);
server.mru_alt_anchor = NULL;
server.mru_alt_navigated = false;
int focus_idx = start_cmd_index >= 0 ? start_cmd_index : total_apps - 1;
struct kick_tab *focus_tab = NULL;
@ -11662,10 +11842,11 @@ int main(int argc, char *argv[]) {
const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt";
kick_log(KICK_LOG_INFO, "kick: running. "
"%s+Up/Down switches tabs; %s+Shift+Up/Down reorders; "
"%s+Up/Down switches tabs; %s+Tab/%s+1 walks recent windows; "
"%s+Shift+Up/Down reorders; "
"%s+Delete closes the active window (or the tab when it's the only window); "
"%s+Shift+T opens a terminal (%s).\n",
prefix, prefix, prefix, prefix, term_cmd);
prefix, prefix, prefix, prefix, prefix, prefix, term_cmd);
/* Standalone event loop: no host display, so drive the server loop alone.
The native output's frame signal (or the headless 60Hz timer) wakes the

View file

@ -82,6 +82,8 @@ C = 46
D = 32
G = 34
ESC = 1
TAB = 15
ONE = 2
ENTER = 28
UP = 103
DOWN = 108
@ -5609,3 +5611,71 @@ def test_standalone_headless_x11_class_deleted(tmp_path):
except Exception:
pass
s.close()
# ===================== MRU window-stack (Alt+Tab / Alt+1) =====================
def test_alt_tab_walks_windows(session):
"""Alt+Tab previews the second-from-top window in the MRU stack.
The session fixture's last tab is startup-active (app 1), so it is the
top of the still-uncommitted MRU stack; one Alt+Tab previews app 0."""
start = session.wait_any_active()
assert start == 1, f"expected startup tab 1 active, got {start}"
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
session.chord(ALT, TAB)
session.wait_active(0, timeout=3.0)
assert session.active() == 0
def test_alt_tab_commit_on_release(session):
"""Releasing Alt after an Alt+Tab move commits the shown window to the
top of the MRU stack: the next Alt+Tab returns to the just-left one.
First Alt+Tab moves from app 1 to app 0 (MRU [1,0]); release commits
app 0 to the front (MRU [0,1]); a second Alt+Tab steps back to app 1."""
start = session.wait_any_active()
assert start == 1
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
session.chord(ALT, TAB)
session.wait_active(0, timeout=3.0)
session.chord(ALT, TAB)
session.wait_active(1, timeout=3.0)
assert session.active() == 1
def test_alt_1_synonymous_with_tab(session):
"""Alt+1 previews the next window just like Alt+Tab."""
start = session.wait_any_active()
assert start == 1
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
session.chord(ALT, ONE)
session.wait_active(0, timeout=3.0)
assert session.active() == 0
def test_alt_tab_hold_repeats(session):
"""Holding Alt+Tab keeps walking the MRU stack via synthesized repeats,
without reshuffling the stack mid-hold."""
start = session.wait_any_active()
assert start == 1
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
base = session.active_count()
session.mods(ALT)
session.key(TAB, KEY_PRESSED)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and session.active_count() - base < 3:
time.sleep(0.05)
transitions = session.active_count() - base
session.key(TAB, KEY_RELEASED)
session.mods(0)
assert transitions >= 3, (
f"held Alt+Tab produced only {transitions} active transitions "
"(expected repeats to walk several windows)"
)