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;