fix: composite overlay thumbnails from live textures for subsurface windows

Firefox paints into subsurfaces while its main surface stays transparent, so the single-buffer snapshot saw only black. Windows with subsurface content now composite at overlay-draw time from each live surface texture (which outlives current.buffer), while plain windows keep the legacy snapshot path. Adds wltest --subsurface repro mode and a regression test.
This commit is contained in:
Andrew Hamilton 2026-09-27 18:22:07 +10:00
parent 8b8137684a
commit 38a17401d1
3 changed files with 406 additions and 19 deletions

272
kick.c
View file

@ -194,6 +194,18 @@ struct kick_tab {
struct wl_list windows; /* kick_window.link */
};
/* One surface in a window's subsurface tree watched for thumbnail commits.
Lives in w->thumb_subs from thumb_sub_wire until the surface dies or the
window goes away (thumb_release); either side frees it exactly once. */
struct kick_thumb_sub {
struct wl_list link; /* w->thumb_subs */
struct kick_window *w;
struct wlr_surface *surface;
struct wl_listener commit;
struct wl_listener destroy;
struct wl_listener new_subsurface;
};
/* One window. Every surface kick shows -- a command's first toplevel (a
black pending tab is its still-unsurfaced window), a grouped second
window from the same client, an external client's window -- is one
@ -317,11 +329,13 @@ struct kick_window {
struct wlr_xdg_toplevel_icon_v1 *proto_icon;
bool icon_protocol;
/* Live overlay-preview thumbnail (--overlay-previews): the window's
latest frame, routed per commit to whichever source is fresh --
a CPU snapshot (fresh SHM attaches), a locked dma-buf (GPU
attaches), or the surface's own texture (in-place SHM updates,
where current.buffer is already NULL at commit time). Released
with the window. */
latest frame. Windows without subsurfaces route each commit to
whichever source is fresh -- a CPU snapshot (fresh SHM attaches),
a locked dma-buf (GPU attaches), or the surface's own texture
(in-place SHM updates, where current.buffer is already NULL at
commit time). Windows with subsurface content (e.g. Firefox)
composite at overlay-draw time from live surface textures instead,
which outlive current.buffer. Released with the window. */
struct wlr_texture *thumb_tex;
struct wlr_buffer *thumb_buf;
uint32_t *thumb_px;
@ -329,6 +343,13 @@ struct kick_window {
bool thumb_dirty;
enum { THUMB_SRC_NONE, THUMB_SRC_SNAP, THUMB_SRC_DMABUF,
THUMB_SRC_LIVE } thumb_src;
/* Subsurface commit tracking for overlay thumbnails: one entry per
surface in the window's subsurface tree (main surface first). A
subsurface-only paint fires no toplevel commit, so each surface
carries its own commit listener re-running thumb_capture, and
new_subsurface chains surfaces created after the claim. Emptied by
thumb_release with the window. */
struct wl_list thumb_subs; /* kick_thumb_sub.link */
/* Client-supplied tag + description (xdg-toplevel-tag-v1): e.g. "main
window" plus its translation. Shown in the overlay row ("title
[tag]") and used for --tab-groups grouping; NULL until the client
@ -1131,6 +1152,7 @@ static void output_mgmt_publish(void);
static struct kick_tab *pick_successor(struct kick_tab *tab,
bool prefer_right);
static void remove_tab(struct kick_tab *tab);
static int tab_index(struct kick_tab *tab);
static int add_tab(const char *cmd);
/* set_title / set_app_id listener: refresh the overlay row and mirror the
new title/app_id onto the taskbar handles. */
@ -2682,10 +2704,24 @@ static int overlay_preview_snap_h(void) {
commit handler and routes each commit to its fresh source: a fresh attach
snapshots (SHM) or locks (dma-buf) the committed buffer; an in-place SHM
update (current.buffer already NULL) leaves the pixels in the surface's
own texture, which wlroots refreshed from the dropped buffer. Draw
serves whichever source the latest commit took. */
own texture, which wlroots refreshed from the dropped buffer. Windows
whose content lives in subsurfaces (e.g. Firefox) instead composite the
whole surface tree at overlay-draw time from live textures, which
outlive current.buffer. Draw serves whichever source fits the window. */
static void thumb_sub_release(struct kick_thumb_sub *sub) {
if (!sub) return;
wl_list_remove(&sub->commit.link);
wl_list_remove(&sub->destroy.link);
wl_list_remove(&sub->new_subsurface.link);
wl_list_remove(&sub->link);
free(sub);
}
static void thumb_release(struct kick_window *w) {
if (!w) return;
struct kick_thumb_sub *sub, *tmp;
wl_list_for_each_safe(sub, tmp, &w->thumb_subs, link)
thumb_sub_release(sub);
if (w->thumb_tex) {
wlr_texture_destroy(w->thumb_tex);
w->thumb_tex = NULL;
@ -2701,10 +2737,10 @@ static void thumb_release(struct kick_window *w) {
w->thumb_src = THUMB_SRC_NONE;
}
static void thumb_capture(struct kick_window *w) {
if (!w || !opt_overlay_previews) return;
struct wlr_surface *s = window_surface(w);
struct wlr_buffer *b = (s && s->current.buffer) ? s->current.buffer : NULL;
/* Single-surface capture: the legacy fast path for windows without
subsurfaces. `s` is the main surface, `b` its current buffer. */
static void thumb_capture_single(struct kick_window *w, struct wlr_surface *s,
struct wlr_buffer *b) {
if (b && b->width > 0 && b->height > 0) {
/* Route dma-buf straight to the GPU path: probing it with a CPU
data-pointer access first can stall on GPU fences at 60Hz, which
@ -2821,6 +2857,199 @@ static void thumb_capture(struct kick_window *w) {
kick_request_redraw();
}
/* Tree-thumbnail collection: two wlr_surface_for_each_surface walks over the
window (bottom to top) at overlay-draw time. Positions are root-relative
surface coordinates; sizes come from each surface's live texture, so the
composite matches what the scene renders -- including subsurface content
whose current.buffer is long gone by draw time. Textures persist on the
surface from the last commit, so hidden tabs show their last frame. */
struct thumb_extent {
int minx, miny, maxx, maxy;
bool any;
};
struct thumb_draw_ctx {
struct wlr_render_pass *pass;
float alpha;
int ox, oy; /* extent origin in root coordinates */
double scale; /* window pixels -> thumb pixels */
int dx0, dy0; /* thumb-box origin of the fitted extent */
};
static void thumb_extent_iter(struct wlr_surface *surface, int sx, int sy,
void *data) {
struct thumb_extent *e = data;
struct wlr_texture *tex = surface ? wlr_surface_get_texture(surface) : NULL;
if (!tex || tex->width <= 0 || tex->height <= 0) return;
if (!e->any) {
e->minx = sx;
e->miny = sy;
e->maxx = sx + tex->width;
e->maxy = sy + tex->height;
e->any = true;
return;
}
if (sx < e->minx) e->minx = sx;
if (sy < e->miny) e->miny = sy;
if (sx + tex->width > e->maxx) e->maxx = sx + tex->width;
if (sy + tex->height > e->maxy) e->maxy = sy + tex->height;
}
static void thumb_draw_iter(struct wlr_surface *surface, int sx, int sy,
void *data) {
struct thumb_draw_ctx *c = data;
struct wlr_texture *tex = surface ? wlr_surface_get_texture(surface) : NULL;
if (!tex || tex->width <= 0 || tex->height <= 0) return;
struct wlr_render_texture_options o = {
.texture = tex,
.src_box = { .x = 0, .y = 0,
.width = (float)tex->width, .height = (float)tex->height },
.dst_box = { .x = (float)(c->dx0 + (sx - c->ox) * c->scale),
.y = (float)(c->dy0 + (sy - c->oy) * c->scale),
.width = (float)(tex->width * c->scale),
.height = (float)(tex->height * c->scale) },
.alpha = &c->alpha,
.filter_mode = WLR_SCALE_FILTER_BILINEAR,
.blend_mode = WLR_RENDER_BLEND_MODE_PREMULTIPLIED,
};
wlr_render_pass_add_texture(c->pass, &o);
}
/* Draw-time tree thumbnail: composite every live surface texture at its
root-relative offset, aspect-fit into the thumb box like the
single-surface path. Textures outlive current.buffer (cleared by commit
finalization), so this sees subsurface content no commit hook can. */
static void overlay_pass_tree_thumb(struct wlr_render_pass *pass,
struct kick_window *w,
int tx, int ty, int tw, int th,
float alpha) {
struct wlr_surface *s = window_surface(w);
if (!s) return;
struct thumb_extent e = { 0 };
wlr_surface_for_each_surface(s, thumb_extent_iter, &e);
if (!e.any || e.maxx <= e.minx || e.maxy <= e.miny) return;
int ew = e.maxx - e.minx, eh = e.maxy - e.miny;
int dw = tw, dh = (int)((int64_t)tw * eh / ew);
if (dh > th) {
dh = th;
dw = (int)((int64_t)th * ew / eh);
}
if (dw <= 0 || dh <= 0) return;
struct thumb_draw_ctx c = {
.pass = pass,
.alpha = alpha,
.ox = e.minx, .oy = e.miny,
.scale = (double)dw / (double)ew,
.dx0 = tx + (tw - dw) / 2,
.dy0 = ty + (th - dh) / 2,
};
wlr_surface_for_each_surface(s, thumb_draw_iter, &c);
}
/* Whether the window owns subsurface content: such windows composite at
draw time from live textures (see overlay_pass_tree_thumb). */
static bool window_has_subsurfaces(struct kick_window *w) {
struct wlr_surface *s = window_surface(w);
if (!s) return false;
return !wl_list_empty(&s->current.subsurfaces_above) ||
!wl_list_empty(&s->current.subsurfaces_below);
}
/* Tree windows composite at draw time from live textures; capture only
repaints while the overlay is held. */
static void thumb_capture_tree(struct kick_window *w) {
(void)w;
if (host.overlay_visible)
kick_request_redraw();
}
/* Route a commit to its thumbnail source. Windows without subsurfaces
snapshot the committed buffer synchronously (current.buffer is only
valid inside the commit handler); windows with subsurface content
composite at overlay-draw time from live textures instead, so every
commit side -- the toplevel commit, each subsurface's commit, a sync
child's arrival -- only has to repaint while the overlay is held. */
static void thumb_capture(struct kick_window *w) {
if (!w || !opt_overlay_previews) return;
struct wlr_surface *s = window_surface(w);
if (!s) return;
if (!window_has_subsurfaces(w)) {
struct wlr_buffer *b = s->current.buffer;
thumb_capture_single(w, s, b);
return;
}
thumb_capture_tree(w);
}
static void thumb_sub_wire(struct kick_window *w, struct wlr_surface *s);
static void thumb_sub_commit(struct wl_listener *listener, void *data) {
(void)data;
struct kick_thumb_sub *sub = wl_container_of(listener, sub, commit);
thumb_capture(sub->w);
}
static void thumb_sub_destroy(struct wl_listener *listener, void *data) {
(void)data;
struct kick_thumb_sub *sub = wl_container_of(listener, sub, destroy);
thumb_sub_release(sub);
}
static void thumb_sub_new(struct wl_listener *listener, void *data) {
struct kick_thumb_sub *sub = wl_container_of(listener, sub,
new_subsurface);
/* new_subsurface carries the fresh wlr_subsurface. */
struct wlr_subsurface *wsub = data;
if (!wsub || !wsub->surface) return;
thumb_sub_wire(sub->w, wsub->surface);
/* The tree gained a surface: repaint while the overlay is held so the
new content appears without waiting for the next commit. */
thumb_capture(sub->w);
}
static bool thumb_sub_wired(struct kick_window *w, struct wlr_surface *s) {
struct kick_thumb_sub *sub;
wl_list_for_each(sub, &w->thumb_subs, link)
if (sub->surface == s) return true;
return false;
}
/* Watch `s` (and, via the walk in thumb_sub_wire_tree, its existing
children) for thumbnail commits. Surfaces created later arrive through
new_subsurface, which wlroots emits on the parent at their first
commit. */
static void thumb_sub_wire(struct kick_window *w, struct wlr_surface *s) {
if (!w || !s || thumb_sub_wired(w, s)) return;
struct kick_thumb_sub *sub = calloc(1, sizeof(*sub));
if (!sub) return;
sub->w = w;
sub->surface = s;
sub->commit.notify = thumb_sub_commit;
wl_signal_add(&s->events.commit, &sub->commit);
sub->destroy.notify = thumb_sub_destroy;
wl_signal_add(&s->events.destroy, &sub->destroy);
sub->new_subsurface.notify = thumb_sub_new;
wl_signal_add(&s->events.new_subsurface, &sub->new_subsurface);
wl_list_insert(w->thumb_subs.prev, &sub->link);
}
static void thumb_wire_iter(struct wlr_surface *surface, int sx, int sy,
void *data) {
(void)sx;
(void)sy;
struct kick_window *w = data;
/* The main surface already has the window's own commit listener:
watching it twice would snapshot every frame twice. */
if (surface == window_surface(w)) return;
thumb_sub_wire(w, surface);
}
static void thumb_sub_wire_tree(struct kick_window *w) {
struct wlr_surface *s = window_surface(w);
if (!s) return;
wlr_surface_for_each_surface(s, thumb_wire_iter, w);
}
static struct wlr_texture *overlay_thumb_tex(struct kick_window *w) {
if (!w || !server.renderer) return NULL;
if (w->thumb_src == THUMB_SRC_LIVE) {
@ -2841,7 +3070,15 @@ static struct wlr_texture *overlay_thumb_tex(struct kick_window *w) {
} else {
return w->thumb_tex;
}
if (!tex) return w->thumb_tex; /* keep the previous frame; retry later */
if (!tex) {
/* A persistent NULL here is a black thumbnail (e.g. a dma-buf the
renderer cannot import): log once per window so KICK_LOG=2 shows
which tab never produced a texture. */
if (!w->thumb_tex)
kick_log(KICK_LOG_DEBUG, "[thumb] import failed app=%d src=%d\n",
tab_index(w->tab), (int)w->thumb_src);
return w->thumb_tex; /* keep the previous frame; retry later */
}
if (w->thumb_tex)
wlr_texture_destroy(w->thumb_tex);
w->thumb_tex = tex;
@ -2856,6 +3093,12 @@ static void overlay_pass_thumb(struct wlr_render_pass *pass,
struct kick_window *w,
int tx, int ty, int tw, int th, float alpha) {
overlay_pass_rect(pass, tx, ty, tw, th, 0.0f, 0.0f, 0.0f, 1.0f);
/* Subsurface content (e.g. Firefox) never sits in the main surface's
buffer: composite the live tree instead of the single snapshot. */
if (window_has_subsurfaces(w)) {
overlay_pass_tree_thumb(pass, w, tx, ty, tw, th, alpha);
return;
}
struct wlr_texture *tex = overlay_thumb_tex(w);
if (!tex || tex->width == 0 || tex->height == 0) return;
int dw = tw, dh = (int)((int64_t)tw * tex->height / tex->width);
@ -4940,6 +5183,7 @@ static struct kick_window *window_alloc(struct kick_tab *tab) {
struct kick_window *w = calloc(1, sizeof(*w));
if (!w) return NULL;
w->tab = tab;
wl_list_init(&w->thumb_subs);
wl_list_insert(tab->windows.prev, &w->link);
/* New windows start least-recent: background arrivals must not hijack
Alt+Tab. The window bubbles to the front when it becomes current
@ -4994,6 +5238,10 @@ static void claim_xdg_window(struct kick_tab *tab, struct kick_window *w,
w->commit.notify = handle_toplevel_commit;
wl_signal_add(&toplevel->base->surface->events.commit, &w->commit);
/* Subsurface paints bypass the toplevel commit: watch the whole tree
so --overlay-previews thumbnails track subsurface clients too. */
thumb_sub_wire_tree(w);
w->set_title.notify = handle_window_meta;
wl_signal_add(&toplevel->events.set_title, &w->set_title);
w->set_app_id.notify = handle_window_meta;

View file

@ -6622,6 +6622,41 @@ def test_standalone_headless_overlay_previews():
_assert_alive(s)
def test_standalone_headless_overlay_previews_subsurface():
"""A window painted through a subsurface (transparent main surface, color
in the subsurface -- the Firefox pattern) shows its color in the overlay
thumbnail, not black."""
with _run_standalone(
{}, "--overlay-previews",
"%s --subsurface '#00ff00' '#000000'" % WL, "%s '#0000ff'" % WL,
wait="any_frame") as s:
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
_, green = _dominant_counts(s, s.dump_host())
assert green < 500, \
f"hidden subsurface tab visible without overlay: {green}"
s.mods(ALT)
try:
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
_, green = _dominant_counts(s, s.dump_host())
if green > 2000:
break
time.sleep(0.2)
assert green > 2000, \
"no green thumbnail for subsurface window while Alt held"
finally:
s.mods(0)
# The scene itself composites the subsurface: cycling to the tab
# shows green in the main view, proving the bug was thumbnail-only.
s.cycle_ack(+1)
s.wait_active_frame()
_, green = _dominant_counts(s, s.dump_host())
assert green > 2000, \
f"subsurface window not green in main view: {green}"
_assert_alive(s)
def test_standalone_headless_overlay_previews_live():
"""Preview thumbnails track window commits while the overlay is held:
a window mapping mid-hold (the delayed --extra) gains a live thumbnail

View file

@ -513,7 +513,24 @@ static struct xdg_surface *ex_xdg;
static struct xdg_toplevel *ex_toplevel;
static struct wl_buffer *ex_buffer;
/* --subsurface #RRGGBB: paint the window through a subsurface instead of the
main surface. The main surface commits a fully transparent buffer while a
single subsurface, covering the whole window, carries the solid color --
the pattern real clients (e.g. Firefox) use when their content lives off
the main surface. The scene shows the color; a thumbnail that only reads
the main surface sees black. */
static bool subsurface_mode;
static uint8_t SUB_PIXEL[4] = { 0x00, 0xff, 0x00, 0xff }; /* RGBA */
static struct wl_subcompositor *subcomp;
static struct wl_surface *sub_surface;
static struct wl_subsurface *subsurface;
static struct wl_buffer *sub_buffer;
static void create_buffer(void);
static struct wl_shm_pool *make_pool(size_t size, int *fd_out);
static struct wl_buffer *make_solid_buffer(uint8_t r, uint8_t g, uint8_t b,
uint8_t a, int32_t format);
static void subsurface_attach(void);
static void extra_open(void);
static void request_constraint(void);
static void pulse_arm(void);
@ -819,14 +836,81 @@ static const struct xdg_wm_base_listener wm_base_listener = {
.ping = xdg_wm_base_ping,
};
/* Fill a W×H SHM buffer with one RGBA pixel (B/G/R/A byte order). */
static struct wl_buffer *make_solid_buffer(uint8_t r, uint8_t g, uint8_t b,
uint8_t a, int32_t format) {
size_t stride = (size_t)W * 4;
size_t size = stride * (size_t)H;
int fd;
struct wl_shm_pool *pool = make_pool(size, &fd);
if (!pool) {
fprintf(stderr, "wltest: subsurface: shm pool failed\n");
exit(1);
}
uint8_t *data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (data == MAP_FAILED) {
fprintf(stderr, "wltest: subsurface: mmap failed\n");
exit(1);
}
for (int y = 0; y < H; y++) {
uint8_t *row = data + (size_t)y * stride;
for (int x = 0; x < W; x++) {
row[x*4+0] = b;
row[x*4+1] = g;
row[x*4+2] = r;
row[x*4+3] = a;
}
}
munmap(data, size);
close(fd);
struct wl_buffer *buf = wl_shm_pool_create_buffer(pool, 0, W, H,
(int32_t)stride, format);
wl_shm_pool_destroy(pool);
return buf;
}
/* --subsurface attach: transparent main buffer plus the color subsurface.
The subsurface stays synchronous (the default), so the parent commit
applies both atomically and the compositor sees one coherent frame. */
static void subsurface_attach(void) {
if (!subcomp) {
fprintf(stderr, "wltest: subsurface: wl_subcompositor not advertised\n");
exit(1);
}
if (!subsurface) {
sub_surface = wl_compositor_create_surface(compositor);
subsurface = wl_subcompositor_get_subsurface(subcomp, sub_surface,
surface);
wl_subsurface_set_position(subsurface, 0, 0);
wl_subsurface_place_above(subsurface, surface);
}
if (sub_buffer)
wl_buffer_destroy(sub_buffer);
sub_buffer = make_solid_buffer(SUB_PIXEL[0], SUB_PIXEL[1], SUB_PIXEL[2],
SUB_PIXEL[3], WL_SHM_FORMAT_ARGB8888);
wl_surface_attach(sub_surface, sub_buffer, 0, 0);
wl_surface_damage_buffer(sub_surface, 0, 0, W, H);
wl_surface_commit(sub_surface);
/* Main surface: fully transparent (alpha 0 everywhere). */
if (buffer)
wl_buffer_destroy(buffer);
buffer = make_solid_buffer(0, 0, 0, 0, WL_SHM_FORMAT_ARGB8888);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage_buffer(surface, 0, 0, W, H);
}
static void xdg_surface_configure(void *data, struct xdg_surface *xs, uint32_t serial) {
(void)data;
xdg_surface_ack_configure(xs, serial);
create_buffer();
if (single_pixel_mode && viewport)
wp_viewport_set_destination(viewport, W, H);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage_buffer(surface, 0, 0, W, H);
if (subsurface_mode) {
subsurface_attach();
} else {
create_buffer();
if (single_pixel_mode && viewport)
wp_viewport_set_destination(viewport, W, H);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage_buffer(surface, 0, 0, W, H);
}
if (pulse_ms && !pulse_cb) {
/* Start the pulse loop once the window exists; the base color is
whatever the command line set. Armed before the commit below
@ -950,6 +1034,9 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name,
} else if (strcmp(interface, wl_shm_interface.name) == 0) {
shm = wl_registry_bind(reg, name, &wl_shm_interface,
version < 1 ? version : 1);
} else if (strcmp(interface, wl_subcompositor_interface.name) == 0) {
subcomp = wl_registry_bind(reg, name, &wl_subcompositor_interface,
version < 1 ? version : 1);
} else if (strcmp(interface, xdg_wm_base_interface.name) == 0) {
/* xdg_popup.reposition needs wm_base (and hence popup) version
3+, but the default v1 bind is kept for every other mode so no
@ -3644,8 +3731,10 @@ static void print_usage(const char *argv0) {
"[--output-mgmt [--output-mgmt-out <file>] "
"[--output-mgmt-size <W>x<H>]] "
"#RRGGBB [WIDTHxHEIGHT]\n"
" %s shows a solid-color xdg_toplevel, e.g. #ff0000\n"
" --extra also opens a second toplevel from the same client\n"
" %s shows a solid-color xdg_toplevel, e.g. #ff0000\n"
" --extra also opens a second toplevel from the same client\n"
" --subsurface paints the color through a subsurface over\n"
" a transparent main surface (subsurface-client proof)\n"
" --pulse MS repaints alternating main color/black every\n"
" MS ms on frame callbacks (a live source for previews)\n"
" --cursor-shape requests that cursor-shape-v1 shape on "
@ -3761,6 +3850,21 @@ int main(int argc, char **argv) {
}
want_extra = true;
i += 2;
} else if (strcmp(argv[i], "--subsurface") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "wltest: --subsurface needs a value\n");
return 1;
}
const char *x = argv[i + 1];
if (*x == '#') x++;
if (strlen(x) != 6 || sscanf(x, "%2hhx%2hhx%2hhx",
&SUB_PIXEL[0], &SUB_PIXEL[1], &SUB_PIXEL[2]) != 3) {
fprintf(stderr, "wltest: bad subsurface color '%s'\n", argv[i + 1]);
return 1;
}
SUB_PIXEL[3] = 0xff;
subsurface_mode = true;
i += 2;
} else if (strcmp(argv[i], "--dialog-modal") == 0) {
dialog_modal = true;
i += 1;