add --sub-windows tabs|stack: sub-windows can stack on their parent tab

A second toplevel from an already-connected client is a sub-window of
that client's tab.  tabs (default) keeps today's behavior: its own slot
glued right of the parent.  stack never creates a slot: the window rides
above the parent tab's surface as a scene-tree child, sharing its
visibility, holding the keyboard while the tab is shown, and falling
back to the tab's own surface when it closes.  Stacked windows are
invisible to cycling and the overlay; closing the tab closes them.

Overlay rows follow what sits on top of the tab: the row title and icon
resolve from the topmost mapped stacked window and revert when it goes,
with a [title] log line per row-title change for observability.

wltest grows an 'X' key that closes its --extra toplevel so the unstack
path is testable.
This commit is contained in:
Andrew Hamilton 2026-08-21 11:35:21 +10:00
parent b9d1782938
commit 2dde2b6510
4 changed files with 491 additions and 21 deletions

View file

@ -138,6 +138,25 @@ terminal, and a sub-window of the tab you are currently looking at becomes
active; every other new window or tab just appears in the list, hidden, until
you cycle to it.
### Sub-windows: `--sub-windows tabs|stack`
A client's second window (a dialog, palette, or another main window from the
same process) is a *sub-window* of that client's tab. `--sub-windows` picks
how it appears:
- `tabs` (default): each sub-window gets its own tab glued immediately to the
right of its parent tab. It activates only if the parent tab is the one
being shown; otherwise it waits hidden until you cycle to it.
- `stack`: sub-windows never get a tab. They render stacked above their
parent tab's surface, share the tab's visibility, and hold the keyboard
while that tab is shown. They are invisible to cycling and to the
Alt-held overlay, and closing the tab closes them with it (same process).
When a stacked window closes, focus falls back to the tab's own surface.
`--sub-windows stack` suits apps whose dialogs feel like part of the window;
`tabs` keeps every surface individually reachable, which is kick's original
model.
## Key bindings
| Key | Action |

347
kick.c
View file

