Skip to content

🎬 Projects-box slide, round two: frames from the steady rendering pipeline (#416)#436

Merged
martinciu merged 18 commits into
mainfrom
416-animate-projects-box
Jun 12, 2026
Merged

🎬 Projects-box slide, round two: frames from the steady rendering pipeline (#416)#436
martinciu merged 18 commits into
mainfrom
416-animate-projects-box

Conversation

@martinciu

@martinciu martinciu commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Closes #416. Supersedes #417 (round one, closed — "I'll try another approach").

🧭 Why round two

Round one animated the slide through a parallel rendering pipeline (snapshot skyline rasterizer + pre-rendered box sliced bottom-first under a phantom border). One root cause, four visible defects: the box rose empty, the chart's endpoint frames didn't match the steady chart, and the x-labels shifted sideways and changed color mid-slide.

✨ What changed

Same motion (single critically-damped harmonica spring on the box's outer height, p to toggle), but every frame is now produced by the same code that paints steady state, at the animated height:

  • 📊 Chart, bar modesrenderWindow (the steady windowed painter: visible slice, flush-right slack, on-screen peak, in-bar labels) at the lever-derived chartHeight().
  • 📈 Chart, line mode — a windowed buildLineChart at viewport width (visibleWindow + slicePointsInRange): the full-canvas per-frame rebuild measured ~41ms at a 30-day canvas, 2.5× the 60fps budget. The x-label row is built for the full canvas exactly as the steady path does, then cut to the visible columns, so it stays byte-stable mid-slide; endpoint frames are untouched (frame 0 never repaints, settle restores the full canvas via refreshChart).
  • 📦 Box — re-flowed by the steady View() path via renderProjectsBox at projectsHeight() every frame: real borders and title from the first frames, cells filling top-down, the "…N more" overflow recounting as rows fit. renderProjectsBox gains exact-height degenerate frames (1: top border, 2: closed shell, 3: shell + title) for the heights only the slide passes through.
  • 🧹 StateprojectsAnimSnapshot, projectsBandRows, projectsTopBorder and the band View branch are deleted; the only slide state left is the spring + projectsSlideFrom/To. A second p mid-slide reverses from the current height. lerpInt now clamps r to [0,1]. An empty/cleared chart snaps instead of animating.

Endpoint identity by construction: frame 0 is byte-identical to the pre-slide steady view, the settle frame byte-identical to the post-slide steady view — pinned by tests in both chart modes under forced color.

🔀 Merged main mid-flight

The branch merged main to pick up the content-aware box height (#420/#429) and the usage-line projects window fix (#430/#431), reconciled with the slide: projectsTargetHeight is now content-aware while the projectsHeight() lever still returns the animated height mid-slide, and beginProjectsAnimation queries the aggregates before reading the target so a show slides to the real content height rather than the empty-box floor.

✅ Testing

  • 🧪 Endpoint identity (bar + line), box content presence from animH≥3, x-label row byte-stability (ANSI included, bar AND windowed line), height conservation every frame, re-arm-from-current-height, lerpInt clamping, degenerate box heights, zero-DB-per-frame guards — all in-process.
  • 🖥️ Real-binary tmux probe: every captured frame exactly 42 rows; box border + title + project rows present early; x-label row md5-identical (ANSI included) across steady/mid/settled/hiding frames; steady ↔ show → hide round trip byte-identical including ANSI against an isolated fixture. Post-merge re-probe on the usage-line view: p renders the per-project rollup for the visible window (the 🪟 Projects box shows "no activity in this window" on the usage-line view after zoom/scroll #430 symptom is gone).
  • 📐 BenchmarkProjectsAnimFrame (Apple M1 Max): bar 3.75ms ± 1%, line 2.50ms ± 3%, and line_30d (realistic 2880-col canvas) 2.48–2.52ms / 4.00MB per frame — all well inside the 16.7ms 60fps budget; the 30fps line-mode escape hatch stays unused.
  • 🛡️ make lint 0 issues, full go test ./... green (goleak guards included), make build clean.

🤖 Generated with Claude Code

martinciu added 18 commits June 9, 2026 22:10
BenchmarkProjectsAnimFrame (Apple M1 Max, darwin/arm64) — one full
per-frame slide render (rasterize + drawSkyline + label row + band slice),
all under the 60fps/16.7ms budget:

  /100-10    289304 ns/op    58207 B/op    322 allocs/op
  /1000-10  1185059 ns/op   410865 B/op    502 allocs/op
  /5000-10  5201372 ns/op  1881785 B/op   1147 allocs/op

Widen seedModelAt / seedBarModelWithMessages / armProjectsShowForTest to
testing.TB so the benchmark reuses them.
renderProjectsFrame replaces the snapshot rasterizer: bar modes go through
renderWindow, line mode re-issues the steady full-canvas buildLineChart +
setX re-apply, both at the lever-derived animated chartHeight. The arm no
longer repaints frame 0 (the current steady frame IS frame 0), which is
half of the endpoint-identity guarantee. Bench and mid-slide View test
follow the rename (they called the deleted renderProjectsAnimFrame).
…416)

View composes the box through the steady renderProjectsBox at
projectsHeight() every frame — real borders, title, and cells from the
first frames. projectsBandRows/projectsTopBorder and their tests die.

renderProjectsBox gains exact-height degenerate frames for the heights
only the slide passes through (1: top border, 2: closed shell, 3: shell
around the title row) — its old innerH/bodyRows floors emitted 3-4 rows
for any height under 4, which would have broken View's per-frame height
conservation; the spec's borders-only intent, pinned by
TestRenderProjectsBox_DegenerateHeights.
The springKindProjects overlay case now reads live unit/peak/zoom — the
exact inputs the steady cases read — instead of the arm-time snapshot,
removing the last reader of projectsSnap's chart fields. The label-
stability test pins the steady x-label row appearing verbatim (bytes,
incl. ANSI color) in every sampled mid-slide frame.
…eight (#416)

projectsAnimSnapshot dies — frames render from live state, so the only
slide state left is the spring + projectsSlideFrom/To endpoints. A second
'p' mid-slide reverses from the current animated height (no snap to an
extreme); u/z aborts keep the refreshChart hard-cut. lerpInt now clamps r
to [0,1] (round-one finding ccpulse-416.5: the spring does not guarantee
it). The 'p' handler snaps on an empty/cleared chart (lastCanvasW==0),
mirroring renderWindow's own no-op guard. armProjectsShowForTest now
delegates to the production arm path.
The implementation landed with renderProjectsFrame (Task 1); this pins it:
frame 0 and the settle frame in remaining mode are byte-identical to the
steady line views. seedRemainingModelWithSamples widens to testing.TB so
the reworked benchmark can reuse it.
Both modes land well inside the 16.7ms 60fps budget — the line mode is
nowhere near the ~12ms gate, so the 30fps escape hatch stays unused.

goos: darwin / goarch: arm64 / cpu: Apple M1 Max
                          │          sec/op          │
ProjectsAnimFrame/bar-10                 3.753m ± 1%
ProjectsAnimFrame/line-10                2.500m ± 3%

                          │           B/op           │
ProjectsAnimFrame/bar-10                3.941Mi ± 0%
ProjectsAnimFrame/line-10               3.822Mi ± 0%

                          │        allocs/op         │
ProjectsAnimFrame/bar-10                 14.35k ± 0%
ProjectsAnimFrame/line-10                2.333k ± 0%
The empty-chart guard pushed handleKey over the gocyclo gate; the
projects branch moves into its own handler, mirroring handleZoomKey /
handleUnitKey.
The per-tick line frame rebuilt the full-history canvas
(EarliestMessageTime -> now): ~41ms / 68.9MB / 68k allocs per frame at a
30-day 15m-zoom canvas (2880 cols) - 2.5x the 16.7ms 60fps budget. Mirror
renderSpringLineFrame's #180 windowing (visibleWindow + slicePointsInRange
at viewport width, SetXOffset(0) keeping the viewportXOffset shadow for the
settle restore); pixel-identical inside the visible region, endpoint frames
untouched (frame 0 never repaints, settle repaints via refreshChart's full
canvas).

The line bench fixture (60 samples, zero messages) collapsed the canvas to
viewport width, so the >=12ms/op 30fps gate could never fire. Add a
line_30d sub-bench seeded with 2880 buckets of message history and a
lastCanvasW guard so the gate measures the realistic worst case.

BenchmarkProjectsAnimFrame (Apple M1 Max, count=2):
  bar       3.80ms 3.76ms   4.13MB/op  14.3k allocs/op
  line      2.65ms 2.53ms   4.00MB/op  2318 allocs/op
  line_30d  2.48ms 2.52ms   4.00MB/op  2317 allocs/op
The windowed per-frame line build synthesized x-labels for the visible
window only, dropping any label straddling the window's left edge (the
steady viewport shows its clipped tail) — labels popped out mid-slide
and back at settle. Build the row for the full canvas exactly as the
steady path does and cut the visible columns with the same ansi.Cut the
viewport applies to content. Pinned by the new remaining-mode sibling
of TestProjectsSlide_XLabelRowStable, which found the defect.
…ide (#416)

RefreshMsg, nowTick, and the u/z arm paths abort an in-flight projects
slide via refreshChart's spring-abort block, which snaps chartHeight()
back to the steady value — but viewport.Height kept the mid-slide value
renderProjectsFrame last wrote, so every View() painted the wrong number
of rows (51 on a 40-row terminal) until the next resize or 'p'. Re-sync
inside the abort block (no-op for unit/zoom aborts) and pin the
RefreshMsg and nowTick paths with siblings of the resize regression
test.
Brings in the content-aware projects box height (#420/#429) and the
usage-line projects window fix (#430/#431), reconciled with the #416
slide:

- projectsTargetHeight adopts the content-aware computation; the
  projectsHeight lever keeps returning the animated height mid-slide.
- beginProjectsAnimation queries aggs BEFORE reading the target: on a
  show the aggs are still nil from the hidden state, so reading first
  would arm the slide against the 4-row empty floor and jump to the
  real height at settle.
- handleProjectsTick's mid-spring reflow guard now also defers across
  the projects slide; its settle refreshChart performs the reflow.
- Slide tests updated for the content-aware target (single-project
  fixture target is 4, not 12): re-arm test drives to an observed
  mid-flight height instead of a fixed tick count; content-presence
  thresholds tightened to 3 (title) / 4 (top spender) rows so they
  stay non-vacuous at the smaller target.

Verified against the dev fixture: usage-line view + p now renders the
per-project rollup for the visible window (the #430 symptom).
@martinciu martinciu merged commit a087e04 into main Jun 12, 2026
7 checks passed
@martinciu martinciu deleted the 416-animate-projects-box branch June 12, 2026 07:36
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.

🎬 Animate the projects box: slide up on show, slide down on hide

1 participant