feat: add --overlay-previews live window thumbnails

Adds a --overlay-previews flag that switches the hold-Alt tab
overlay from icon+title rows to icon plus a small live view of
each window (three times as tall as a title row, no captions).
Tabs with no mapped window keep their command-line text row.

Thumbnails stay live while the overlay is held: hidden windows
get no scene frame callbacks, so a ~15fps tick paces them with
frame-done (disarmed on release; the plain title list never
wakes background clients). Each commit is routed to its fresh
source -- a CPU snapshot for SHM attaches, a locked dma-buf
import for GPU clients, or the surface's own texture for
in-place SHM updates -- and hidden commits force a repaint.

Also adds a wltest --pulse MS mode (alternating color/black on
frame callbacks) as a deterministic live source, three smoke
tests (appear on open, map mid-hold, pulse flip), and README
docs.
This commit is contained in:
Andrew Hamilton 2026-09-27 07:01:43 +10:00
parent 2b8560dd7c
commit a9e5f3dcad
4 changed files with 522 additions and 8 deletions

337
kick.c
View file

@ -316,6 +316,19 @@ struct kick_window {
desktop-file guesser (or placeholder) is in charge. */
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. */
struct wlr_texture *thumb_tex;
struct wlr_buffer *thumb_buf;
uint32_t *thumb_px;
int thumb_pw, thumb_ph;
bool thumb_dirty;
enum { THUMB_SRC_NONE, THUMB_SRC_SNAP, THUMB_SRC_DMABUF,
THUMB_SRC_LIVE } thumb_src;
/* 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
@ -648,6 +661,11 @@ struct kick_server {
navigated session commits the then-current window to the MRU front. */
struct kick_window *mru_alt_anchor;
bool mru_alt_navigated;
/* --overlay-previews live tick: while the preview overlay is held,
hidden windows get no scene frame callbacks and would freeze, so a
timer paces them with frame-done at preview rate instead. Armed in
update_overlay_visible, disarmed on release. */
struct wl_event_source *preview_timer;
/* wlr-layer-shell (waybar / panels). Layer surfaces render into their
own always-on-top scene tree (layer_tree) instead of being tabs, so a
@ -894,6 +912,13 @@ static bool tab_groups = true;
competes. */
static bool opt_focus_priority = false;
/* --overlay-previews: show live window thumbnails in the tab overlay instead
of icon+title rows. Each row is the window's icon followed by a small live
view of the window itself (aspect-fit into a fixed box three times as tall
as a title row); tabs with no mapped window keep their command-line text
row. Off by default. */
static bool opt_overlay_previews = false;
/* ===================== Clipboard bridge ===================== */
enum bridge_backend {
@ -2629,6 +2654,183 @@ static void overlay_pass_icon_tex(struct wlr_render_pass *pass,
wlr_render_pass_add_texture(pass, &o);
}
/* --overlay-previews geometry: thumbnail boxes are three times as tall as a
title row, at a fixed 16:9 size; the preview row pads them like a text row
pads its font (+8). Pending (windowless) rows stay short in both modes. */
#define OVERLAY_PREVIEW_TALL 3
static int overlay_title_row_h(void) {
int rh = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
if (rh < OVERLAY_ICON_SZ + 4) rh = OVERLAY_ICON_SZ + 4;
return rh;
}
static int overlay_preview_thumb_h(void) {
return overlay_title_row_h() * OVERLAY_PREVIEW_TALL;
}
static int overlay_preview_thumb_w(void) {
return overlay_preview_thumb_h() * 16 / 9;
}
static int overlay_preview_row_h(void) {
return overlay_preview_thumb_h() + 8;
}
/* Live overlay-preview thumbnail (--overlay-previews). Capture runs in the
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. */
static void thumb_release(struct kick_window *w) {
if (!w) return;
if (w->thumb_tex) {
wlr_texture_destroy(w->thumb_tex);
w->thumb_tex = NULL;
}
if (w->thumb_buf) {
wlr_buffer_unlock(w->thumb_buf);
w->thumb_buf = NULL;
}
free(w->thumb_px);
w->thumb_px = NULL;
w->thumb_pw = w->thumb_ph = 0;
w->thumb_dirty = false;
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;
if (b && b->width > 0 && b->height > 0) {
void *data = NULL;
uint32_t fmt = 0;
size_t stride = 0;
bool begun = wlr_buffer_begin_data_ptr_access(
b, WLR_BUFFER_DATA_PTR_ACCESS_READ, &data, &fmt, &stride);
bool cpu = begun &&
(fmt == DRM_FORMAT_ARGB8888 || fmt == DRM_FORMAT_XRGB8888);
if (cpu) {
int bw = b->width, bh = b->height;
int tw = overlay_preview_thumb_w(), th = overlay_preview_thumb_h();
if (!w->thumb_px || w->thumb_pw != tw || w->thumb_ph != th) {
free(w->thumb_px);
w->thumb_px = malloc((size_t)tw * th * 4);
w->thumb_pw = tw;
w->thumb_ph = th;
}
if (w->thumb_px) {
for (int y = 0; y < th; y++) {
const uint32_t *row = (const uint32_t *)
((const char *)data + (size_t)y * bh / th * stride);
for (int x = 0; x < tw; x++) {
uint32_t p = row[(size_t)x * bw / tw];
unsigned a = (p >> 24) & 0xff;
if (fmt == DRM_FORMAT_XRGB8888 || a == 0)
a = 0xff;
unsigned r = (p >> 16) & 0xff, g = (p >> 8) & 0xff,
bl = p & 0xff;
r = r * a / 255;
g = g * a / 255;
bl = bl * a / 255;
w->thumb_px[(size_t)y * tw + x] =
(a << 24) | (r << 16) | (g << 8) | bl;
}
}
w->thumb_src = THUMB_SRC_SNAP;
w->thumb_dirty = true;
}
wlr_buffer_end_data_ptr_access(b);
if (w->thumb_buf) {
/* Source switched back to SHM: drop the GPU buffer lock. */
wlr_buffer_unlock(w->thumb_buf);
w->thumb_buf = NULL;
}
} else {
/* Not CPU-readable (dma-buf): hold a lock and import at draw. */
if (begun)
wlr_buffer_end_data_ptr_access(b);
if (b != w->thumb_buf) {
if (w->thumb_buf)
wlr_buffer_unlock(w->thumb_buf);
w->thumb_buf = wlr_buffer_lock(b);
}
w->thumb_src = THUMB_SRC_DMABUF;
w->thumb_dirty = true;
}
} else if (s && wlr_surface_get_texture(s)) {
/* In-place SHM update: the pixels went straight into the surface
texture (current.buffer was already consumed), which stays live
through every applied commit. */
if (w->thumb_buf) {
wlr_buffer_unlock(w->thumb_buf);
w->thumb_buf = NULL;
}
w->thumb_src = THUMB_SRC_LIVE;
w->thumb_dirty = true;
} else {
return;
}
/* A hidden window's commit damages no output, so repaint explicitly for
the preview to track the window live. */
if (host.overlay_visible)
kick_request_redraw();
}
static struct wlr_texture *overlay_thumb_tex(struct kick_window *w) {
if (!w || !server.renderer) return NULL;
if (w->thumb_src == THUMB_SRC_LIVE) {
struct wlr_surface *s = window_surface(w);
struct wlr_texture *live = s ? wlr_surface_get_texture(s) : NULL;
if (live) return live;
}
if (w->thumb_tex && !w->thumb_dirty)
return w->thumb_tex;
struct wlr_texture *tex = NULL;
if (w->thumb_src == THUMB_SRC_DMABUF && w->thumb_buf) {
tex = wlr_texture_from_buffer(server.renderer, w->thumb_buf);
} else if (w->thumb_src == THUMB_SRC_SNAP && w->thumb_px) {
tex = wlr_texture_from_pixels(
server.renderer, DRM_FORMAT_ARGB8888,
(uint32_t)w->thumb_pw * 4,
(uint32_t)w->thumb_pw, (uint32_t)w->thumb_ph, w->thumb_px);
} else {
return w->thumb_tex;
}
if (!tex) return w->thumb_tex; /* keep the previous frame; retry later */
if (w->thumb_tex)
wlr_texture_destroy(w->thumb_tex);
w->thumb_tex = tex;
w->thumb_dirty = false;
return w->thumb_tex;
}
/* Icon + aspect-fit live thumbnail. The thumb box is letterboxed (black)
and the window's current frame is centered inside, downscaled with
bilinear filtering; a window with no frame yet keeps the letterbox. */
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);
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);
if (dh > th) {
dh = th;
dw = (int)((int64_t)th * tex->width / tex->height);
}
if (dw <= 0 || dh <= 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 = tx + (tw - dw) / 2, .y = ty + (th - dh) / 2,
.width = dw, .height = dh },
.alpha = &alpha,
.filter_mode = WLR_SCALE_FILTER_BILINEAR,
.blend_mode = WLR_RENDER_BLEND_MODE_PREMULTIPLIED,
};
wlr_render_pass_add_texture(pass, &o);
}
/* Cursor inside the overlay plane (Option 1): while the overlay is held on
standalone DRM the hardware cursor plane is parked, so the cursor image
is painted into the plane buffer after the menu -- cursor above overlay
@ -2754,6 +2956,11 @@ static int overlay_tab_box(struct overlay_box *box) {
int row_h = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
int row_gap = 8;
if (row_h < icon_sz + 4) row_h = icon_sz + 4;
int thumb_w = 0;
if (opt_overlay_previews) {
row_h = overlay_preview_row_h();
thumb_w = overlay_preview_thumb_w();
}
int max_row_w = 0, n_rows = 0;
struct kick_tab *tab;
wl_list_for_each(tab, &server.tabs, link) {
@ -2762,10 +2969,14 @@ static int overlay_tab_box(struct overlay_box *box) {
struct kick_window *w;
wl_list_for_each(w, &tab->windows, link) {
if (!w->mapped) continue;
char tb[256];
const char *t = overlay_window_title(w, tb, sizeof(tb));
overlay_utf8_cut(tb);
row_w += icon_sz + icon_gap + overlay_text_width(t);
if (opt_overlay_previews) {
row_w += icon_sz + icon_gap + thumb_w;
} else {
char tb[256];
const char *t = overlay_window_title(w, tb, sizeof(tb));
overlay_utf8_cut(tb);
row_w += icon_sz + icon_gap + overlay_text_width(t);
}
n++;
}
if (n > 0) {
@ -2884,6 +3095,12 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass,
int row_h = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
int row_gap = 8;
if (row_h < icon_sz + 4) row_h = icon_sz + 4;
int thumb_w = 0, thumb_h = 0;
if (opt_overlay_previews) {
row_h = overlay_preview_row_h();
thumb_w = overlay_preview_thumb_w();
thumb_h = overlay_preview_thumb_h();
}
struct kick_tab *tab;
int bw = 1;
@ -2932,6 +3149,12 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass,
}
int total_w = 0;
for (int k = 0; k < n; k++) {
if (opt_overlay_previews) {
/* No captions in preview mode: fixed thumbnail boxes. */
widths[k] = thumb_w;
total_w += icon_sz + icon_gap + widths[k];
continue;
}
/* Titles are ellipsized at draw time by the text tile itself.
overlay_window_title returns the client string directly when
there is nothing to synthesize, so draw the RETURNED pointer:
@ -2947,6 +3170,7 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass,
int pen = box_x + boxpad + (row_inner - total_w) / 2;
int iy = ry + (row_h - icon_sz) / 2;
int ty = ry + (row_h - thumb_h) / 2;
if (n == 0) {
struct kick_window *first = tab_first_window(tab);
const char *ct = (first && first->cmd && first->cmd[0])
@ -2982,9 +3206,15 @@ static void draw_tab_overlay_pass(struct wlr_render_pass *pass,
overlay_pass_rect(pass, mx + 3, my + 3, icon_sz - 6,
icon_sz - 6, 0.07f, 0.07f, 0.09f, 1.0f);
}
overlay_pass_text(pass, strs[k], shares[k], mx + icon_sz + icon_gap,
ry + (row_h - host.overlay_font_h) / 2,
active ? 1.0f : 0.72f * 0.9f);
if (opt_overlay_previews) {
overlay_pass_thumb(pass, w, mx + icon_sz + icon_gap, ty,
thumb_w, thumb_h,
active ? 1.0f : 0.72f);
} else {
overlay_pass_text(pass, strs[k], shares[k], mx + icon_sz + icon_gap,
ry + (row_h - host.overlay_font_h) / 2,
active ? 1.0f : 0.72f * 0.9f);
}
pen += mw + member_gap;
}
row++;
@ -3017,6 +3247,11 @@ static bool overlay_hit_test(double ox, double oy,
int row_h = host.overlay_font_h > 0 ? host.overlay_font_h + 8 : 18;
int row_gap = 8;
if (row_h < icon_sz + 4) row_h = icon_sz + 4;
int thumb_w = 0;
if (opt_overlay_previews) {
row_h = overlay_preview_row_h();
thumb_w = overlay_preview_thumb_w();
}
struct kick_tab *tab;
int row = 0;
@ -3043,6 +3278,11 @@ static bool overlay_hit_test(double ox, double oy,
}
int total_w = 0;
for (int k = 0; k < n; k++) {
if (opt_overlay_previews) {
widths[k] = thumb_w;
total_w += icon_sz + icon_gap + widths[k];
continue;
}
char tb[256];
const char *s = overlay_window_title(wins[k], tb, sizeof(tb));
struct overlay_text_entry *te = overlay_text_tile(s, share);
@ -3736,6 +3976,7 @@ static void remove_tab(struct kick_tab *tab) {
}
if (w->icon_wlr)
wlr_texture_destroy(w->icon_wlr);
thumb_release(w);
icon_proto_release(w);
if (w->toplevel)
window_detach_xdg_listeners(w);
@ -3795,6 +4036,7 @@ static void window_destroy_common(struct kick_window *w) {
free(w->tag_desc);
if (w->icon_wlr)
wlr_texture_destroy(w->icon_wlr);
thumb_release(w);
icon_proto_release(w);
mru_remove(w);
free(w);
@ -4263,6 +4505,8 @@ static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
negative y once they decide on their titlebar), so keep the scene
position in sync with every commit instead of only at activation. */
position_window(w);
/* --overlay-previews: a commit is a new thumbnail frame. */
thumb_capture(w);
/* [diag] What does this client commit, and is its scene node visible? */
struct wlr_surface *s = toplevel->base->surface;
@ -5028,6 +5272,7 @@ static void handle_xs_commit(struct wl_listener *listener, void *data) {
(void)data;
struct kick_window *w = wl_container_of(listener, w, commit);
x11_sync_position(w);
thumb_capture(w);
}
static void handle_xs_surface_map(struct wl_listener *listener, void *data) {
@ -9497,6 +9742,58 @@ static void handle_window_meta(struct wl_listener *l, void *data) {
so no stale fraction leaks into the next session. */
static double overlay_wheel_acc = 0.0;
/* Preview live tick (~15fps): hidden windows get no scene frame callbacks
while the overlay is up, so their previews would freeze on the last frame
from before the hold. Pacing them with frame-done keeps GPU clients
(terminals, video) rendering into the thumbnails; SHM clients that paint
on their own schedule are unaffected. Shown windows already get scene
frame callbacks and need nothing. Commits repaint through thumb_capture,
so the timer itself schedules nothing. Gated on preview mode: the plain
title list never wakes background clients. */
#define KICK_PREVIEW_TICK_MS 66
/* Frame-done to every surfaced buffer in a hidden window's tree: clients
may wait on a subsurface/popup callback rather than the main surface's
(foot does), and the scene samples none of them while hidden. Surfaces
without a pending callback are a no-op send. */
static void preview_tick_tree(struct wlr_scene_tree *tree,
const struct timespec *now) {
if (!tree) return;
struct wlr_scene_node *node;
wl_list_for_each(node, &tree->children, link) {
if (node->type == WLR_SCENE_NODE_BUFFER) {
struct wlr_scene_buffer *sb = wlr_scene_buffer_from_node(node);
struct wlr_scene_surface *ss =
wlr_scene_surface_try_from_buffer(sb);
if (ss && ss->surface)
wlr_surface_send_frame_done(ss->surface, now);
} else if (node->type == WLR_SCENE_NODE_TREE) {
preview_tick_tree(wlr_scene_tree_from_node(node), now);
}
}
}
static int preview_tick_cb(void *data) {
(void)data;
if (!host.overlay_visible || !opt_overlay_previews) return 0;
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
struct kick_tab *tab;
wl_list_for_each(tab, &server.tabs, link) {
struct kick_window *w, *tmp;
wl_list_for_each_safe(w, tmp, &tab->windows, link) {
if (!w->mapped || window_is_shown(w)) continue;
struct wlr_surface *s = window_surface(w);
if (!s) continue;
wlr_surface_send_frame_done(s, &now);
if (w->tree)
preview_tick_tree(w->tree, &now);
}
}
if (server.preview_timer)
wl_event_source_timer_update(server.preview_timer,
KICK_PREVIEW_TICK_MS);
return 0;
}
static void update_overlay_visible(bool alt) {
if (alt == host.overlay_visible) return;
if (alt) {
@ -9524,6 +9821,19 @@ static void update_overlay_visible(bool alt) {
host.overlay_visible = alt;
kick_log(KICK_LOG_DEBUG, "[render] overlay %s\n",
alt ? "show" : "hide");
/* Preview live tick: pace hidden windows with frame-done while the
preview overlay is up so their thumbnails track live frames;
disarm on release so background clients freeze again. */
if (alt && opt_overlay_previews) {
if (!server.preview_timer)
server.preview_timer = wl_event_loop_add_timer(
server.loop, preview_tick_cb, NULL);
if (server.preview_timer)
wl_event_source_timer_update(server.preview_timer,
KICK_PREVIEW_TICK_MS);
} else if (server.preview_timer) {
wl_event_source_timer_update(server.preview_timer, 0);
}
/* A rejected plane goes back to UNKNOWN on the next hold so the fresh
allocation is probed again instead of staying composited forever. */
if (alt && server.overlay_plane == OVERLAY_PLANE_OFF)
@ -12870,6 +13180,7 @@ static void sweep_dead_startup_groups(void) {
wlr_texture_destroy(w->icon_wlr);
w->icon_wlr = NULL;
}
thumb_release(w);
icon_proto_release(w);
mru_remove(w);
wl_list_remove(&w->link);
@ -13284,8 +13595,15 @@ int main(int argc, char *argv[]) {
"best-effort/4) while every\n"
" other window is pushed down "
"(nice +10,\n"
" best-effort/7). Off by default; "
" best-effort/7). Off by default; "
"needs no sudo.\n"
" --overlay-previews Show live window thumbnails "
"in the tab overlay\n"
" instead of icon+title rows: "
"each row is the icon\n"
" plus a small live view of "
"the window. Off by\n"
" default.\n"
" -h, --help Show the full help.\n"
"\n"
"Keys (hold the modifier):\n"
@ -13390,6 +13708,9 @@ int main(int argc, char *argv[]) {
} else if (strcmp(argv[i], "--focus-priority") == 0) {
opt_focus_priority = true;
continue;
} else if (strcmp(argv[i], "--overlay-previews") == 0) {
opt_overlay_previews = true;
continue;
} else if (strcmp(argv[i], "--panel") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "kick: --panel needs a value, "