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

View file

@ -258,6 +258,18 @@ highlighted like a cursor: `Alt+Up`/`Alt+Down` switch tabs and
`Alt+Left`/`Alt+Right` move between windows within the active tab, so the
highlight walks through every window in the session.
Pass `--overlay-previews` to list live thumbnails instead of titles: each
row is the window's icon followed by a small live view of the window itself
(three times as tall as a title row, no captions). The preview tracks the
window's commits while the overlay is held, so a window that repaints
mid-hold visibly updates; a tab with no mapped window keeps its
command-line text row. Both software (SHM) and GPU (DMA-BUF) clients are
covered. Hidden windows get no frame callbacks from the scene while
another tab is shown, so while the preview overlay is up kick paces them
with frame-done at ~15fps instead -- just enough to keep the thumbnails
live, frozen again on release. The plain title list never wakes background
clients this way.
`Alt+Tab` (and its synonym `Alt+1`, the chord that survives hosts like
GNOME which grab `Alt+Tab`, `Alt+Esc` *and* `Alt+Backtick` -- the last from
the metacity-era `switch_group` default) walk the session's

321
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;
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);
}
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);
@ -13286,6 +13597,13 @@ int main(int argc, char *argv[]) {
"(nice +10,\n"
" 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, "

View file

@ -6576,3 +6576,99 @@ def test_alt_tab_hold_repeats():
f"held Alt+Tab produced only {settled - base} transitions"
assert session.active_count() == settled, \
"repeats continued after release"
def _dominant_counts(s, path):
"""(red, green) dominant-pixel counts in a SIGUSR1 PPM dump.
Dominance (channel >120 and 80 above both others) survives the
overlay's inactive-tab dimming (0.72), so hidden tabs' thumbnails
count while overlay chrome (grays/blues) does not.
"""
w, h, px = s.ppm_rgb(path)
red = green = 0
for i in range(0, len(px), 3):
r, g, b = px[i], px[i + 1], px[i + 2]
if r > 120 and r > g + 80 and r > b + 80:
red += 1
elif g > 120 and g > r + 80 and g > b + 80:
green += 1
return red, green
def test_standalone_headless_overlay_previews():
"""--overlay-previews shows a live thumbnail per window instead of
icon+title rows: the hidden tab's color appears in the overlay while
Alt is held (it is invisible without the flag)."""
with _run_standalone(
{}, "--overlay-previews",
"%s '#ff0000'" % WL, "%s '#0000ff'" % WL,
wait="any_frame") as s:
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
red, _ = _dominant_counts(s, s.dump_host())
assert red < 500, f"hidden red tab visible without overlay: {red}"
s.mods(ALT)
try:
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
red, _ = _dominant_counts(s, s.dump_host())
if red > 2000:
break
time.sleep(0.2)
assert red > 2000, "no red thumbnail while Alt held"
finally:
s.mods(0)
_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
without reopening the overlay."""
with _run_standalone(
{}, "--overlay-previews", "--tab-groups", "off",
"%s --extra '#00ff00' --extra-delay 6000 '#ff0000'" % WL,
"%s '#0000ff'" % WL, wait="any_frame") as s:
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
s.mods(ALT)
try:
# The green extra window maps ~6s after client boot, mid-hold.
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
_, green = _dominant_counts(s, s.dump_host())
if green > 2000:
break
time.sleep(0.3)
assert green > 2000, \
"no green thumbnail for the window mapped mid-hold"
finally:
s.mods(0)
_assert_alive(s)
def test_standalone_headless_overlay_previews_pulse():
"""A hidden window repainting while the overlay is held shows live
frames: a --pulse client flipping red/black swings the overlay's red
pixel count between full thumbnail and nothing."""
with _run_standalone(
{}, "--overlay-previews",
"%s --pulse 500 '#ff0000'" % WL,
"%s '#0000ff'" % WL, wait="any_frame") as s:
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
s.mods(ALT)
try:
reds = []
deadline = time.monotonic() + 12.0
while time.monotonic() < deadline and len(reds) < 8:
red, _ = _dominant_counts(s, s.dump_host())
reds.append(red)
time.sleep(0.6)
swing = max(reds) - min(reds) if reds else 0
assert swing > 5000, \
f"pulse thumbnail never flipped (red samples: {reds})"
finally:
s.mods(0)
_assert_alive(s)

View file

@ -499,6 +499,14 @@ static bool extra_modal;
static int extra_delay_ms;
static bool extra_done;
static uint8_t EXTRA_PIXEL[4] = { 0x00, 0xff, 0x00, 0xff };
/* --pulse MS: repaint alternating between the main color and black every
MS milliseconds, driven by frame callbacks (so a hidden client only
animates while the compositor keeps sending them). Gives the overlay
preview tests a deterministic live source. */
static int pulse_ms;
static uint8_t PULSE_BASE[4];
static bool pulse_phase;
static struct wl_callback *pulse_cb;
static int EX_W = 640, EX_H = 400;
static struct wl_surface *ex_surface;
static struct xdg_surface *ex_xdg;
@ -508,6 +516,7 @@ static struct wl_buffer *ex_buffer;
static void create_buffer(void);
static void extra_open(void);
static void request_constraint(void);
static void pulse_arm(void);
static void locked_locked(void *data, struct zwp_locked_pointer_v1 *lp) {
(void)data; (void)lp;
@ -818,6 +827,14 @@ static void xdg_surface_configure(void *data, struct xdg_surface *xs, uint32_t s
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
so the frame request takes effect immediately. */
memcpy(PULSE_BASE, PIXEL, sizeof(PULSE_BASE));
pulse_phase = false;
pulse_arm();
}
wl_surface_commit(surface);
if (want_extra && !extra_done) {
extra_done = true;
@ -1195,6 +1212,61 @@ static void create_buffer(void) {
wl_shm_pool_destroy(pool);
}
static uint64_t pulse_now_ms(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000;
}
static void pulse_frame_done(void *data, struct wl_callback *cb,
uint32_t time);
static const struct wl_callback_listener pulse_frame_listener = {
.done = pulse_frame_done,
};
static void pulse_arm(void) {
if (pulse_cb)
wl_callback_destroy(pulse_cb);
pulse_cb = wl_surface_frame(surface);
wl_callback_add_listener(pulse_cb, &pulse_frame_listener, NULL);
}
static void pulse_frame_done(void *data, struct wl_callback *cb,
uint32_t time) {
(void)data;
(void)time;
if (cb == pulse_cb)
pulse_cb = NULL;
else
wl_callback_destroy(cb);
if (!pulse_ms)
return;
bool phase = (pulse_now_ms() / (uint64_t)pulse_ms) & 1;
/* Alternate on wall clock so phases stay aligned no matter how often
(or rarely) the compositor sends frames. Always attach+commit:
the new frame request below only takes effect on a commit. */
if (phase != pulse_phase) {
pulse_phase = phase;
if (phase) {
PIXEL[0] = 0x00;
PIXEL[1] = 0x00;
PIXEL[2] = 0x00;
} else {
PIXEL[0] = PULSE_BASE[0];
PIXEL[1] = PULSE_BASE[1];
PIXEL[2] = PULSE_BASE[2];
}
create_buffer();
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage_buffer(surface, 0, 0, W, H);
} else {
wl_surface_attach(surface, buffer, 0, 0);
}
pulse_arm();
wl_surface_commit(surface);
wl_display_flush(display);
}
/* ---- the extra (second, same-process) toplevel ---- */
static void extra_toplevel_configure(void *data, struct xdg_toplevel *xp,
@ -3574,6 +3646,8 @@ static void print_usage(const char *argv0) {
"#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"
" --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 "
"pointer enter (default|text|pointer|crosshair)\n"
" --globals-out lists advertised globals to <file> and exits\n"
@ -3705,6 +3779,17 @@ int main(int argc, char **argv) {
return 1;
}
i += 2;
} else if (strcmp(argv[i], "--pulse") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "wltest: --pulse needs ms\n");
return 1;
}
pulse_ms = atoi(argv[i + 1]);
if (pulse_ms <= 0) {
fprintf(stderr, "wltest: bad --pulse\n");
return 1;
}
i += 2;
} else if (strcmp(argv[i], "--tearing") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "wltest: --tearing needs vsync|async\n");