From e7df8d5396eb502855349ebfe01046a106506a7e Mon Sep 17 00:00:00 2001 From: Andrew Hamilton Date: Sat, 12 Sep 2026 12:20:25 +1000 Subject: [PATCH] feat: per-entry fullscreen, host chrome follows shown clients --- README.md | 17 ++- kick.c | 271 +++++++++++++++++++++++++++++++++----------- tests/test_smoke.py | 150 +++++++++++++++++++++--- 3 files changed, 352 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 2044141..9d90a5d 100644 --- a/README.md +++ b/README.md @@ -289,14 +289,19 @@ browser's `F11`). ## Fullscreen -Fullscreen mirrors between the host window and the clients: +Fullscreen is per overlay entry: - Fullscreen the kick window (your host compositor's fullscreen keybinding, - or `Alt+F11`) and every window of every tab -- mains and stacked - tab-group members alike -- is told it is fullscreen, so clients drop - their decorations and fill the screen. -- A client that requests fullscreen itself (e.g. a video player) also - fullscreens the kick host window: one action fills the whole monitor. + or `Alt+F11`) and only the focused overlay entry is told it is + fullscreen, so it drops decorations and fills the screen. Unfocused + stacked members, other tabs and new arrivals stay maximized and grow to + the fullscreen output size without gaining fullscreen state. A black tab + (client not yet mapped) picks fullscreen up when it maps. +- A shown client that requests fullscreen itself (e.g. Firefox F11) takes + the host window with it: one action fills the whole monitor, still for + that entry only. Exiting (second F11) drops the host chrome again. A + hidden tab's request is remembered via the protocol and live when you + cycle to it, without touching the host. - Leaving fullscreen returns every window to maximized (X11 tab-group members keep their own geometry). diff --git a/kick.c b/kick.c index 9e7d3a7..318d850 100644 --- a/kick.c +++ b/kick.c @@ -255,6 +255,12 @@ struct kick_window { of being configured to the output size. */ bool transient; + /* Fullscreen state is NOT stored here: it lives in the protocol objects + (xdg toplevel->scheduled.fullscreen, X11 xsurface->fullscreen) and is + read via window_is_fullscreen. server.clients_fullscreen remains the + aggregate (host chrome OR any live window) driving panel visibility + and the fullscreen layout. */ + struct wl_listener destroy; struct wl_listener map; struct wl_listener unmap; @@ -666,8 +672,10 @@ struct kick_server { per --modifier). */ xkb_mod_index_t xkb_prefix_mod; - /* A client's fullscreen request is honored and mirrored onto the host - window, and vice versa. */ + /* Aggregate fullscreen state: host chrome fullscreen OR any live + window fullscreen (read from the protocol objects via + window_is_fullscreen, never stored). Drives panel visibility and + the fullscreen layout. */ bool clients_fullscreen; /* Standalone (real output) mode: the output is driven directly by a @@ -976,6 +984,11 @@ static void layer_apply_layout(void); static void layer_raise_above_windows(void); static void layer_update_visibility(void); static void clients_set_fullscreen(bool fs); +static bool window_is_fullscreen(struct kick_window *w); +static bool any_window_fullscreen(void); +static void fullscreen_sync_aggregate(void); +static void window_apply_fullscreen(struct kick_window *w, bool fs); +static bool window_is_shown(struct kick_window *w); /* Request a redraw from a non-client source (kick's own UI: tab switches, reorder, resize, overlay, boot). Marks the frame dirty and wakes the @@ -1351,6 +1364,21 @@ static void window_set_fullscreen(struct kick_window *w, bool on) { #endif } +/* Live fullscreen state, read from the protocol objects (never stored): + xdg uses scheduled (what we've told the client; current lags until the + client acks, requested is the client's wish). Uninitialized windows read + as not fullscreen. */ +static bool window_is_fullscreen(struct kick_window *w) { + if (!w) return false; + if (w->toplevel) + return w->toplevel->base->initialized && w->toplevel->scheduled.fullscreen; +#if HAVE_XWAYLAND + if (w->is_x11) + return w->xsurface && w->xsurface->fullscreen; +#endif + return false; +} + /* Keep a mapped window at the maximized output size. XDG clients are told via set_size; X11 clients get a configure to the full output at 0,0. */ static void window_set_size(struct kick_window *w, int width, int height) { @@ -3246,6 +3274,7 @@ static void window_destroy_common(struct kick_window *w) { w->close_timer = NULL; } bool had_focus = w->focused; + bool was_fs = window_is_fullscreen(w); /* Tear down the taskbar handles so taskbars drop the entry now. */ foreign_handle_destroy(w); free(w->tag); @@ -3257,10 +3286,29 @@ static void window_destroy_common(struct kick_window *w) { free(w); if (wl_list_empty(&tab->windows)) { drop_tab(tab); + /* Host chrome still fullscreen but its client is gone: hand + fullscreen to the successor so host and client stay in sync. */ + if (host.fullscreen && server.active_tab) { + struct kick_window *nw = tab_current_window(server.active_tab); + if (nw && !window_is_fullscreen(nw) && window_initialized(nw)) + window_set_fullscreen(nw, true); + } + (void)was_fs; + fullscreen_sync_aggregate(); return; } if (had_focus) refocus_tab(tab); + if (was_fs) { + /* Fullscreen window closed itself: if the host chrome is still up, + keep a client fullscreen on this tab; otherwise just restore. */ + if (host.fullscreen && tab == server.active_tab) { + struct kick_window *nw = tab_current_window(tab); + if (nw && !window_is_fullscreen(nw) && window_initialized(nw)) + window_set_fullscreen(nw, true); + } + fullscreen_sync_aggregate(); + } /* The topmost window changed (a stacked member left): re-resolve the row title now that focus has settled, so the fallback is log-observable without an overlay draw. */ @@ -3445,37 +3493,105 @@ static void handle_request_fullscreen(struct wl_listener *listener, void *data) struct wlr_xdg_toplevel *toplevel = w->toplevel; if (!toplevel->base->initialized) return; /* wlroots 0.19 emits request_fullscreen with NULL data; the direction - lives in toplevel->requested.fullscreen. Honor the client's request - and mirror it onto the host window: one action fullscreens the whole - monitor. */ + lives in toplevel->requested.fullscreen. Per-tab: only the + requesting overlay entry is told fullscreen. A shown entry also + takes the host chrome with it (nested); a hidden entry is just + marked via the protocol and stays hidden. Exiting always drops the + host chrome again (simple mirror, no conditions). */ bool fs = toplevel->requested.fullscreen; - wlr_xdg_toplevel_set_fullscreen(toplevel, fs); - kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", fs); - host_apply_fullscreen(fs); - /* Standalone has no host window to round-trip through: propagate - directly so panels hide and every tab follows, like nested does via - the host configure. */ - if (server.standalone) - clients_set_fullscreen(fs); + window_apply_fullscreen(w, fs); + if (server.standalone) return; + if (fs) { + if (window_is_shown(w)) + host_apply_fullscreen(true); + } else { + host_apply_fullscreen(false); + } } -/* Propagate the host window's fullscreen state onto every client window. - Called from host_xdg_toplevel_configure when that state actually changes. - X11 windows keep their own geometry (only into a tab by an explicit - --tab-groups group attachment), so on the way out only xdg windows and - tab-owned x11 windows get the re-maximize/resize; windows arriving later - pick the flag up at their first commit. */ -static void clients_set_fullscreen(bool fs) { - server.clients_fullscreen = fs; - kick_log(KICK_LOG_INFO, "clients fullscreen=%d\n", fs); - kick_log(KICK_LOG_INFO, "members fullscreen=%d\n", fs); +/* Whether any live window holds fullscreen state (visible or hidden). */ +static bool any_window_fullscreen(void) { 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_initialized(w)) continue; - window_set_fullscreen(w, fs); - if (!fs && server.output && + if (window_is_fullscreen(w)) return true; + } + } + return false; +} + +/* Recompute the aggregate fullscreen state (host chrome OR any live + window), driving panel visibility and the fullscreen layout. All tabs + are sized to the full area while aggregated, so non-fullscreen tabs grow + bigger without gaining fullscreen state. */ +static void fullscreen_sync_aggregate(void) { + bool agg = host.fullscreen || any_window_fullscreen(); + if (agg == server.clients_fullscreen) return; + server.clients_fullscreen = agg; + kick_log(KICK_LOG_INFO, "clients fullscreen=%d\n", agg); + kick_log(KICK_LOG_INFO, "members fullscreen=%d\n", agg); + layer_update_visibility(); + layer_apply_layout(); +} + +/* Whether a window is the currently shown overlay entry. */ +static bool window_is_shown(struct kick_window *w) { + return w && w->tab == server.active_tab && + w == tab_current_window(server.active_tab); +} + +/* Apply a per-window fullscreen change and sync the aggregate. Hidden + windows are still told (protocol ack) so the state is live on return. */ +static void window_apply_fullscreen(struct kick_window *w, bool fs) { + if (!w) { + fullscreen_sync_aggregate(); + return; + } + if (!window_initialized(w)) { + fullscreen_sync_aggregate(); + return; + } + bool cur = window_is_fullscreen(w); + window_set_fullscreen(w, fs); + if (cur != fs) + kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", fs); + fullscreen_sync_aggregate(); +} + +/* Propagate a host-window fullscreen transition onto clients per-tab. + Enter: fullscreen the focused overlay entry only -- every other tab, and + every unfocused stacked member, stays maximized and merely grows to the + fullscreen output size. A black (pending) active tab keeps no protocol + state to set; it picks fullscreen up at map via host.fullscreen (see the + initial-commit and map paths). Exit: clear every live window and + re-maximize. */ +static void clients_set_fullscreen(bool fs) { + if (fs) { + if (!any_window_fullscreen()) { + struct kick_tab *at = server.active_tab; + struct kick_window *w = at ? tab_current_window(at) : NULL; + if (w && window_initialized(w)) { + window_set_fullscreen(w, true); + kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", true); + } + } + if (!server.clients_fullscreen) { + server.clients_fullscreen = true; + kick_log(KICK_LOG_INFO, "clients fullscreen=%d\n", true); + kick_log(KICK_LOG_INFO, "members fullscreen=%d\n", true); + } + layer_update_visibility(); + layer_apply_layout(); + return; + } + 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_is_fullscreen(w) && window_initialized(w)) + window_set_fullscreen(w, false); + if (server.output && (w->toplevel || w->origin != ORIGIN_GROUP || w->tab != w->parent_tab)) { window_set_maximized(w, true); @@ -3483,26 +3599,24 @@ static void clients_set_fullscreen(bool fs) { } } } - /* A fullscreen tab takes the whole output: hide the panel/layer trees - so a bar (e.g. waybar) does not paint over it, and re-run the layout - pass so tabs grow back to the full area (restored to the bar-reserved - usable box on the way out). */ - layer_update_visibility(); - layer_apply_layout(); + fullscreen_sync_aggregate(); } -/* Toggle fullscreen. Nested flips the host window (see - host_apply_fullscreen); the actual flip happens asynchronously in - host_toplevel_configure, where the resulting configure updates - host.fullscreen and propagates to every client. Standalone has no host - window to round-trip through, so flip the clients directly -- otherwise a - client-initiated fullscreen (e.g. firefox) outlives its tab with no way - back once that tab closes. */ +/* Toggle fullscreen. Nested flips the host window chrome (see + host_apply_fullscreen); the resulting configure fullscreens the focused + overlay entry via clients_set_fullscreen. Standalone has no host window, + so flip the focused entry directly. */ static void host_toggle_fullscreen(void) { - if (server.standalone) - clients_set_fullscreen(!server.clients_fullscreen); - else - host_apply_fullscreen(!host.fullscreen); + if (server.standalone) { + struct kick_tab *at = server.active_tab; + struct kick_window *w = at ? tab_current_window(at) : NULL; + if (w) + window_apply_fullscreen(w, !window_is_fullscreen(w)); + else + fullscreen_sync_aggregate(); + return; + } + host_apply_fullscreen(!host.fullscreen); } static void handle_request_minimize(struct wl_listener *listener, void *data) { @@ -3558,7 +3672,12 @@ static void handle_toplevel_commit(struct wl_listener *listener, void *data) { if (server.output) wlr_xdg_toplevel_set_size(toplevel, server.output->width, server.output->height); - if (server.clients_fullscreen) + /* Per-tab: new windows start maximized-bigger, never fullscreen -- + except a black tab revealed while the host chrome is fullscreen + (Alt+F11 landed before its client mapped): the focused entry + picks fullscreen up here. */ + if (host.fullscreen && w->tab == server.active_tab && + w == tab_current_window(server.active_tab)) wlr_xdg_toplevel_set_fullscreen(toplevel, true); } /* Geometry can settle after the first frames (kitty/foot report a @@ -3836,6 +3955,12 @@ static void handle_window_map(struct wl_listener *listener, void *data) { } } #endif + /* Black-tab fix: a tab revealed while the host chrome is fullscreen + (Alt+F11 landed before its client mapped) picks fullscreen up here. + Live fullscreen states otherwise survive mapping on their own. */ + if (host.fullscreen && w->tab == server.active_tab && + w == tab_current_window(server.active_tab) && window_initialized(w)) + window_set_fullscreen(w, true); if (kick_log_level() >= KICK_LOG_DEBUG) kick_log(KICK_LOG_DEBUG, "[diag] map app=%d content=%s\n", tab_index(w->tab), @@ -4181,8 +4306,6 @@ static void x11_initial_configure(struct wlr_xwayland_surface *xs) { (uint16_t)server.output->width, (uint16_t)server.output->height); wlr_xwayland_surface_set_maximized(xs, true, true); - if (server.clients_fullscreen) - wlr_xwayland_surface_set_fullscreen(xs, true); } /* Claim an X11 toplevel by backing `w` with the xwayland surface: a scene @@ -4418,13 +4541,21 @@ static void handle_xs_request_fullscreen(struct wl_listener *listener, struct kick_window *w = wl_container_of(listener, w, request_fullscreen); struct wlr_xwayland_surface *xs = w->xsurface; if (!xs) return; - /* Mirror onto the host window like the xdg path. */ + /* Per-tab like the xdg path: shown entries take the host chrome too, + hidden ones are just marked; exiting always drops the host chrome. + (wlroots applies the client's wish to xs->fullscreen before + emitting, so the change is already live here: log unconditionally, + since window_apply's change detection cannot see it.) */ bool fs = xs->fullscreen; - wlr_xwayland_surface_set_fullscreen(xs, fs); + window_apply_fullscreen(w, fs); kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", fs); - host_apply_fullscreen(fs); - if (server.standalone) - clients_set_fullscreen(fs); + if (server.standalone) return; + if (fs) { + if (window_is_shown(w)) + host_apply_fullscreen(true); + } else { + host_apply_fullscreen(false); + } } static void handle_xs_request_configure(struct wl_listener *listener, @@ -7207,9 +7338,14 @@ static void handle_foreign_request_maximize(struct wl_listener *listener, void * static void handle_foreign_request_fullscreen(struct wl_listener *listener, void *data) { struct wlr_foreign_toplevel_handle_v1_fullscreen_event *event = data; struct kick_window *w = wl_container_of(listener, w, ft_fullscreen); - window_set_fullscreen(w, event->fullscreen); - if (event->fullscreen) - host_apply_fullscreen(true); + window_apply_fullscreen(w, event->fullscreen); + if (server.standalone) return; + if (event->fullscreen) { + if (window_is_shown(w)) + host_apply_fullscreen(true); + } else { + host_apply_fullscreen(false); + } } static void handle_foreign_handle_destroy(struct wl_listener *listener, void *data) { @@ -8122,14 +8258,16 @@ static void host_xdg_toplevel_configure(void *data, struct xdg_toplevel *t, struct wl_array *states) { (void)data; (void)t; + /* Mirror the host window's state per entry: entering fullscreen + fullscreens the focused overlay entry only (others grow via the + resize below); leaving clears every live window. */ + bool prev_fs = host.fullscreen; host.fullscreen = false; uint32_t *state; wl_array_for_each(state, states) if (*state == XDG_TOPLEVEL_STATE_FULLSCREEN) host.fullscreen = true; - /* Mirror the host window's state onto the clients: fullscreen begets - fullscreen, anything else begets maximized. */ - if (host.fullscreen != server.clients_fullscreen) { + if (host.fullscreen != prev_fs) { kick_log(KICK_LOG_INFO, "host fullscreen=%d\n", host.fullscreen); clients_set_fullscreen(host.fullscreen); } @@ -8232,9 +8370,9 @@ static void host_frame_configure(struct libdecor_frame *frame, bool fs = (state & LIBDECOR_WINDOW_STATE_FULLSCREEN) != 0; if (fs != host.fullscreen) { kick_log(KICK_LOG_INFO, "host fullscreen=%d\n", fs); + host.fullscreen = fs; clients_set_fullscreen(fs); } - host.fullscreen = fs; } /* libdecor reports the *content* size; the host window wraps that content with its decorations. The content is exactly our wlr output. */ @@ -9103,14 +9241,15 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state, return true; } } - /* prefix+F11 toggles fullscreen. Nested it flips the kick window in - the host compositor; the configure that comes back updates every - client via the usual host->clients propagation, exactly as if the - host compositor had fullscreened us. Standalone has no host window, - so it flips every client directly (the escape hatch when a closed - tab leaves the rest fullscreened). Grabbed so the focused client - never sees the chord; a latch drops host-synthesized repeats so one - hold can't flip-flop the toggle. Plain unprefixed F11 still passes + /* prefix+F11 toggles fullscreen for the focused overlay entry only. + Nested it flips the kick window chrome in the host compositor; the + configure that comes back fullscreens the focused entry (siblings, + stacked members and other tabs stay maximized and merely grow to + the fullscreen output size). A black active tab picks it up at map + via host.fullscreen. Standalone has no host window, so it flips + the focused entry directly. Grabbed so the focused client never + sees the chord; a latch drops host-synthesized repeats so one hold + can't flip-flop the toggle. Plain unprefixed F11 still passes through below. */ { bool is_f11 = server.xkb_state ? (sym == XKB_KEY_F11) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d9f550a..898f866 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -2706,11 +2706,12 @@ def test_function_keys_pass_through(session): def test_client_fullscreen_round_trip(tmp_path): - """A client's fullscreen request is honored and mirrored onto the host - window; the host configure then propagates fullscreen to every tab. - wltest toggles xdg_toplevel fullscreen on the 'F' key, so tap it and - watch both directions of the mirror. The host is a private outer kick - (not the live desktop), so configure roundtrips are deterministic.""" + """A shown client's fullscreen request takes the host window with it + (nested) but stays per-entry: only the requesting overlay entry is told + fullscreen. Exiting (second toggle) drops the host chrome again. + wltest toggles xdg_toplevel fullscreen on the 'F' key. The host is a + private outer kick (not the live desktop), so configure roundtrips are + deterministic.""" outer_log = tmp_path / "outer.log" inner_log = tmp_path / "inner.log" outer = _spawn_standalone( @@ -2727,9 +2728,57 @@ def test_client_fullscreen_round_trip(tmp_path): # the key silently; map alone does not guarantee enter under load. inner.wait_for(r"\[diag\] kbd-enter app=%d\b" % idx) inner.tap_ack(F) + inner.wait_for("client fullscreen=1", timeout=30.0) inner.wait_for("host fullscreen=1", timeout=30.0) inner.wait_for("clients fullscreen=1") inner.tap_ack(F) + inner.wait_for("client fullscreen=0", timeout=30.0) + inner.wait_for("host fullscreen=0", timeout=30.0) + inner.wait_for("clients fullscreen=0") + _assert_alive(inner) + _assert_alive(outer) + finally: + inner.close() + finally: + outer.close() + + +def test_client_fullscreen_is_per_tab(tmp_path): + """Only the requesting window gains fullscreen state: fullscreen one + tab via its client's 'F' key, then prove the other tab's client never + sees a fullscreen configure (via wltest --req-out).""" + outer_log = tmp_path / "outer2.log" + inner_log = tmp_path / "inner2.log" + out_a = tmp_path / "a.req" + out_b = tmp_path / "b.req" + outer = _spawn_standalone( + {}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log)) + try: + outer.wait_any_active() + inner = _spawn_nested_in( + outer, + "%s --app-id tabA --req-out '%s' '#ff0000'" % (WL, out_a), + "%s --app-id tabB --req-out '%s' '#00ff00'" % (WL, out_b), + log=str(inner_log)) + try: + idx = inner.wait_any_active() + inner.wait_surface_mapped(idx) + inner.wait_for(r"\[diag\] kbd-enter app=%d\b" % idx) + other = 1 - idx + other_out = out_a if other == 0 else out_b + active_out = out_b if idx == 1 else out_a + assert _wait_out(active_out, "maximized=", timeout=15.0) + inner.tap_ack(F) + inner.wait_for("client fullscreen=1", timeout=30.0) + inner.wait_for("host fullscreen=1", timeout=30.0) + assert _wait_out(active_out, "fullscreen=1", timeout=15.0), \ + "active tab's client never got fullscreen configure" + time.sleep(1.0) + other_text = (other_out.read_text() + if other_out.exists() else "") + assert "fullscreen=1" not in other_text, \ + f"other tab wrongly fullscreened: {other_text[-200:]!r}" + inner.tap_ack(F) inner.wait_for("host fullscreen=0", timeout=30.0) inner.wait_for("clients fullscreen=0") _assert_alive(inner) @@ -2740,12 +2789,78 @@ def test_client_fullscreen_round_trip(tmp_path): outer.close() +def test_standalone_client_fullscreen_is_per_entry(tmp_path): + """Standalone (no host chrome): a client's fullscreen request tells only + the requesting overlay entry fullscreen -- the other entry's client + never sees a fullscreen configure (via wltest --req-out). Regression + test: the old standalone path broadcast to every tab.""" + out_a = tmp_path / "a.req" + out_b = tmp_path / "b.req" + with _run_standalone( + {}, "%s --app-id tabA --req-out '%s' '#ff0000'" % (WL, out_a), + "%s --app-id tabB --req-out '%s' '#00ff00'" % (WL, out_b), + wait="any") as s: + idx = s.wait_any_active() + s.wait_surface_mapped(idx) + s.wait_for(r"\[diag\] kbd-enter app=%d\b" % idx) + other = 1 - idx + other_out = out_a if other == 0 else out_b + active_out = out_b if idx == 1 else out_a + assert _wait_out(active_out, "maximized=", timeout=15.0) + s.tap_ack(F) + s.wait_for("client fullscreen=1", timeout=30.0) + s.wait_for("clients fullscreen=1", timeout=30.0) + assert _wait_out(active_out, "fullscreen=1", timeout=15.0), \ + "active entry's client never got fullscreen configure" + time.sleep(1.0) + other_text = (other_out.read_text() + if other_out.exists() else "") + assert "fullscreen=1" not in other_text, \ + f"other entry wrongly fullscreened: {other_text[-200:]!r}" + s.tap_ack(F) + s.wait_for("client fullscreen=0", timeout=30.0) + s.wait_for("clients fullscreen=0", timeout=30.0) + _assert_alive(s, "compositor died") + + +def test_standalone_alt_f11_is_per_entry(tmp_path): + """Standalone prefix+F11 fullscreens the focused overlay entry only -- + the other entry's client never sees a fullscreen configure. Regression + test: the old standalone toggle flipped every tab.""" + out_a = tmp_path / "a.req" + out_b = tmp_path / "b.req" + with _run_standalone( + {}, "%s --app-id tabA --req-out '%s' '#ff0000'" % (WL, out_a), + "%s --app-id tabB --req-out '%s' '#00ff00'" % (WL, out_b), + wait="any") as s: + idx = s.wait_any_active() + s.wait_surface_mapped(idx) + s.wait_for(r"\[diag\] kbd-enter app=%d\b" % idx) + other = 1 - idx + other_out = out_a if other == 0 else out_b + active_out = out_b if idx == 1 else out_a + assert _wait_out(active_out, "maximized=", timeout=15.0) + s.chord_ack(ALT, F11) + s.wait_for("client fullscreen=1", timeout=30.0) + s.wait_for("clients fullscreen=1", timeout=30.0) + assert _wait_out(active_out, "fullscreen=1", timeout=15.0), \ + "focused entry's client never got fullscreen configure" + time.sleep(1.0) + other_text = (other_out.read_text() + if other_out.exists() else "") + assert "fullscreen=1" not in other_text, \ + f"other entry wrongly fullscreened: {other_text[-200:]!r}" + s.chord_ack(ALT, F11) + s.wait_for("clients fullscreen=0", timeout=30.0) + _assert_alive(s, "compositor died") + + def test_alt_f11_toggles_host_fullscreen(tmp_path): """prefix+F11 toggles the kick window's fullscreen state in the host; - the resulting configure propagates to every tab exactly like a - host-compositor-initiated fullscreen. Tapping plain F11 (no prefix) - must still pass through to the client untouched. The host is a private - outer kick, so configure roundtrips are deterministic.""" + the resulting configure fullscreens the active tab only (other tabs + stay maximized and grow to the fullscreen size). Tapping plain F11 + (no prefix) must still pass through to the client untouched. The host + is a private outer kick, so configure roundtrips are deterministic.""" outer_log = tmp_path / "outer.log" inner_log = tmp_path / "inner.log" outer = _spawn_standalone( @@ -2772,11 +2887,12 @@ def test_alt_f11_toggles_host_fullscreen(tmp_path): outer.close() -def test_alt_f11_propagates_to_stack_members(tmp_path): - """prefix+F11's host fullscreen reaches not just each tab's main window - but every stacked tab-group member too, and leaving fullscreen clears - both again. The host is a private outer kick, so configure roundtrips - are deterministic.""" +def test_alt_f11_fullscreens_focused_entry_only(tmp_path): + """prefix+F11 fullscreens the focused overlay entry only: with a stacked + tab-group tab active (main + extra member), entering fullscreen must + tell exactly one window (one "client fullscreen=1" line), leaving the + unfocused member maximized. Leaving clears again. The host is a + private outer kick, so configure roundtrips are deterministic.""" outer_log = tmp_path / "outer.log" inner_log = tmp_path / "inner.log" outer = _spawn_standalone( @@ -2790,10 +2906,16 @@ def test_alt_f11_propagates_to_stack_members(tmp_path): idx = inner.wait_any_active() inner.wait_surface_mapped(idx) inner.wait_for(r"\[sub\] stack app=0\b") + if idx != 0: + inner.cycle_ack(-1) + inner.wait_active(0, timeout=15.0) inner.chord_ack(ALT, F11) inner.wait_for("host fullscreen=1", timeout=30.0) inner.wait_for("clients fullscreen=1") inner.wait_for("members fullscreen=1") + n = inner.read_log().count("client fullscreen=1") + assert n == 1, \ + f"expected exactly one fullscreened entry, got {n}" inner.chord_ack(ALT, F11) inner.wait_for("host fullscreen=0", timeout=30.0) inner.wait_for("clients fullscreen=0")