@ -171,6 +171,38 @@ struct kick_app {
cleared once the toplevel is destroyed or the slot is reused. */
bool closing;
struct wl_event_source *close_timer;
/* Stacked sub-windows (--sub-windows stack): same-process toplevels
rendered above this tab's surface instead of getting their own slot.
The list is ordered oldest first, so the tail is the topmost; newest
arrivals go to the tail. focus_sub is the entry that currently holds
the keyboard while this tab is active (NULL when the main surface
does). */
struct wl_list sub_windows;
struct kick_sub_window *focus_sub;
/* Last overlay row title / icon app_id drawn for this tab. The row
shows whatever sits on top of the tab (a stacked window can swap it),
so these caches let the overlay log one line per actual change and
re-resolve the icon when the effective app_id moves underneath. */
char row_title[128];
char icon_app_id[128];
};
/* A stacked sub-window: a second toplevel from a client whose tab already
exists (--sub-windows stack). Its scene tree is a child of the owner
tab's tree, so it shows and hides with the tab and paints above the tab's
own surface. */
struct kick_sub_window {
struct wlr_xdg_toplevel *toplevel;
struct kick_app *owner;
struct wlr_scene_tree *tree;
bool mapped;
struct wl_listener destroy;
struct wl_listener map;
struct wl_listener unmap;
struct wl_listener commit;
struct wl_list link; /* owner->sub_windows */
};
struct kick_server {
@ -388,6 +420,13 @@ static bool delete_key_held; /* prefix+Delete currently down (repeat latch) */
enum kick_prefix { PREFIX_ALT, PREFIX_SUPER };
static enum kick_prefix tab_prefix = PREFIX_ALT;
/* --sub-windows: how a second toplevel from an already-connected client
appears. 'tabs' (default) glues a slot right of its parent tab;
'stack' renders it above the parent tab's surface instead -- it shares
the tab's visibility and takes keyboard focus while that tab is shown,
and never appears in cycling or the overlay. */
static bool stack_sub_windows;
/* ===================== Clipboard bridge ===================== */
enum bridge_backend {
@ -528,6 +567,8 @@ static void kick_request_redraw(void) {
static struct kick_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel);
static void host_teardown_window(void);
static int app_index(struct kick_app *app);
static struct kick_sub_window *top_mapped_sub(struct kick_app *app);
static const char *app_row_app_id(struct kick_app *app);
static struct kick_app *pick_successor(struct kick_app *app,
int prefer_right);
static void remove_app(int idx);
@ -1370,8 +1411,10 @@ static unsigned char *overlay_load_svg(const char *path, int *w, int *h) {
the overlay draws the placeholder instead. */
static void overlay_icon_load(struct kick_app *app) {
app->icon_resolved = true;
const char *app_id = app->toplevel ? app->toplevel->app_id : NULL;
if (!app_id || !app_id[0])
const char *app_id = app_row_app_id(app);
snprintf(app->icon_app_id, sizeof(app->icon_app_id), "%s",
app_id ? app_id : "");
if (!app_id)
return;
char *icon = desktop_icon_name(app_id);
@ -1545,16 +1588,49 @@ static void fit_text(const char *src, int max_px, char *out, int out_sz)
out[n] = '\0';
}
/* Row title for an overlay slot: the mapped toplevel's title, or the
command line for a still-black (pending) tab. */
/* Row title for an overlay slot: whatever sits on top of the tab -- a mapped
stacked sub-window (--sub-windows stack), else the tab's own toplevel --
or the command line for a still-black (pending) tab. Logs one INFO line
whenever a row's resolved title changes, so a stacked window swapping the
title underneath an unchanged tab is observable in logs and tests. */
static const char *app_row_title(struct kick_app *app) {
if (app->toplevel && app->toplevel->title && app->toplevel->title[0])
return app->toplevel->title;
if (app && app->cmd && app->cmd[0])
return app->cmd;
const char *t = NULL;
struct kick_sub_window *top = top_mapped_sub(app);
if (top && top->toplevel->title && top->toplevel->title[0])
t = top->toplevel->title;
else if (app->toplevel && app->toplevel->title && app->toplevel->title[0])
t = app->toplevel->title;
else if (app->cmd && app->cmd[0])
t = app->cmd;
const char *cur = t ? t : "";
if (strcmp(cur, app->row_title) != 0) {
kick_log(KICK_LOG_INFO, "[title] app=%d '%s'\n",
app_index(app), cur);
snprintf(app->row_title, sizeof(app->row_title), "%s", cur);
}
return t;
}
/* Effective overlay app_id for a tab: the topmost mapped stacked sub-window
wins over the tab's own toplevel, mirroring app_row_title. */
static const char *app_row_app_id(struct kick_app *app) {
struct kick_sub_window *top = top_mapped_sub(app);
if (top && top->toplevel->app_id && top->toplevel->app_id[0])
return top->toplevel->app_id;
if (app->toplevel && app->toplevel->app_id && app->toplevel->app_id[0])
return app->toplevel->app_id;
return NULL;
}
/* Icons latch after one load attempt; re-latch when the effective app_id
changed underneath the tab (a stacked window took over, or went away). */
static bool overlay_icon_needs_load(struct kick_app *app) {
if (!app->icon_resolved)
return true;
const char *id = app_row_app_id(app);
return id && strcmp(id, app->icon_app_id) != 0;
}
/* A row in the overlay (both the host-GL and the standalone wlr paths):
skip slots that are dead or mid-close; black pending tabs are rows too. */
static bool app_overlay_row(struct kick_app *app) {
@ -1572,7 +1648,7 @@ static void draw_tab_overlay(void) {
for (int i = 0; i < server.num_apps; i++) {
struct kick_app *app = server.apps[i];
if (!app_overlay_row(app)) continue;
if (!app->icon_resolved)
if (overlay_icon_needs_load(app))
overlay_icon_load(app);
const char *title = app_row_title(app);
char fallback[16];
@ -1727,7 +1803,7 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass) {
for (int i = 0; i < server.num_apps; i++) {
struct kick_app *app = server.apps[i];
if (!app_overlay_row(app)) continue;
if (!app->icon_resolved)
if (overlay_icon_needs_load(app))
overlay_icon_load(app);
const char *title = app_row_title(app);
char fallback[16];
@ -2098,6 +2174,19 @@ static void remove_app(int idx) {
wlr_texture_destroy(dead->icon_wlr);
dead->icon_wlr = NULL;
}
/* Stacked sub-windows of the dead tab: drop our records without ever
touching the freed owner again. Their clients die with the tab's
process group, and their scene trees go with the owner's tree. */
while (!wl_list_empty(&dead->sub_windows)) {
struct kick_sub_window *sub =
wl_container_of(dead->sub_windows.next, sub, link);
wl_list_remove(&sub->destroy.link);
wl_list_remove(&sub->map.link);
wl_list_remove(&sub->unmap.link);
wl_list_remove(&sub->commit.link);
wl_list_remove(&sub->link);
free(sub);
}
free(dead->cmd);
free(dead);
memmove(&server.apps[idx], &server.apps[idx + 1],
@ -2285,19 +2374,27 @@ static void handle_request_resize(struct wl_listener *listener, void *data) {
(void)listener; (void)data;
}
static void position_toplevel(struct kick_app *app) {
if (!app || !app->tree) return;
/* Place a scene tree so the client's content sits at 0,0. Shared by tabs
and stacked sub-windows. */
static void position_tree(struct wlr_xdg_toplevel *toplevel,
struct wlr_scene_tree *tree) {
if (!tree) return;
/* CSD clients (foot/kitty) reserve a titlebar gutter above their
content, which we don't paint, so it shows as a dark bar and their
content sits too low (bottom row/cursor cropped). Shift them up by
that gutter so the dark bar is cropped instead of the terminal's
bottom. Clients with no gutter (geometry.y >= 0, e.g. wltest) are
left untouched. */
struct wlr_box geom = app->toplevel->base->geometry;
struct wlr_box geom = toplevel->base->geometry;
int y = -geom.y;
if (geom.y < 0)
y = 0;
wlr_scene_node_set_position(&app->tree->node, -geom.x, y);
wlr_scene_node_set_position(&tree->node, -geom.x, y);
}
static void position_toplevel(struct kick_app *app) {
if (!app || !app->tree) return;
position_tree(app->toplevel, app->tree);
}
static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
@ -2457,6 +2554,144 @@ static struct kick_app *sub_window_owner(pid_t cpid) {
return NULL;
}
/* ===================== Stacked sub-windows (--sub-windows stack) ===== */
/* The topmost mapped stacked sub-window of a tab, or NULL. */
static struct kick_sub_window *top_mapped_sub(struct kick_app *app) {
struct kick_sub_window *sub;
wl_list_for_each_reverse(sub, &app->sub_windows, link)
if (sub->mapped)
return sub;
return NULL;
}
/* Give the keyboard to a stacked sub-window of the active tab, demoting
whatever held it before (the main surface or an older sub-window). */
static void sub_enter(struct kick_sub_window *sub) {
struct kick_app *app = sub->owner;
if (app->focus_sub == sub) return;
if (app->focus_sub && app->focus_sub->toplevel)
wlr_xdg_toplevel_set_activated(app->focus_sub->toplevel, false);
if (app->toplevel)
wlr_xdg_toplevel_set_activated(app->toplevel, false);
wlr_xdg_toplevel_set_activated(sub->toplevel, true);
struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
if (!kbd) kbd = &server.keyboard;
wlr_seat_keyboard_notify_enter(server.seat,
sub->toplevel->base->surface,
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
app->focus_sub = sub;
kick_request_redraw();
}
/* After the focused sub-window unmaps or dies, hand the keyboard back to
the owner's next topmost mapped sub-window or its main surface. No-op
unless the owner is the tab being shown. */
static void sub_refocus_owner(struct kick_app *app) {
if (server.active_app != app) return;
struct kick_sub_window *top = top_mapped_sub(app);
if (top) {
sub_enter(top);
return;
}
app->focus_sub = NULL;
if (app->toplevel) {
kick_log(KICK_LOG_INFO, "[sub] refocus app=%d\n", app_index(app));
wlr_xdg_toplevel_set_activated(app->toplevel, true);
struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
if (!kbd) kbd = &server.keyboard;
wlr_seat_keyboard_notify_enter(server.seat,
app->toplevel->base->surface,
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
}
kick_request_redraw();
}
static void handle_sub_commit(struct wl_listener *listener, void *data) {
(void)data;
struct kick_sub_window *sub = wl_container_of(listener, sub, commit);
struct wlr_xdg_toplevel *toplevel = sub->toplevel;
/* Same treatment as a tab's own surface: maximize and size to the
output on first commit, then keep the CSD-gutter position in sync. */
if (toplevel->base->initial_commit) {
wlr_xdg_toplevel_set_maximized(toplevel, true);
wlr_xdg_toplevel_set_size(toplevel, server.output->width,
server.output->height);
if (server.clients_fullscreen)
wlr_xdg_toplevel_set_fullscreen(toplevel, true);
}
position_tree(toplevel, sub->tree);
}
static void handle_sub_map(struct wl_listener *listener, void *data) {
(void)data;
struct kick_sub_window *sub = wl_container_of(listener, sub, map);
sub->mapped = true;
/* A sub-window never steals focus: it appears with its owner tab and
takes the keyboard only when that tab is already the one shown. */
if (server.active_app == sub->owner) {
kick_log(KICK_LOG_INFO, "[sub] focus app=%d\n",
app_index(sub->owner));
sub_enter(sub);
} else {
kick_log(KICK_LOG_INFO, "[sub] map app=%d\n", app_index(sub->owner));
}
}
static void handle_sub_unmap(struct wl_listener *listener, void *data) {
(void)data;
struct kick_sub_window *sub = wl_container_of(listener, sub, unmap);
sub->mapped = false;
if (sub->owner->focus_sub == sub)
sub_refocus_owner(sub->owner);
}
static void handle_sub_destroy(struct wl_listener *listener, void *data) {
(void)data;
struct kick_sub_window *sub = wl_container_of(listener, sub, destroy);
struct kick_app *app = sub->owner;
bool had_focus = app->focus_sub == sub;
wl_list_remove(&sub->destroy.link);
wl_list_remove(&sub->map.link);
wl_list_remove(&sub->unmap.link);
wl_list_remove(&sub->commit.link);
wl_list_remove(&sub->link);
kick_log(KICK_LOG_INFO, "[sub] unstack app=%d\n", app_index(app));
if (had_focus) {
app->focus_sub = NULL;
sub_refocus_owner(app);
}
free(sub);
}
/* Attach `toplevel` as a stacked sub-window of `owner`: scene-tree child of
the owner's tree (shows/hides with the tab, paints above its surface),
same maximize/size/position treatment as a tab. Never creates a slot. */
static void attach_stacked_sub(struct kick_app *owner,
struct wlr_xdg_toplevel *toplevel) {
struct kick_sub_window *sub = calloc(1, sizeof(*sub));
if (!sub) return;
sub->toplevel = toplevel;
sub->owner = owner;
sub->tree = wlr_scene_xdg_surface_create(owner->tree, toplevel->base);
if (!sub->tree) {
free(sub);
return;
}
wlr_scene_node_set_enabled(&sub->tree->node,
owner->tree && owner->tree->node.enabled);
sub->commit.notify = handle_sub_commit;
wl_signal_add(&toplevel->base->surface->events.commit, &sub->commit);
sub->map.notify = handle_sub_map;
wl_signal_add(&toplevel->base->surface->events.map, &sub->map);
sub->unmap.notify = handle_sub_unmap;
wl_signal_add(&toplevel->base->surface->events.unmap, &sub->unmap);
sub->destroy.notify = handle_sub_destroy;
wl_signal_add(&toplevel->events.destroy, &sub->destroy);
wl_list_insert(owner->sub_windows.prev, &sub->link);
kick_log(KICK_LOG_INFO, "[sub] stack app=%d\n", app_index(owner));
}
static void handle_toplevel_map(struct wl_listener *listener, void *data) {
(void)data;
struct kick_app *app = wl_container_of(listener, app, map);
@ -2529,6 +2764,12 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
if (!app && cpid > 0) {
struct kick_app *owner = sub_window_owner(cpid);
if (owner) {
/* Stack mode: no slot at all -- the window rides on its
owner's tab, above its surface. */
if (stack_sub_windows) {
attach_stacked_sub(owner, toplevel);
return;
}
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
struct kick_app *sub = calloc(1, sizeof(*sub));
if (!sub) return;
@ -2536,6 +2777,7 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
sub->cmd = NULL;
sub->origin = ORIGIN_SUB;
sub->parent_app = owner;
wl_list_init(&sub->sub_windows);
struct kick_app *pa = toplevel->parent
? app_for_toplevel(toplevel->parent) : NULL;
insert_app_after(sub, pa ? pa : owner);
@ -2566,6 +2808,7 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
extra->pid = -1;
extra->cmd = NULL;
extra->origin = ORIGIN_EXTERNAL;
wl_list_init(&extra->sub_windows);
struct kick_app *pa = toplevel->parent
? app_for_toplevel(toplevel->parent) : NULL;
if (!pa)
@ -2798,10 +3041,19 @@ static void set_active_app(struct kick_app *app) {
if (!app) return;
struct kick_app *old = server.active_app;
if (old && old != app && old->toplevel) {
if (old->tree)
wlr_scene_node_set_enabled(&old->tree->node, false);
wlr_xdg_toplevel_set_activated(old->toplevel, false);
if (old && old != app) {
/* A stacked sub-window holding the keyboard loses it with the tab. */
if (old->focus_sub) {
if (old->focus_sub->toplevel)
wlr_xdg_toplevel_set_activated(old->focus_sub->toplevel,
false);
old->focus_sub = NULL;
}
if (old->toplevel) {
if (old->tree)
wlr_scene_node_set_enabled(&old->tree->node, false);
wlr_xdg_toplevel_set_activated(old->toplevel, false);
}
}
server.active_app = app;
@ -2823,15 +3075,30 @@ static void set_active_app(struct kick_app *app) {
/* In standalone the seat keyboard is the emitting native device (set by
the standalone handlers); fall back to the virtual keyboard otherwise
so the nested/ctl path keeps using it. A black active tab has no
surface to enter, so clear the focus instead of leaving a stale one. */
surface to enter, so clear the focus instead of leaving a stale one.
A tab with mapped stacked sub-windows hands the keyboard to its
topmost one instead of its main surface. */
struct kick_sub_window *top = top_mapped_sub(app);
struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat);
if (!kbd) kbd = &server.keyboard;
if (app->toplevel)
if (top) {
kick_log(KICK_LOG_INFO, "[sub] focus app=%d\n", app_index(app));
if (app->toplevel)
wlr_xdg_toplevel_set_activated(app->toplevel, false);
wlr_xdg_toplevel_set_activated(top->toplevel, true);
wlr_seat_keyboard_notify_enter(server.seat,
top->toplevel->base->surface,
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
app->focus_sub = top;
} else if (app->toplevel) {
wlr_seat_keyboard_notify_enter(server.seat,
app->toplevel->base->surface,
kbd->keycodes, kbd->num_keycodes, &kbd->modifiers);
else
app->focus_sub = NULL;
} else {
app->focus_sub = NULL;
wlr_seat_keyboard_notify_clear_focus(server.seat);
}
/* The newly-active tab owns the cursor shape: apply its last requested
shape (or default when it never asked) so a switched-from tab's shape
@ -6089,6 +6356,7 @@ static int add_tab(const char *cmd) {
app->pid = -1;
app->pending_claim = true;
app->origin = ORIGIN_ADD_TAB;
wl_list_init(&app->sub_windows);
/* Anchor on the active slot itself, not its toplevel: while the active
tab is still black (its client has not connected yet) it has no
@ -6278,6 +6546,10 @@ int main(int argc, char *argv[]) {
"labwc-style hosts.\n"
" --start-cmd-index picks the command (1-based) that "
"starts focused; without it the last command does.\n"
" --sub-windows picks how a client's second window "
"appears: 'tabs' gives each its own tab right of its "
"parent (default); 'stack' stacks it over the parent "
"tab, sharing its visibility and focus.\n"
" Each <prog> is one shell command; quote it when it "
"takes args, e.g. \"firefox -no-remote\".\n"
" With no arguments, a single terminal tab opens "
@ -6329,6 +6601,38 @@ int main(int argc, char *argv[]) {
return 2;
}
continue;
} else if (strcmp(argv[i], "--sub-windows") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "kick: --sub-windows needs a value "
"(tabs or stack)\n");
free(appv);
return 2;
}
const char *m = argv[++i];
if (strcmp(m, "tabs") == 0) {
stack_sub_windows = false;
} else if (strcmp(m, "stack") == 0) {
stack_sub_windows = true;
} else {
fprintf(stderr, "kick: bad --sub-windows '%s' "
"(use 'tabs' or 'stack')\n", m);
free(appv);
return 2;
}
continue;
} else if (strncmp(argv[i], "--sub-windows=", 14) == 0) {
const char *m = argv[i] + 14;
if (strcmp(m, "tabs") == 0) {
stack_sub_windows = false;
} else if (strcmp(m, "stack") == 0) {
stack_sub_windows = true;
} else {
fprintf(stderr, "kick: bad --sub-windows '%s' "
"(use 'tabs' or 'stack')\n", m);
free(appv);
return 2;
}
continue;
} else if (strcmp(argv[i], "--start-cmd-index") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "kick: --start-cmd-index needs a "
@ -6748,6 +7052,7 @@ int main(int argc, char *argv[]) {
app->origin = ORIGIN_CLI;
/* Live-but-black until the command claims the slot with a toplevel. */
app->pending_claim = true;
wl_list_init(&app->sub_windows);
server.apps[i] = app;
}
server.num_apps = total_apps;

View file

@ -1715,6 +1715,114 @@ def test_sub_window_activates_when_parent_active():
s.proc.terminate()
def test_sub_windows_stack_mode_never_claims_a_slot():
"""--sub-windows stack: a second toplevel from an already-running client
rides on its owner's tab -- it logs [sub] stack, never claims a slot of
its own, and takes the keyboard only because its parent tab is the one
being shown."""
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=("--sub-windows", "stack"))
try:
s.wait_any_active() # app 0 crowned on its first map
s.wait_for(r"\[sub\] stack app=0\b")
s.wait_for(r"\[sub\] focus app=0\b")
time.sleep(0.5)
claims = CLAIM_RE.findall(s.read_log())
assert len(claims) == 1 and claims[0][0] == "0", \
f"stacked sub-window claimed a slot: {claims}"
assert len(ACTIVE_RE.findall(s.read_log())) == 1, \
"stacked sub-window stole focus via an active transition"
assert s.proc.poll() is None
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_sub_windows_stack_mode_focus_follows_tab():
"""--sub-windows stack: a stacked window of a background tab maps
without taking the keyboard; showing its tab hands focus to it."""
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
# Tab 0 (RED) opens a green extra; tab 1 (BLUE) is the startup-selected
# active tab, so the extra must map hidden.
s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'",
extra_args=("--sub-windows", "stack"))
try:
s.wait_any_active() # app 1 crowned
s.wait_for(r"\[sub\] stack app=0\b")
s.wait_for(r"\[sub\] map app=0\b") # mapped while its tab is hidden
time.sleep(0.3)
assert "[sub] focus app=0" not in s.read_log(), \
"hidden tab's stacked window took the keyboard"
s.cycle_ack(-1) # show tab 0
s.wait_for(r"\[sub\] focus app=0\b")
assert s.proc.poll() is None
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_sub_windows_stack_mode_unstack_refocuses_then_close():
"""--sub-windows stack: closing the stacked window ('X' in wltest)
refocuses the tab's own surface without killing the tab; Alt+Delete on
the tab then tears the whole unit 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=("--sub-windows", "stack"))
try:
s.wait_any_active()
s.wait_for(r"\[sub\] focus app=0\b") # extra holds the keyboard
s.tap(45) # 'X': close the extra
s.wait_for(r"\[sub\] unstack app=0\b")
s.wait_for(r"\[sub\] refocus app=0\b")
time.sleep(0.5)
claims = CLAIM_RE.findall(s.read_log())
assert len(claims) == 1, f"tab lost or gained slots: {claims}"
assert s.proc.poll() is None, "compositor died when the extra closed"
s.chord(ALT, DELETE) # close the tab itself
deadline = time.monotonic() + 10.0
while s.proc.poll() is None:
assert time.monotonic() < deadline, \
"Alt+Delete never tore the tab with stacked windows down"
time.sleep(0.1)
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_sub_windows_stack_mode_row_title_follows_top():
"""--sub-windows stack: the overlay row shows the topmost stacked
window's title while it is mapped, and falls back to the tab's own
surface title when it closes ('X' in wltest)."""
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=("--sub-windows", "stack"))
try:
s.wait_any_active()
s.wait_for(r"\[sub\] focus app=0\b")
# The resolved-title log only fires while overlay rows are drawn,
# so hold Alt for the whole check.
s.mods(ALT)
try:
s.wait_for(r"\[title\] app=0 'extra'")
s.tap(45) # 'X': close the extra
s.wait_for(r"\[title\] app=0 '#ff0000'")
finally:
s.mods(0)
assert s.proc.poll() is None
finally:
s.close()
if s.proc.poll() is None:
s.proc.terminate()
def test_alt_delete_activates_right_neighbor():
"""Alt+Delete closes the active tab, making the tab to its right active
(SIGTERM, escalated to SIGKILL only if the client lingers). With three
@ -2144,3 +2252,30 @@ def test_start_cmd_index_errors():
f"--start-cmd-index {case} should exit rc 2; rc={proc.returncode}\n{out}"
assert b"--start-cmd-index" in out, \
f"error should mention --start-cmd-index:\n{out}"
def test_sub_windows_flag_errors():
"""A missing or bad --sub-windows value is a startup error (rc 2) naming
the flag, before any window or backend work."""
env = dict(os.environ)
cases = (["--sub-windows"],
["--sub-windows", "float"],
["--sub-windows=float"])
for case in cases:
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"stdbuf", "-oL",
BIN] + case,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
out, _ = proc.communicate(timeout=15)
assert proc.returncode == 2, \
f"--sub-windows {case} should exit rc 2; rc={proc.returncode}\n{out}"
assert b"--sub-windows" in out, \
f"error should mention --sub-windows:\n{out}"

View file

@ -179,6 +179,17 @@ static void keyboard_key(void *data, struct wl_keyboard *kb, uint32_t serial,
else
xdg_toplevel_unset_fullscreen(toplevel);
}
/* 'X' (evdev 45) closes the --extra toplevel, letting the smoke tests
exercise a stacked sub-window going away while its tab lives on. */
if (key == 45 && state == WL_KEYBOARD_KEY_STATE_PRESSED &&
ex_toplevel) {
xdg_toplevel_destroy(ex_toplevel);
xdg_surface_destroy(ex_xdg);
wl_surface_destroy(ex_surface);
ex_toplevel = NULL;
ex_xdg = NULL;
ex_surface = NULL;
}
if (state == WL_KEYBOARD_KEY_STATE_PRESSED)
maybe_set_primary(serial);
}