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

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)