Skip to content

feat(ui): prove persistent canvas::Cache on the now-playing waveform#137

Merged
thewrz merged 11 commits into
mainfrom
feat/issue-131
Jun 22, 2026
Merged

feat(ui): prove persistent canvas::Cache on the now-playing waveform#137
thewrz merged 11 commits into
mainfrom
feat/issue-131

Conversation

@thewrz

@thewrz thewrz commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Why

ADR-009 (decision step 4) mandates proving the correct persistent-canvas::Cache pattern on a low-risk surface before the tile grid (#13/#92). PR #96 failed by calling Cache::new() inside view() — which Iced re-runs every Message, throwing the cache away each frame (~6000 path ops/frame at 200 tiles). This PR proves the persistent-cache lifecycle on the now-playing waveform so the grid can copy a known-good pattern instead of repeating PR #96's regression.

What

The now-playing bar's manual container-width progress bar is replaced with a canvas-rendered waveform whose geometry is cached and reused across frames:

  • src/ui/waveform.rs (new, pure, Iced-free): deterministic per-id bar samples, a quantized progress_bucket, and the RenderKey (sound id + bucket) that decides when the cache is invalidated. Keeping it renderer-free makes the cache-lifecycle decision unit-testable without a GPU.
  • src/ui/now_playing.rs: NowPlaying owns the persistent canvas::Cache and the last RenderKey. sync(playing, progress) clears the cache only when the key changes — it is the single place the cache is ever cleared. WaveformProgram draws the static bars through cache.draw(..) (one tessellation per sound) and the moving playhead as a cheap per-frame overlay that never invalidates the cached bars.
  • src/app.rs: HonkHonk holds one now_playing: NowPlaying field and makes a single delegating sync(..) call in update(). No cache-lifecycle logic lives in app.rs (it is over the 400-line budget) — all of it stays in the now-playing module.
  • docs/adr/009-...md: records the proven pattern (cache in persistent state, render-key invalidation, quantized inputs, cheap moving elements as overlays) as the reference for feat(ui): canvas sticker tiles + hand-drawn glyphs #13/feat(ui): sticker tile hover + playing animations (PR2 of #13) #92.

The progress bucket resolution is tied to the bar count (WAVEFORM_BARS), since the cached bars are the only cache-sensitive content and the smooth playhead reads raw progress — so cache clears happen at most once per bar boundary, not per frame.

The canvas Iced feature is enabled (it is not in the default set); the resulting Cargo.lock additions (the MIT/Apache-2.0 lyon tessellation family + float_next_after) were vendored into packaging/flatpak/cargo-sources.json via the repo's generator to keep the #121 Flatpak freshness gate green.

Design decisions

Ambiguous calls made (the issue body was the only spec; brainstorming was skipped):

  • Waveform data is synthesized deterministically from the sound id. SoundEntry carries no real waveform/PCM data, and decoding audio to draw a real waveform is out of scope for a cache-pattern proof. A per-id deterministic sample array gives stable, distinct-looking bars that legitimately only need re-tessellation when the sound changes — exactly the property the cache pattern needs to demonstrate. Real PCM-derived waveforms can replace samples() later without touching the cache lifecycle.
  • Cache keyed on (sound id, progress bucket); bucket count = bar count (48). Bucketing prevents sub-pixel progress jitter from thrashing the cache. Tying the resolution to WAVEFORM_BARS (rather than an arbitrary 240) means a cache clear happens only when a bar's played/unplayed color could actually change, keeping the smooth playhead motion in the un-cached overlay.
  • Playhead is a separate per-frame Frame overlay, not part of the cached geometry. This is the core of the pattern: frequently-moving elements are drawn fresh each frame so the expensive tessellation (the bars) stays cached.
  • sync runs in update(), not view(). sync needs &mut self (it stores the key); view() is &self. Every code path that mutates self.playing/self.progress routes through update(), and sync runs after the message match, so the cache is always fresh before the next view().
  • canvas::Canvas::new(program) instead of the free canvas(program) fn, because use iced::widget::canvas; shadows the helper name — a correct equivalent.

Testing

  • Unit tests pass — cache-lifecycle logic tested at the module boundary (ui::waveform, ui::now_playing); rendering output intentionally not unit-tested per ADR-009
  • Reuse-contract regression tests: 60 steady frames → exactly 1 cache clear; advancing across ~4 buckets over 16 frames → far fewer clears than frames (proves no per-frame re-tessellation)
  • Full suite green: 416 tests, 0 failures
  • cargo clippy --all-targets -- -D warnings clean; cargo fmt --check clean
  • cargo build --release clean
  • File budgets respected: now_playing.rs 313 lines, waveform.rs 119 (both < 400); no cache logic added to over-budget app.rs
  • Manual two-renderer smoke check (wgpu default + HONKHONK_RENDERER=software): the waveform renders and the playhead animates without stutter/flicker — deferred to a human on a desktop session (CI/agent env is headless, no display/GPU). The automated reuse-contract tests + the headless benches/grid_render.rs canvas layout/draw path stand in for the per-frame-reuse acceptance.

🤖 Co-authored by Claude Opus 4.8. Closes #131.

Summary by CodeRabbit

  • Chores
    • Updated dependencies to support enhanced rendering capabilities for the waveform visualization
    • Refactored internal now-playing waveform architecture to improve rendering efficiency

thewrz and others added 9 commits June 22, 2026 09:39
Canvas is feature-gated in Iced 0.14 and absent from the default set;
the persistent-cache waveform (#131) needs iced::widget::canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure, Iced-free logic for the now-playing waveform: per-id bar heights
and the RenderKey (sound id + quantized progress bucket) that decides
when the canvas::Cache is invalidated. Bucketing prevents per-frame
cache thrash from sub-pixel progress jitter. (#131)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cache lives in this module's own state and is cleared ONLY when
sync() observes a render-key change (different sound or a crossed
progress bucket) — never rebuilt in view(). This is the persistent-cache
lifecycle ADR-009 mandates proving on a low-risk surface before #13/#92.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Static bars draw through the persistent cache (one tessellation per
sound); the playhead is a per-frame overlay that never invalidates the
cached bars. Replaces the manual container-width progress bar. (#131)

Note: canvas widget constructed via canvas::Canvas::new(program) rather
than the free fn canvas(program) since the iced::widget::canvas import
shadows the helper in scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `now_playing: NowPlaying` field to HonkHonk struct. Initialize in
`new` and `new_for_test`. Call `sync` at the end of `update` so the
waveform cache stays in step with playback state. Fix the stale
`view_now_playing` call site to pass `&self.now_playing` as first arg.

All cache-lifecycle logic lives in `ui::now_playing::NowPlaying`;
app.rs holds only the minimal glue (field + 2 inits + 1 sync + arg fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two regression tests that assert the canvas::Cache is cleared exactly
once on state change, not on every frame — the observable proxy for
no-per-frame re-tessellation (ADR-009).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes ADR-009 decision step 4 — now_playing.rs is the reference impl
for the persistent canvas::Cache lifecycle the tile grid must follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y docs

Fix 1: reduce PROGRESS_BUCKETS from 240 to WAVEFORM_BARS (48) so the cache
is cleared only when played_to actually crosses a bar boundary — the only
cache-sensitive event. Previously the cache was cleared ~5× more often than
the bar geometry changed. The smooth playhead overlay uses raw progress and
is unaffected.

Fix 2: update PROGRESS_BUCKETS doc to clarify it is a max bucket index
(inclusive), with PROGRESS_BUCKETS+1 distinct values, and why it equals the
bar count.

Fix 3: add comment in WaveformProgram::draw noting why the played/unplayed
bar split baked into cached geometry is correct, and what would break it.

Fix 4: add clarity comments — bounds.size() triggers resize re-tessellation;
canvas::Canvas::new struct form avoids shadowed helper name; sub-bucket jitter
test documents its bucket-width dependency.

Fix 5: hash the sound id once in samples() and mix in bar index via wrapping
integer arithmetic instead of re-hashing the id 48 times per frame.

Co-Authored-By: Claude <noreply@anthropic.com>
Records the ordered TDD plan executed for the now-playing waveform
canvas-cache work, consistent with docs/superpowers/plans/ convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@thewrz, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 17 minutes and 53 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b4d9ee0b-41fe-4765-bb6c-773c24cab711

📥 Commits

Reviewing files that changed from the base of the PR and between e942771 and ec02cc8.

📒 Files selected for processing (2)
  • docs/superpowers/plans/2026-06-22-issue-131-persistent-canvas-cache.md
  • src/ui/now_playing.rs
📝 Walkthrough

Walkthrough

Adds a persistent canvas::Cache-backed waveform renderer to the now-playing bar. A new waveform.rs module provides deterministic bar-height generation and a RenderKey/progress-bucketing API. NowPlaying owns the cache and clears it only on key change via sync(). HonkHonk gains the now_playing field and delegates sync in update(). Flatpak vendor entries for float_next_after and lyon are added, and ADR-009 is updated.

Changes

Persistent canvas::Cache waveform rendering

Layer / File(s) Summary
Iced canvas feature + Flatpak vendor entries
Cargo.toml, packaging/flatpak/cargo-sources.json
Adds canvas to iced's feature list and inserts vendored source entries for float_next_after 1.0.0 and the lyon/lyon_* 1.0.19–1.0.20 crate family.
waveform.rs: RenderKey, samples, and progress bucketing
src/ui/waveform.rs, src/ui/mod.rs
New module declares WAVEFORM_BARS/PROGRESS_BUCKETS constants, samples(id) deterministic bar generator, progress_bucket quantizer, RenderKey struct with render_key constructor and allocation-free matches method, and full unit-test coverage. Exported via pub mod waveform in mod.rs.
NowPlaying cache owner + WaveformProgram canvas renderer
src/ui/now_playing.rs
NowPlaying struct holds canvas::Cache + Option<RenderKey>; sync() clears cache only on key change. WaveformProgram implements canvas::Program drawing static bars from the persistent cache plus a per-frame playhead overlay. view_now_playing signature updated; view_waveform helper added. Unit tests cover reuse and invalidation across playback scenarios.
HonkHonk wiring
src/app.rs
HonkHonk gains now_playing: NowPlaying field initialized in both constructors; update() calls self.now_playing.sync(...) once after every message match; view_main() passes &self.now_playing to view_now_playing.
Implementation plan and ADR-009 update
docs/superpowers/plans/2026-06-22-issue-131-persistent-canvas-cache.md, docs/adr/009-canvas-sticker-tiles-rejected.md
Full step-by-step implementation plan for Issue #131 added. ADR-009 updated with "persistent-cache pattern proven" section documenting rules and tile-grid applicability.

Sequence Diagram(s)

sequenceDiagram
  participant App as HonkHonk
  participant NP as NowPlaying
  participant Cache as canvas::Cache
  participant WP as WaveformProgram

  rect rgba(70, 130, 180, 0.5)
    note over App,Cache: update() — per message
    App->>NP: sync(playing, progress)
    NP->>NP: RenderKey::matches(playing, progress)?
    alt render key changed
      NP->>Cache: clear()
      NP->>NP: store new RenderKey
    else same render key
      NP-->>App: no-op (cache preserved)
    end
  end

  rect rgba(34, 139, 34, 0.5)
    note over App,WP: view() — every frame
    App->>NP: &now_playing into view_now_playing
    NP->>WP: construct WaveformProgram(&cache, samples, progress)
    WP->>Cache: cache.draw() → tessellate waveform bars (skipped if cache valid)
    WP->>WP: draw playhead overlay line (every frame)
    WP-->>App: canvas::Canvas element
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • wrzonance/HonkHonk#50: Introduced the original view_now_playing with a basic progress-bar, whose signature and rendering this PR directly replaces with the cached canvas waveform approach.
  • wrzonance/HonkHonk#75: Modified src/ui/now_playing.rs and view_now_playing styling, overlapping directly with the file and function this PR substantially refactors.
  • wrzonance/HonkHonk#97: Introduced docs/adr/009-canvas-sticker-tiles-rejected.md, which this PR extends with the "persistent-cache pattern proven" section.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: implementing a persistent canvas::Cache pattern on the now-playing waveform to prove the design before grid work.
Linked Issues check ✅ Passed All four acceptance criteria from issue #131 are met: cache stored in persistent NowPlaying state, waveform rendered via canvas with cross-frame reuse, regression tests verify cache hits, and ADR-009 documents the proven pattern for tile grid work.
Out of Scope Changes check ✅ Passed All changes directly support the stated objectives of proving the persistent canvas::Cache pattern; no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-131

Comment @coderabbitai help to get the list of available commands.

@thewrz

thewrz commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

NowPlaying::sync called render_key() on every update(), allocating an
owned id String even on the common no-change frame. Add an allocation-free
RenderKey::matches and only build the owned key when it actually changes
(i.e. on a cache clear). A test pins matches ≡ render_key equality.

Addresses the Info-1 finding from the #137 local code review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thewrz

thewrz commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Local code review (Claude Opus 4.8)

CodeRabbit hit its review rate limit / org credit cap on this PR (see the rate-limit notice above), so this is a stand-in review of the diff for correctness and reuse/simplification/efficiency. CI is fully green on the reviewed HEAD.

🔴 Critical: none · 🟠 Warning: none · 🔵 Info: 2

Correctness — cache lifecycle is sound ✅

Focused on the most bug-prone part: whether cached geometry can go stale.

  • Cache is keyed on RenderKey { id, bucket } and cleared only in NowPlaying::sync, which runs once per update() after the message match (app.rs) — key is always fresh before the next view().
  • The played/unplayed bar split baked into the cached geometry is a pure function of progress (captured by bucket) and bounds (handled by the bounds.size() arg to cache.draw); window resize invalidates independently of the key.
  • Intended quantization, not a bug: within one bucket the bar-fill color is frozen while the playhead overlay moves smoothly each frame — bar fill lags the playhead by ≤1/48 of the track. This is the documented design (expensive bars cached, cheap moving element drawn fresh).
  • Contract tests pin the right invariants (60 steady frames → 1 clear; smooth progress → ≤5 clears/16 frames; sub-bucket jitter → no clear).

🔵 Info-1 — sync allocated a String every update() — ✅ FIXED in e942771

render_key did id.to_owned() on every call (incl. any progress tick), even on no-change frames. Added an allocation-free RenderKey::matches; the owned key is now built only when the key actually changes (on a clear). Test pins matchesrender_key equality.

🔵 Info-2 — samples(id) recomputed each view() — declining

48 integer ops per view rebuild; the expensive tessellation is what's cached, so this is negligible. Not worth churning tested code.

Verdict: merge-ready. app.rs footprint is appropriately minimal (one field + one delegating sync call; zero cache logic in app.rs).

🤖 Local review by Claude Opus 4.8 (CodeRabbit substitute due to rate limit).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-06-22-issue-131-persistent-canvas-cache.md`:
- Around line 97-98: The documentation file is missing the `matches()` method in
the RenderKey impl block, which is a critical optimization for allocation-free
comparisons in the hot path. Add the `matches()` method to the RenderKey impl
block after the `render_key` function definition, implementing the comparison
logic that checks if the bucket equals progress_bucket(progress) and the id
matches the playing parameter. Additionally, add the test function
`matches_is_allocation_free_equivalent_of_render_key` to the test suite to
validate that matches() behaves as the allocation-free equivalent of comparing
the owned RenderKey.
- Around line 283-291: The sync() method currently allocates a new RenderKey on
every call via render_key() for comparison, which is wasteful in the common
case. Replace the current implementation to use the RenderKey::matches() method
instead, checking if the existing self.key matches the provided playing and
progress parameters without allocating a new key. Only call render_key() to
create and store a new key when the comparison fails and the cache needs to be
cleared, following the allocation-free pattern demonstrated in the proposed
implementation.
- Around line 122-131: The `samples()` function currently re-hashes the id
string for each bar within the loop closure passed to `std::array::from_fn`,
which is inefficient. Refactor by moving the id hashing outside the loop to
compute it once upfront, then for each bar index, mix in the index value using
cheap wrapping arithmetic (specifically integer multiplication) rather than
hashing the index again. This reduces the hashing cost from O(n) to O(1) and
aligns with the proven implementation pattern referenced in the comment.
- Line 119: The PROGRESS_BUCKETS constant is hardcoded to 240 instead of being
derived from WAVEFORM_BARS as intended by the PR objectives. Change the
PROGRESS_BUCKETS constant definition from the hardcoded numeric value 240 to
WAVEFORM_BARS as u16 to ensure progress bucket resolution matches the bar count
(48) and achieve the intended reduction in cache invalidations. This aligns the
implementation with the optimization goal of reducing unnecessary cache clears.

In `@src/app.rs`:
- Around line 160-162: The now_playing field and its related wiring (at lines
321, 363, 960-965, and 1225) are being added directly to src/app.rs, which
violates the guideline that this file is off-limits for additions due to its
existing size violation. Refactor by first splitting src/app.rs to extract the
appropriate module surface for now-playing waveform cache management, then add
the now_playing field and all associated wiring to the newly extracted module
boundary instead of the main app.rs file.

In `@src/ui/now_playing.rs`:
- Around line 93-97: In the now_playing.rs file, the comparison operator in the
color assignment condition is using `x <= played_to` which causes the first bar
to render as played even at 0% progress. Change the less-than-or-equal operator
to a strict less-than operator (from `<=` to `<`) in the condition `if x <=
played_to` so that when progress is 0.0, no bars appear as played.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a23a4c51-fd1c-4ba6-82d5-f3166e86f9ef

📥 Commits

Reviewing files that changed from the base of the PR and between b294398 and e942771.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • Cargo.toml
  • docs/adr/009-canvas-sticker-tiles-rejected.md
  • docs/superpowers/plans/2026-06-22-issue-131-persistent-canvas-cache.md
  • packaging/flatpak/cargo-sources.json
  • src/app.rs
  • src/ui/mod.rs
  • src/ui/now_playing.rs
  • src/ui/waveform.rs

Comment thread src/app.rs
Comment thread src/ui/now_playing.rs Outdated
CodeRabbit review: the now-playing waveform used `x <= played_to`, so at
0% progress (played_to == 0.0) the first bar still rendered as played.
Use strict `<` so zero progress shows zero played bars.

Also add an "as-planned, not as-shipped" banner to the #131 plan doc so
#13/#92 implementers copy the proven pattern from the shipped code +
ADR-009 (PROGRESS_BUCKETS = WAVEFORM_BARS = 48, hash-once samples,
allocation-free RenderKey::matches) rather than the older plan snippets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thewrz thewrz merged commit 54aaab8 into main Jun 22, 2026
11 checks passed
@thewrz thewrz deleted the feat/issue-131 branch June 22, 2026 21:21
thewrz added a commit that referenced this pull request Jun 22, 2026
Resolves the app.rs overlap with #131 (#137): the effects_ui and
now_playing fields + their constructor inits now coexist; and
audio/effects/mod.rs combines #131's neighbours with this branch's
`layout` module alongside #136's `formant` modules — all additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ui): prove persistent canvas::Cache on the now-playing waveform

1 participant