diff --git a/README.md b/README.md index bbeb922..2e1b7ac 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/kick.c b/kick.c index 05a04c1..2f8a9ad 100644 --- a/kick.c +++ b/kick.c @@ -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 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; diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 7e323d7..4250bda 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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}" diff --git a/tests/wltest.c b/tests/wltest.c index 3cc30e3..d59c3d7 100644 --- a/tests/wltest.c +++ b/tests/wltest.c @@ -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); }