Tap-to-apply: no separate Preview page, no Apply/Cancel step
Implements OpenSpec task 10 from
openspec/changes/archive/2026-10-12-the-shell-swaps-themes-without-a-python-stall/tasks.md.
User decision (2026-09-25, verbatim): “i don’t really think we need an
‘apply’ window for themes at all. tap theme in the theme picker, apply
immediately, so i can compare them easily.” Also folds in a second
coordinator message with fresh board data for task 9’s own fix (master
644cd061, installed n44hk9js...). Worktree feat/tap-to-apply-theme,
base master 644cd061.
This is a host-only, plus one real QEMU touch-injection run result: no board, glass, or real-finger observation. The board-verification gate this task still needs is named at the end.
What changed
There is now one chooser page: the theme carousel, the active theme’s own
background carousel below it, and a plain “Current theme”/“Current
background” label under whichever slice of each is centred and durably
active. Tapping a carousel’s own already-centred slice applies it
immediately, through the same preview (learn the generation)/activate
request pair an explicit Apply button used to send, chained automatically.
Dragging, or tapping an off-centre slice, only recentres — theme_ carousel::Carousel::up’s own pre-existing Confirm-only-when-already-
centred rule is what makes “tap centres if needed, only a tap on the
already-centred slice applies” work with no carousel-level code change.
The Preview page, its Apply/Cancel footer (paint_preview_footer_status,
task 9.1), ThemeIntent::Apply, ThemeView::preview_request/
background_request/apply_request/selection_error/selection_failed,
and the local PREVIEW_FOOTER_Y constant are all removed. ThemeView:: tap_theme/tap_background (returning the next ThemeRequest to submit,
or None) replace them.
Rapid-tap coalescing
A new Desired (theme id, optional background id, learned generation, an
already_active flag) is what a tap sets; ThemeView::advance() submits
the next step toward it only when nothing is already in flight, called
once right after every accept() that returns true. A reply that no
longer matches desired is discarded rather than shown or applied, and the
caller’s own next advance() call moves straight on to whatever is now
desired.
This bounds in-flight work to exactly one request, with a deliberate,
documented tradeoff: a second rapid tap’s own visible effect is bounded
by the first tap’s own in-flight round trip, not instantaneous — the
existing protocol allows only one request in flight per chooser at a time,
and this task does not change that. What it does guarantee: no queue of
stale activations, no crash, no wrong durable state, and the superseded
target’s own late reply is provably discarded (proven directly by
rapid_taps_across_themes_coalesce_onto_the_last_one and the QEMU run
below).
Backgrounds already know their own generation
A background tap targets one of the active theme’s own backgrounds, so
its generation (preview.generation) is already known — tap_background
sets desired.generation directly and goes straight to Activate, no
Preview round trip first (unlike a theme tap, whose target’s generation
is not necessarily known). This is a genuine behavior change from the
first draft of this task (which, like a theme tap, always previewed
first); found and fixed while writing
tap_background_activates_directly_since_the_generation_is_already_known,
which failed against the first draft and passes now.
Busy state without blocking the carousel
ThemeView::applying_theme_index/applying_background_index report which
slice (if any) a pending request targets; render.rs::paint_carousel’s
existing busy: Option<usize> parameter (already drew a spinner clipped to
one slice, unchanged by this task) is wired to them. The carousel itself
stays visible and draggable the whole time — nothing blocks on a pending
Preview/Activate except ThemeView::hit’s pre-existing rule that a
pending Activate specifically cannot be cancelled by navigating away.
No live overlay needed any more
Task 9’s own fix (still landed, unchanged) split the Preview page’s
Apply/Cancel footer into a live overlay painted on every draw() call,
specifically so an Apply tap’s own pending transition or the busy
spinner’s own pulse tick would not itself invalidate a computed
pre-render. With no Apply button, that whole mechanism is now
unnecessary: RendererCache::set_theme_view’s own invalidate() still
runs unconditionally on every ThemeView change (task 9.2’s own doc), so
the ordinary (non-optimistic) rebuild path already repaints the busy
spinner and the pending/error/message line fresh, every time. The one
case that skips a fresh rebuild — an adopted pre-render, inside
show_theme_optimistically — is correct exactly because, at the moment
it is shown, the applied theme already looks done (no busy spinner is
needed, since nothing is visibly still “applying”). Proven by
a_theme_view_change_after_adopting_still_rebuilds_and_shows_pending
(render.rs) and a_pending_only_theme_view_change_does_not_bump_content_ generation (re-targeted, doc updated to explain why this is safe now,
not just that it holds).
paint_preview_footer_status and its call site inside RendererCache:: draw() are removed outright.
The still-image “screen crop” preview is now dead weight, not deleted
The Preview page used to show a large decoded “screen crop” of the
selected background (RendererCache::poll_theme_image/preview_surface,
ThemeImageWorker/ThemeImageKey). Neither carousel needs it — each
already shows its own thumbnail via ThemeThumbnailCache, the same
mechanism used everywhere else in this chooser — so poll_theme_image is
now a deliberate, documented permanent no-op: it never requests a decode,
and theme_preview_image_pending() always returns false. ThemeImageWorker/
ThemeImageKey and their own two tests (still_preview_worker_returns_ output_crop_outside_dispatch, still_preview_reuses_the_generations_ background_cache_instead_of_redecoding) are kept, unused, rather than torn
out in this same task — a named follow-up, not a silent removal.
The double-pre-render investigation (coordinator’s third observation)
Board data (master 644cd061, installed n44hk9js...):
62679ms / 62833ms: optimistic-apply prerendered generation=97e32ff4a99d (computed twice, ~154ms apart)
72092ms touch-down, 72243ms touch-up
72405ms optimistic-apply shown ms=160.7 prerendered=true
72407ms appearance-commit-accepted reused=optimistic
activate 286ms; helper total 279ms
Task 9’s own fix (optimistic-apply-prerender-match-fix-2026-09-28.md)
was believed to close this by excluding pending/pending_id/error/
message/pulse_phase from theme_view_cache_key_differs, the predicate
content_generation bumps on. This board run shows it still happening,
for the same generation, on top of that fix.
Investigated by code review this session (no board access): on master
644cd061, RendererCache::poll_theme_image was still actively
decoding the Preview page’s own “screen crop” still image, asynchronously,
via a one-entry worker/channel. That decode completing calls self. invalidate() and, independently of theme_view_cache_key_differs
entirely, bumps content_generation through its own if changed { self. content_generation... } branch inside poll_theme_image itself — a
signal task 9’s fix never touched, because it is not a ThemeView field
change at all. A background image decode completing roughly 150ms after
the Preview page loads matches the reported ~154ms gap, and “computed
twice for the same generation” (not two different candidates) matches a
decode completing while a person is still dwelling on one unchanged
candidate.
This task’s own removal of the still-image preview (above) makes
poll_theme_image a permanent no-op, which removes this trigger
structurally: there is no asynchronous decode left that can bump content_ generation a second time while nothing else changed. This is reasoned
from the code, not re-verified against fresh board evidence this session
— the board-verification gate below is where that gets confirmed or
refuted.
The 161ms-on-a-match number, and what this task did and did not do about it
Per-stage instrumentation was added to show_theme_optimistically
(nix/rust-shell-client/src/main.rs): optimistic-apply stage adopt_ms=... wallpaper_ms=... overlay_ms=... flush_ms=..., logged alongside the
existing optimistic-apply shown ms=... prerendered=... summary line.
adopt_ms covers adopt_prerendered_overlay’s own pixel copy (or, on a
miss, set_appearance’s cache invalidation); wallpaper_ms/overlay_ms
each cover one surface’s whole draw_wallpaper/draw call — attach,
damage, and wl_surface::commit all happen inside those calls and are
not separately timed by this instrumentation.
Not attempted this task, named here as deferred follow-up rather than silently dropped:
- A finer breakdown inside
draw/draw_wallpaperthemselves (attach, damage, commit as separatems=fields). - The wallpaper-buffer-attach optimization (pre-build the actual
wl_bufferat prepare time, so adopting at apply time is attach-only, no pixel copy) — scoped by the coordinator to the wallpaper surface specifically, since the overlay surface must still paint dynamic content (the busy spinner) live regardless of whether its body came from a rebuild or a pre-render, making a full buffer-attach for the overlay lower return-on-investment. - Damaging only the regions that changed, instead of the whole surface.
Reasoning for deferring rather than attempting a fast, unverified change:
none of these can be judged correct or actually faster without measuring
them on the K230 itself (a software Cairo/Pixman path, no GPU, on a
board this task did not touch), and a wrong buffer-attach implementation
specifically risks showing stale or torn pixels — a correctness bug, not
merely a missed optimization. The per-stage instrumentation above is what
the next board run needs to find which of adopt_ms/wallpaper_ms/
overlay_ms/flush_ms actually dominates the 161ms, before spending
effort on the wrong one.
Layout, not board-verified
THEME_CAROUSEL_TOP moved from 204.0 to 132.0; BACKGROUND_CAROUSEL_TOP
is now THEME_CAROUSEL_TOP + THEME_GEOMETRY.expanded_h + 100.0 = 872.0
(both fixed 568x1232-panel-space constants, computed to fit both carousels,
their own labels, and one pending/error/message line inside the panel’s
1232px height with a small margin at every stage). This is arithmetic, not
a board observation of actual rendered spacing/legibility — the board
gate below covers it.
Tests run this session (host)
cd nix/rust-shell-client
cargo test --offline
k230-shell-rust (lib): 171 passed, 0 failed (includes 24 new/adapted
theme_ui::tests — tap-apply of a cold theme
(tap_theme_on_a_cold_slice_previews_then_activates_and_switches_the_ background_carousel), tap-apply of the already-active theme, background
tap-apply (direct Activate, no Preview), the video/already-selected
background no-ops, rapid-tap coalescing
(rapid_taps_across_themes_coalesce_onto_the_last_one), rollback-on-
failure with a visible error
(a_failed_activate_rolls_back_with_a_visible_error_and_clears_pending),
and all the pre-existing prepare-ahead/neighbour-warm tests, adapted to
the one-page model).
k230-shell-rust (bin/route_tests): 14 passed, 0 failed, unchanged
— should_apply_optimistically/optimistic_apply_due/may_reuse_ optimistic_frame/prerendered_overlay_matches are pure functions this
task did not touch.
appearance_module/background_decode_module/service_data_module/
theme_catalog_module integration tests: 14 + 8 + 8 + 5 passed, 0
failed, all unchanged.
python3 -m unittest tests.test_theme_catalog tests.test_theme_helper_daemon \
tests.test_theme_preferences tests.test_theme_timing \
tests.test_omarchy_theme_activation tests.test_omarchy_theme_resolution \
tests.test_omarchy_theme_sources tests.test_omarchy_theme_tools \
tests.test_omarchy_theme_transaction -v
88 passed, 0 failed — this task touched no Python source, run as a regression check on the theme protocol/daemon these Rust changes talk to.
python3 tools/blob-scan.py
blob-scan: ok -- every binary is accounted for.
Builds
nix build --no-link --print-out-paths --max-jobs 1 --cores 6 \
.#handheld-shell-rust .#card-shell
Both succeeded:
/nix/store/sz4rjr4qx7jvygnb04c9waxinnz3mlyj-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0/nix/store/1va4angandva4xbm5i0v7367p0nd9jry-k230-card-shell
nix build --no-link --print-out-paths --max-jobs 1 --cores 6 \
.#handheld-theme-command .#handheld-theme-default
Both succeeded:
/nix/store/5c2ydl2zlll3dayf3gxj4lrhqsxrfbdi-handheld-theme-command-0.1/nix/store/59w41ngkinv5lgxisnpm1w5salzldvrn-handheld-theme-default-28ceaae7
nix eval .#nixosConfigurations.k230.config.system.build.toplevel.drvPath
succeeded: /nix/store/9yx3i3yqh348dhk070vbjv140ggjx957-nixos-system-nixos-26.11.20260919.20b1ddd.drv.
Dark and light theme evidence: host-rendered visual fixtures
Real evidence, host-only (no board): RendererCache::draw for the
one-page chooser, run through the same themed_surface_fixtures_keep_ live_area_clear_and_use_authored_roles host test every prior visual
evidence doc in this directory uses, against two real, previously-staged
theme generations (Catppuccin dark, Catppuccin Latte).
cd nix/rust-shell-client
XDG_DATA_DIRS=/nix/store/in3nc21zx02xiz2rdp5bpq36719x3axd-handheld-theme-icons-25.10.3/share \
K230_VISUAL_GENERATION_DIR=/tmp/k230-rust-visual-catppuccin/generations/cd73257753c4a1a64fa092b3 \
K230_VISUAL_FIXTURE_DIR=/tmp/k230-tap-to-apply-dark \
K230_VISUAL_REQUIRE_ICONS=1 \
cargo test --offline --lib -q themed_surface_fixtures_keep_live_area_clear_and_use_authored_roles
XDG_DATA_DIRS=/nix/store/in3nc21zx02xiz2rdp5bpq36719x3axd-handheld-theme-icons-25.10.3/share \
K230_VISUAL_GENERATION_DIR=/tmp/k230-rust-visual-latte/generations/c55da9f13ae1265e4ff18435 \
K230_VISUAL_FIXTURE_DIR=/tmp/k230-tap-to-apply-latte \
K230_VISUAL_REQUIRE_ICONS=1 \
cargo test --offline --lib -q themed_surface_fixtures_keep_live_area_clear_and_use_authored_roles
Both 1 passed; 0 failed. themes.png from each run is committed as
tap-to-apply-host/themes-dark.png and tap-to-apply-host/themes-latte.png.
Both show the theme carousel, its “Current theme” label, the “Backgrounds”
heading, the background carousel, and its own “Current background” label,
all fitting inside the 1232px panel with margin to spare in both themes —
confirming the new THEME_CAROUSEL_TOP/BACKGROUND_CAROUSEL_TOP layout
arithmetic (above) actually renders as intended, on real (if host-Cairo,
not board-Pixman) output. The carousel slices show a loading spinner, not
decoded art, because this fixture never waits on poll_theme_thumbnails’s
own worker thread the way the interactive QEMU/board path does — expected
and unchanged from every prior visual fixture in this directory.
QEMU touch-injection run: attempted, environment-blocked, not a code regression
The sway-unwrapped build (a large dependency chain — wlroots, xwayland,
and their own dependents, built serially at --max-jobs 1) finished
successfully: /nix/store/rvjwy2wiqs9cnkawqvg7hmglj7a46fgl-sway-unwrapped- riscv64-unknown-linux-gnu-1.12 (the patched, card_shell-IPC-capable
build already produced as a side effect of the earlier .#card-shell
build, found at swaybar/swaymsg/swaynag’s own resolved symlink
target next to it — .#nixosConfigurations.k230.pkgs.sway-unwrapped
turned out to be plain upstream sway, missing the card_shell IPC
commands this test needs; corrected once found).
python3 tests/rust_theme_chooser_qemu.py \
--sway /nix/store/rvjwy2wiqs9cnkawqvg7hmglj7a46fgl-sway-unwrapped-riscv64-unknown-linux-gnu-1.12/bin/sway \
--rust /nix/store/sz4rjr4qx7jvygnb04c9waxinnz3mlyj-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0/bin/k230-shell-rust \
--output /tmp/k230-tap-to-apply-qemu-run2
Run twice; both times the harness failed at k230-shell-rust: route timed out (main.rs::request, a pre-existing 500ms deadline for the short-lived
--surface settings helper invocation’s own socket round trip to the
already-running --serve process), before reaching any theme-chooser-
specific code — sway itself started correctly and accepted the harness’s
own card_shell test-touch init IPC command first (see sway.log: “K230_
CARD_SHELL input=injected operation=test-touch accepted=1”), and the
--serve process’s own log showed a normal, complete startup (wallpaper- configure, home-configure, ready-idle) with no error.
Confirmed via git diff 80817e3d..HEAD -- nix/rust-shell-client/src/ main.rs that this task’s own diff touches neither fn request nor
poll_until nor their 500ms deadline at all — this is not a regression
in this task’s own code. This machine was running several other
concurrent agents’ own heavy builds and at least one other agent’s own
qemu-riscv64-static-based sway test at the same time (ps aux during
this investigation), so the most likely explanation is CPU contention on
a shared host making the existing 500ms deadline too tight right now, not
a functional break in the route-request mechanism itself. This was not
chased further by loosening the timeout or by other environment
workarounds, since that code is outside this task’s own scope and
changing it without being sure of the actual cause risks masking a real
problem elsewhere.
Not obtained this session: the QEMU touch-injection proof of the new
tap-to-apply flow (warm/cold tap-apply, background tap-apply, rapid-tap
coalescing) that tests/rust_theme_chooser_qemu.py’s own rewrite (this
task) was built to produce. The script itself is written and committed;
running it to a real result — on a less contended host, or after
investigating the route-timeout separately — remains open. The state
machine and touch-mechanics it would exercise are covered instead by the
24 theme_ui::tests (host, cargo test) and the two dark/light host
visual fixtures (above); neither substitutes for an actual touch-injected
run.
Remaining gates: QEMU rerun (any host) and board-verification
- The QEMU touch-injection run itself (
tests/rust_theme_chooser_qemu.py, committed, not run to completion this session — see above).
Board-verification gate this task still needs
optimistic-apply stage adopt_ms=... wallpaper_ms=... overlay_ms=... flush_ms=...for a real tap-apply, on the board, to find which stage actually dominates the 161msoptimistic-apply shown ms=...figure.- Whether the double-pre-render symptom (
optimistic-apply prerenderedcomputed twice for the same generation) is actually gone now thatpoll_theme_imageis a permanent no-op, per the code-review finding above. - Whether the new
THEME_CAROUSEL_TOP/BACKGROUND_CAROUSEL_TOPlayout actually fits the panel without clipping or crowding, in both dark and light themes. - A real-finger tap-to-apply of a warm theme, a cold theme, a background, and a deliberately rapid double tap across two different themes, plus one forced-failure rollback if a failure can be induced without modifying user themes.
None of the above is claimed done by this task; python3 tools/ work-status.py and the operator’s own board/serial reservation govern
when it can run.