evidence · 19.3 KiB

README.md

Committed at docs/evidence/omarchy-themes/chooser-responsive/README.md. This is what grounds the requirements that cite it.

Open raw source

Theme chooser responsiveness: idle-redraw fix, immediate feedback, fast thumbnails

Captured 2026-09-24 against branch perf/theme-chooser-responsive, base master b9f81d47 (pushed). Host and headless-QEMU only (grim screenshots, synthetic Wayland touch via Sway’s card_shell test-touch IPC, qemu-riscv64-static user-mode emulation of the real cross-built riscv64 binaries) — no board, no real finger, no physical panel. The coordinator’s own board reproduction (Themes carousel real, preview page slow/silent, continuous ~15fps commit/frame-done while idle) is the bug this addresses; see the task brief and docs/evidence/omarchy-themes/background-decode-cache-qemu/board-timing.md for the previously-recorded on-board Python/decode timing this does not change.

Root cause of the idle redraw

main.rs’s event loop already called RendererCache::poll_theme_thumbnails() unconditionally every iteration and correctly set dirty = true only when that call’s own changed return value said something actually arrived (a decode completed). Separately, further down the same loop body, a second block did this instead:

if state.renderer.theme_thumbnails_pending() {
    state.dirty = true;
}

theme_thumbnails_pending() only says whether some visible carousel slice’s bitmap is still unresolved — it says nothing about whether anything changed this iteration. Forcing dirty = true on that alone meant: for the entire span a thumbnail or the “Selected background” still preview was decoding (observed 8-28s on the real board), the loop rendered a full, unchanged Cairo scene and committed a new Wayland buffer at whatever rate the compositor’s own frame callback allowed — effectively the panel’s own frame rate, continuously, regardless of whether the frame just rendered looked any different from the one before it. That full scene render (carousel paint, hit-test geometry, icon lookups) is real CPU work on the K230’s single in-order C908 core, directly competing with the background worker thread doing the actual decode — worse than merely wasteful, it measurably slowed the very decode the user was waiting on.

The fix (nix/rust-shell-client/src/main.rs): delete that block. Retries of dropped/queued thumbnail requests and draining completed decodes already happen every iteration regardless (poll_theme_thumbnails/poll_theme_image are called unconditionally near the top of the loop), so nothing about correctness depended on the deleted block. In its place, a new, deliberately throttled THEME_PULSE_INTERVAL (160ms, ~6/s) drives the one legitimate reason left to redraw without anything having changed: advancing the loading-spinner animation (ThemeView::pulse_phase) while a thumbnail decode, a still-preview decode, or a pending Activate is genuinely in flight. This satisfies the goal as stated — render only for an animation, a completed decode, or input — and bounds that animation’s own cost, rather than removing feedback during a real wait.

Regression coverage: render::tests::pending_thumbnails_do_not_by_themselves_report_a_change (a fresh decode request must not itself report changed, proving pending and changed are decoupled) plus the QEMU idle-window measurement below.

Immediate feedback (goal 1)

Fast thumbnails (goal 3)

Timings (goal 4)

All runs use a synthetic k230-theme command (instant reply, no real Python startup) so these numbers isolate the Rust client’s own render/decode path; they do not include the ~1.2s-per-invocation Python interpreter startup already measured and left unchanged by this task (see ../background-decode-cache-qemu/board-timing.md). Fixture backgrounds are procedurally generated 3840x2160 solid-color images, matching Catppuccin’s real full-resolution webp backgrounds’ pixel dimensions (the ones the bug report names). “tap to preview page” = wall time from the theme-confirm tap to the Preview page’s background carousel first painting real pixels. “tap to thumbnails visible” = wall time from the background-confirm tap to that background’s own thumbnail (a distinct solid color) actually appearing, sampled directly from the rendered frame.

RunThrottleCachetap→preview pagetap→thumbnails visiblepending-window commitsidle-window (Ns) commits/frame-done
before (b9f81d47)nonen/a0.276s3.419s—0/0 over 5s
after (this branch)nonecold0.281s3.468s—0/0 over 5s
before (b9f81d47)cpulimit 50%n/a0.392s10.524s26 commits / 10.9s (2.38/s)1/1 over 5s
after (this branch)cpulimit 50%cold0.372s4.807s15 commits / 5.18s (2.90/s)1/2 over 5s
after (this branch)cpulimit 50%cold (2nd sample)0.381s3.625s14 commits / 4.01s (3.49/s)1/1 over 3s
after (this branch)cpulimit 50%warm (same XDG_CACHE_HOME as the row above)0.375s0.806s4 commits / 1.18s4/4 over 3s

Raw JSON for each row: timings-before-unthrottled.json, timings-after-unthrottled.json, timings-before-throttled50.json, timings-after-throttled50.json, timings-after-cold-cache-throttled50.json, timings-after-warm-cache-throttled50.json.

Reading these numbers.

Estimating real board numbers. qemu-riscv64-static plus a 50% cpulimit throttle is not a calibrated model of the K230’s C908 core, so these are directional, not a hardware claim (<!-- UNVERIFIED against real silicon --> per this project’s grounding rules). The relative improvement is the meaningful number: if the board’s own ~15fps continuous-redraw regime was genuinely stealing roughly half the single core from the decode thread (as the coordinator’s report and this fix’s mechanism both suggest), a similar ~2x reduction in wall time to “thumbnails visible” would be expected there too — i.e., something closer to the report’s own “8s to preview page” (unaffected: the Preview page paints before any background thumbnail needs to resolve) and a materially shorter tail than the reported 28s for backgrounds to finish appearing, before even counting the build-time/persistent-cache wins above (which apply in full on the board, independent of this throttle estimate, since they eliminate the full decode outright on a cache hit).

