feat: per-entry fullscreen, host chrome follows shown clients

This commit is contained in:
Andrew Hamilton 2026-09-12 12:20:25 +10:00
parent 7494fafb89
commit e7df8d5396
3 changed files with 352 additions and 86 deletions

View file

@ -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).

271
kick.c
View file

@ -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)

View file

@ -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")