feat: present standalone overlay on a hardware plane, repaint via per-frame hash poll

On DRM standalone outputs the Alt tab list / confirm-quit modal is
scanned out on a hardware plane via wlr_output_layer (libliftoff), so
the primary buffer stays untouched and direct scanout of the focused
client keeps running underneath. Plane capability is probed once per
output from the first overlay commit and forced via
WLR_DRM_FORCE_LIBLIFTOFF; KICK_OVERLAY_PLANE=0 keeps the composite
fallback. Unlock the plane buffer only after commit, destroy the
per-frame layer after state finish, and recompute the attach-render
lock from capability on output recreate.

Overlay freshness no longer depends on scattered explicit refresh
calls: while held, each frame decision point hashes what the overlay
would draw (rows, titles, highlight, icons) and repaints on drift.
The confirm-quit modal stays on its explicit damage path.

Adds test_alt_shift_t_refreshes_held_overlay.
This commit is contained in:
Andrew Hamilton 2026-09-23 05:53:48 +10:00
parent df65452f62
commit 137e4d0568
4 changed files with 652 additions and 214 deletions

View file

@ -343,6 +343,22 @@ ignored. In this mode kick takes over the screen, and the tab overlay is
composited onto the output in a second render pass instead of being drawn composited onto the output in a second render pass instead of being drawn
into a host window. into a host window.
On a real DRM output the overlay (the `Alt` tab list and the confirm-quit
modal) is presented on a **hardware plane** instead: its own small buffer is
scanned out by libliftoff (wlroots' `wlr_output_layer` path), so the main
buffer never carries the overlay and direct scanout of a focused fullscreen
client keeps running underneath it. The plane is probed once per output: the
first overlay hold commits to the plane, and whether libliftoff accepted the
layer (fixed hardware gives a fixed answer) sets the verdict for the whole
session -- every frame after that just asks the plane with a buffer or
without, and the commit itself arms or disarms it. If the probe finds no
plane (too few planes, format or placement constraints, multi-GPU rendering),
kick stays on the composite second pass for the session; the headless backend
is always composite (it would claim every layer "accepted" while drawing
nothing). `KICK_OVERLAY_PLANE=0` forces the composite path everywhere for A/B
comparison; the verdict is logged by `[render] overlay plane available` /
`[render] overlay plane not available, compositing`.
The auto-detection fires only when none of `KICK_MODE`, `WLR_BACKENDS`, The auto-detection fires only when none of `KICK_MODE`, `WLR_BACKENDS`,
`WAYLAND_DISPLAY` or `DISPLAY` is set **and** either kick's stdin is a real `WAYLAND_DISPLAY` or `DISPLAY` is set **and** either kick's stdin is a real
VT (`/dev/tty<digits>`) or the login advertises a Wayland session VT (`/dev/tty<digits>`) or the login advertises a Wayland session
@ -579,7 +595,9 @@ files to `/tmp` regardless of the log level:
- `SIGUSR1` writes `/tmp/host_N.ppm` — the host compositor's back - `SIGUSR1` writes `/tmp/host_N.ppm` — the host compositor's back
buffer, so you can see what the host actually displayed. In standalone buffer, so you can see what the host actually displayed. In standalone
mode this is the composited frame being presented to the output (scene mode this is the composited frame being presented to the output (scene
plus the `Alt` overlay). plus the `Alt` overlay when the overlay rides the composite path; when it
is scanned out on a hardware plane it lives outside the frame buffer, so
dumps won't show it).
- `N` is an incrementing per-run counter; a single `SIGUSR1` produces a - `N` is an incrementing per-run counter; a single `SIGUSR1` produces a
matching scene/host pair. matching scene/host pair.
- With `KICK_AUTODUMP` (any value, e.g. `KICK_AUTODUMP=1`), a scene and - With `KICK_AUTODUMP` (any value, e.g. `KICK_AUTODUMP=1`), a scene and

View file

@ -3,5 +3,5 @@ Version=1.0
Type=Application Type=Application
Name=kick Name=kick
Comment=Nested Wayland compositor presenting client apps as tabs in a single window Comment=Nested Wayland compositor presenting client apps as tabs in a single window
Exec=env KICK_MODE=standalone kick Exec=kick-pi --screen-mode 1920x1080@60.000 --panel wf-panel-pi --focus-priority firefox lxterminal
DesktopNames=kick; DesktopNames=kick;

781
kick.c

File diff suppressed because it is too large Load diff

View file

@ -3343,6 +3343,69 @@ def test_alt_shift_t_becomes_active(session):
os.environ["TERMINAL"] = old_term os.environ["TERMINAL"] = old_term
def test_alt_shift_t_refreshes_held_overlay():
"""Alt+Shift+T while the overlay is held must repaint it at once.
Regression: add_tab scheduled no frame, so a held overlay kept showing
the old tab list until unrelated damage arrived -- on the hardware-plane
path the stale box was even scanned out as-is. The fresh (still-black)
tab must appear before its client maps, so TERMINAL never connects.
Headless standalone exercises the shared overlay/frame path.
"""
for path in (BIN, WL):
if not os.access(path, os.X_OK):
pytest.skip(f"build first: {path}")
log = _fresh_log()
dump_dir = _mk_dump_dir()
env = dict(os.environ)
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"XDG_DATA_HOME=" + env.get("XDG_DATA_HOME", ""),
"XDG_DATA_DIRS=" + env.get("XDG_DATA_DIRS", ""),
"KICK_LOG=2",
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"KICK_FONT=" + (env.get("KICK_FONT") or _pick_font() or ""),
"XCURSOR_THEME=Adwaita",
"KICK_DUMP_DIR=" + dump_dir,
"KICK_REPEAT_DELAY_MS=" + KICK_REPEAT_TEST_DELAY,
"TERMINAL=sleep 30",
"stdbuf", "-oL",
BIN, f"{WL} '{RED}'", f"{WL} '{BLUE}'"],
stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
s = Session(proc, log, dump_dir)
try:
s.wait_any_active()
s.wait_active_frame()
pos = len(s.read_log())
s.mods(ALT)
s.wait_for_new_after(pos, r"\[render\] overlay show")
s.wait_for_new_after(pos, r"\[diag\] frame start .*vis=1")
mark = len(s.read_log())
s.mods(ALT | SHIFT)
s.key(20, KEY_PRESSED)
s.key(20, KEY_RELEASED)
s.mods(ALT)
# The new tab launches (and sleep never connects, so no map can
# rescue the overlay): a held-overlay frame showing its row must
# follow the launch itself.
s.wait_for_new_after(mark, r"\[launch\] app=\d+ pid=\d+ cmd=sleep 30")
off = s.read_log().index("cmd=sleep 30")
s.wait_for_new_after(off, r"\[title\] app=\d+ 'sleep 30'")
s.wait_for_new_after(off, r"\[diag\] frame start .*vis=1")
_assert_alive(s)
finally:
with contextlib.suppress(Exception):
s.mods(0)
s.close()
def test_sub_window_hidden_when_parent_not_active(): def test_sub_window_hidden_when_parent_not_active():
"""--tab-groups off: a second toplevel from an already-running client gets """--tab-groups off: a second toplevel from an already-running client gets
its own slot glued right after the parent tab, and because the parent is its own slot glued right after the parent tab, and because the parent is