Commands run

cargo test --offline                                    # 132 passed, 0 failed
python3 tools/blob-scan.py; echo $?                      # ok, exit 0 -- no blob-inventory change needed
nix build --no-link --print-out-paths --max-jobs 1 --cores 6 \
  .#handheld-shell-rust .#handheld-theme-default .#handheld-theme-command .#card-shell
nix eval .#nixosConfigurations.k230-coherent-shell.config.system.build.toplevel.drvPath
python3 tests/rust_theme_chooser_qemu.py --sway <sway-unwrapped riscv64> --rust <k230-shell-rust riscv64>
python3 tests/test_handheld_theme_bundle.py --package <built handheld-theme-default>
python3 tests/test_handheld_theme_default.py tests/test_handheld_theme_rendering.py \
  tests/test_handheld_theme_trial.py tests/test_omarchy_theme_activation.py \
  tests/test_omarchy_theme_resolution.py tests/test_omarchy_theme_sources.py \
  tests/test_omarchy_theme_tools.py tests/test_omarchy_theme_transaction.py \
  tests/test_theme_background_metrics.py tests/test_theme_background_status.py \
  tests/test_theme_catalog.py tests/test_theme_preferences.py     # each run individually; all OK

The before/after timing and screenshot runs above used an ad hoc harness (not committed: a throwaway extension of tests/rust_theme_chooser_qemu.py’s own boot/IPC helpers with full-resolution fixtures, wall-clock timing and an explicit idle window) against a handheld-shell-rust build of master b9f81d47 (“before”) and of this branch (“after”), both paired with the same sway-unwrapped binary from .#card-shell’s closure (nix-store -qR), via qemu-riscv64-static and, for the throttled rows, cpulimit -l 50 -i -z -- (the exact binary tests/test_theme_commit_under_occlusion_runtime.py also pins).

Follow-up: staged-generation-copy fix (branch fix/chooser-staged-thumbs)

The board run after landing the above (system xzb322jc) caught a real miss: the Backgrounds carousel was still spinning ~16s after the tap (40 commits in 5s at the pulse rate, i.e. genuinely pending, not idle redraw). The build-time seed thumbnails existed in the store, but theme_thumbnails::write_builtin_thumbnail’s original lookup was keyed by mirroring the source’s own path (share/omarchy/themes/<name>/<rest> -> share/omarchy/thumbs/<name>/<rest>). tools/theme_activate.py::prepare stages a byte-for-byte copy of a theme’s background under ~/.local/state/omarchy/current/generations/<gen>/theme/backgrounds/... before the chooser ever decodes it — the Rust client only ever sees that staged path, which has no themes component, so the precomputed thumbnail was silently never found and every first view paid for a full 4K decode.

Fix: both disk caches (build-time seed and runtime) are now keyed by the same mechanism — the source file’s own content hash, exactly like the runtime cache already was — rather than by path. The seed ships as a flat, read-only, content-hash-named directory (share/omarchy/thumbs-by-hash/<hash>-<variant>-<w>x<h>.rgba) inside handheld-theme-default, wired to the shell process as K230_THEME_THUMBNAIL_SEED by nix/shell.nix (same pattern as the existing K230_THEME_DEFAULT_GENERATION). A staged copy’s bytes are identical to the pinned source’s (checked_copy never re-encodes), so its hash matches regardless of which of the two paths — or any future third one — asked. Checked also for the theme list’s own preview.png (never staged, already worked, and still works identically under the new key) and the “Selected background” still preview (already fixed by reusing background.cache directly in the previous commit, an unrelated and unaffected code path).

Real, non-board verification that the exact reported gap is closed (tests/test_handheld_theme_bundle.py’s own theme_activate.prepare() call, against the newly built package, no synthetic fixture): staging catppuccin produces generations/<gen>/theme/backgrounds/1-totoro.webp (the exact background named in both the original report and the coordinator’s follow-up); its SHA-256 matches the pinned source’s, and a share/omarchy/thumbs-by-hash/ <that hash>-{expanded,slice}-*.rgba pair exists in the built package.

Quick throttled-QEMU timing, real command, real bundled theme (not the synthetic-fixture harness above — this drives the actual built handheld-theme-command’s k230-theme against the actual built handheld-theme-default, under a fresh $HOME so a real generation gets staged, with K230_THEME_THUMBNAIL_SEED wired exactly as shell.nix does): opening the Themes carousel and confirming the first-discovered theme (catppuccin) settles its background carousel (a full-resolution Totoro/ waves/blue-eye/omarchy set) in 2.45s at 50% cpulimit throttle (6 total commits) and 2.55s unthrottled — versus the reported ~16s/40-commits this branch fixes. timing-staged-fix-real-catppuccin-{unthrottled,throttled50}.json.

Proof commands for this follow-up: cargo test --offline (134 passed, 0 failed, including the new regression test theme_thumbnails::tests::resolve_finds_a_seed_thumbnail_for_a_staged_generation_copy_at_a_different_path), nix build --no-link --print-out-paths --max-jobs 1 --cores 6 .#handheld-shell-rust .#handheld-theme-default .#handheld-theme-command .#card-shell, nix eval .#nixosConfigurations.k230-coherent-shell.config.system.build.toplevel.drvPath, python3 tests/rust_theme_chooser_qemu.py, test_handheld_theme_bundle.py plus every test_handheld_theme_*/test_theme_*/test_omarchy_theme_* host suite (all pass), python3 tools/blob-scan.py (exit 0). Host and QEMU only; the board was not touched for this follow-up.