From 962ed4844c4b5ff33f36660bd81146e7c8848c9d Mon Sep 17 00:00:00 2001 From: martinciu Date: Tue, 9 Jun 2026 22:10:03 +0200 Subject: [PATCH 01/17] tui: scaffold projects-slide state + height lever (#416) --- pkg/tui/model.go | 11 +++++++ pkg/tui/projectsspring.go | 47 +++++++++++++++++++++++++++++ pkg/tui/projectsspring_test.go | 54 ++++++++++++++++++++++++++++++++++ pkg/tui/viewport.go | 18 +++++++++--- 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 pkg/tui/projectsspring.go create mode 100644 pkg/tui/projectsspring_test.go diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 0c3cd7d..7efbc17 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -136,6 +136,7 @@ const ( springKindNone springKind = iota springKindUnit springKindZoom + springKindProjects ) // Model is the root Bubble Tea model for the chart view. @@ -268,6 +269,16 @@ type Model struct { zoomSpringR float64 zoomSpringVel float64 zoomSnap zoomAnimSnapshot + // Projects-box slide (#416). Single-phase spring on a 0→1 ratio driving + // projectsAnimH (the animated OUTER box height). projectsSnap freezes + // everything the per-frame tick reads so no frame touches the DB. Mutually + // exclusive with the unit/zoom springs via the shared springActive flag + + // springKind tag. + projectsSpring harmonica.Spring + projectsSpringR float64 + projectsSpringVel float64 + projectsAnimH int + projectsSnap projectsAnimSnapshot // nowGen is bumped each time the live-advance tick is re-armed (zoom // change). scheduleNowTick captures the current value into the scheduled // nowTickMsg; the handler drops ticks whose gen doesn't match, so a zoom diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go new file mode 100644 index 0000000..0376e0a --- /dev/null +++ b/pkg/tui/projectsspring.go @@ -0,0 +1,47 @@ +// Package tui — projects-box slide animation (issue #416). +// +// Sibling of the zoom-transition machine in zoomspring.go: it reuses the master +// springActive flag and the shared springGen counter (the unit/zoom/projects +// animations are mutually exclusive — refreshChart aborts any in-flight one) and +// is disambiguated by Model.springKind == springKindProjects. +// +// The crux (see spec): the CHART rescales (re-rasterized at the interpolated +// height each frame, bars shrink/grow into fewer/more rows) while the BOX slices +// (rendered once at target height at arm, bottom rows revealed each frame with a +// phantom top border). Both read only in-memory snapshot state — no DB, no +// ntcharts rebuild, per frame. +package tui + +import ( + "math" + "time" + + "github.com/martinciu/ccpulse/pkg/cache" +) + +// projectsAnimSnapshot captures, once at arm time, everything the per-frame +// slide tick needs so nothing it reads can shift mid-animation. +type projectsAnimSnapshot struct { + boxRows []string // box pre-rendered at the steady target height, split into rows (the SLICE source) + startH int // slide start outer height (show: 0, hide: target) + targetH int // slide end outer height (show: target, hide: 0) + + // Chart inputs — frozen so the per-frame re-rasterize/re-build reads no DB. + values []float64 + starts []time.Time + peak float64 + pts5h []cache.UtilizationPoint + pts7d []cache.UtilizationPoint + unit chartUnit + isLine bool + vpWidth int + zoom ZoomLevel + viewFrom time.Time // line mode: stable visible window (no horizontal squeeze in this slide) + viewTo time.Time +} + +// lerpInt linearly interpolates between integer heights a and b at parameter r, +// rounding to the nearest row. r is clamped to [0,1] by the caller's spring. +func lerpInt(a, b int, r float64) int { + return int(math.Round(float64(a) + (float64(b)-float64(a))*r)) +} diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go new file mode 100644 index 0000000..a4a7a0a --- /dev/null +++ b/pkg/tui/projectsspring_test.go @@ -0,0 +1,54 @@ +package tui + +import ( + "testing" + "time" +) + +func TestLerpInt(t *testing.T) { + cases := []struct { + a, b int + r float64 + want int + }{ + {0, 12, 0, 0}, + {0, 12, 1, 12}, + {0, 12, 0.5, 6}, + {12, 0, 0.5, 6}, + {12, 0, 1, 0}, + {0, 10, 0.24, 2}, // 2.4 rounds to 2 + {0, 10, 0.25, 3}, // 2.5 rounds to 3 (math.Round) + } + for _, c := range cases { + if got := lerpInt(c.a, c.b, c.r); got != c.want { + t.Errorf("lerpInt(%d,%d,%g)=%d, want %d", c.a, c.b, c.r, got, c.want) + } + } +} + +func TestProjectsHeight_SpringBranchOverridesTarget(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + + // Steady target (122x40 → m.h-7=33 → min(16,12)=12). + m.showProjects = true + if got, want := m.projectsHeight(), m.projectsTargetHeight(); got != want { + t.Fatalf("steady projectsHeight()=%d, want projectsTargetHeight()=%d", got, want) + } + if m.projectsTargetHeight() != 12 { + t.Fatalf("projectsTargetHeight()=%d, want 12 at 122x40", m.projectsTargetHeight()) + } + + // Spring branch: returns projectsAnimH regardless of showProjects. + m.springActive = true + m.springKind = springKindProjects + m.projectsAnimH = 7 + if got := m.projectsHeight(); got != 7 { + t.Errorf("in-slide projectsHeight()=%d, want 7 (animated)", got) + } + m.showProjects = false + if got := m.projectsHeight(); got != 7 { + t.Errorf("in-slide projectsHeight() with showProjects=false=%d, want 7", got) + } +} diff --git a/pkg/tui/viewport.go b/pkg/tui/viewport.go index 2eb01ab..f4889fb 100644 --- a/pkg/tui/viewport.go +++ b/pkg/tui/viewport.go @@ -195,14 +195,24 @@ func (m Model) visibleBuckets() int { const projectsMaxRows = 12 // projectsHeight returns the OUTER height (incl. border) reserved for the -// projects box: roughly half the post-header area, capped, and 0 when the -// terminal is too short to host both the chart's 5-row floor and a usable -// box. Deliberately independent of the project count so chartHeight stays -// stable across scroll/zoom (no circular dependency). +// projects box. While a slide is in flight it returns the animated value so the +// chart cedes/reclaims rows in lockstep (#416); otherwise 0 when hidden, else +// the steady target. func (m Model) projectsHeight() int { + if m.springActive && m.springKind == springKindProjects { + return m.projectsAnimH + } if !m.showProjects { return 0 } + return m.projectsTargetHeight() +} + +// projectsTargetHeight is the steady-state outer box height: roughly half the +// post-header area, capped, and 0 when the terminal is too short to host both +// the chart's 5-row floor and a usable box. Independent of project count so +// chartHeight stays stable across scroll/zoom (no circular dependency). +func (m Model) projectsTargetHeight() int { avail := m.h - 7 // shared by chart + projects box (same overhead as chartHeight) // Need the chart's 5-row floor + a minimum 4-row box (border+title+1 row). if avail < 5+4 { From 31cf8b305b96635633e4a614c7a167f8b6302705 Mon Sep 17 00:00:00 2001 From: martinciu Date: Tue, 9 Jun 2026 22:11:51 +0200 Subject: [PATCH 02/17] tui: per-frame projects-slide render (band slice + chart rescale) (#416) --- pkg/tui/model.go | 21 ++++++- pkg/tui/projectsspring.go | 62 ++++++++++++++++++++ pkg/tui/projectsspring_test.go | 103 +++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 7efbc17..7e0f229 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -729,8 +729,15 @@ func (m Model) View() string { // help overlay is up (help replaces the chart body, so the box would be // out of place). if !m.showHelp { - if ph := m.projectsHeight(); ph > 0 { - parts = append(parts, renderProjectsBox(m.projectAggs, m.w, ph)) + switch { + case m.springActive && m.springKind == springKindProjects: + if band := projectsBandRows(m.projectsSnap.boxRows, m.w, m.projectsAnimH); len(band) > 0 { + parts = append(parts, strings.Join(band, "\n")) + } + default: + if ph := m.projectsHeight(); ph > 0 { + parts = append(parts, renderProjectsBox(m.projectAggs, m.w, ph)) + } } } parts = append(parts, sep, footer) @@ -768,6 +775,16 @@ func (m Model) renderChartBody(rawBody string) string { return overlayYTicks(rawBody, m.chartHeight(), 1.0) } return barZoomYLabel(rawBody, m.zoomSnap, ZoomLevels[m.zoomIdx], m.chartHeight(), m.zoomSpringR) + case m.springActive && m.springKind == springKindProjects: + // The chart isn't morphing data, only height — render the steady-state + // y-axis at the frame's (animated) chartHeight, from frozen snapshot + // unit/peak/zoom. MUST precede the generic springActive case, which reads + // m.springRatios (the unit-toggle state, unset here). + if m.projectsSnap.isLine { + return overlayYTicks(rawBody, m.chartHeight(), 1.0) + } + return overlayYLabel(rawBody, m.projectsSnap.peak, m.projectsSnap.unit, + m.chartHeight(), 1.0, m.projectsSnap.zoom.hasInBarNumbers()) case m.springActive: var maxR float64 for _, r := range m.springRatios { diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index 0376e0a..7838485 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -14,8 +14,11 @@ package tui import ( "math" + "strings" "time" + "github.com/charmbracelet/lipgloss" + "github.com/martinciu/ccpulse/pkg/cache" ) @@ -45,3 +48,62 @@ type projectsAnimSnapshot struct { func lerpInt(a, b int, r float64) int { return int(math.Round(float64(a) + (float64(b)-float64(a))*r)) } + +// projectsTopBorder renders a phantom rounded top-border line `width` cols wide, +// matching renderProjectsBox's RoundedBorder + colorMuted, so the sliced box +// reads as a complete bordered box at the cut throughout the slide. +func projectsTopBorder(width int) string { + b := lipgloss.RoundedBorder() + inner := max(width-2, 0) + line := b.TopLeft + strings.Repeat(b.Top, inner) + b.TopRight + return lipgloss.NewStyle().Foreground(colorMuted).Render(line) +} + +// projectsBandRows returns the bottom `animH` rows of the once-rendered box +// `rows`, with the topmost visible row replaced by a phantom top border. animH<=0 +// → nil (nothing to show); animH>=len → the full box verbatim (settle frame). +func projectsBandRows(rows []string, width, animH int) []string { + if animH <= 0 || len(rows) == 0 { + return nil + } + if animH >= len(rows) { + return rows + } + band := make([]string, 0, animH) + band = append(band, projectsTopBorder(width)) + band = append(band, rows[len(rows)-(animH-1):]...) // bottom (animH-1) rows incl. real bottom border + return band +} + +// renderProjectsAnimFrame rebuilds the viewport chart body at the frame's +// (animated) height from in-memory snapshot state — bar mode re-rasterizes the +// skyline so bars rescale into the new row count; line mode re-builds the +// windowed line chart. No DB, no ntcharts rebuild beyond the windowed line +// canvas. The box band is composed separately by View. +func (m *Model) renderProjectsAnimFrame() { + chartH := m.chartHeight() // derives from projectsAnimH via the projectsHeight lever + m.viewport.Height = chartH + snap := m.projectsSnap + + if snap.isLine { + body := buildLineChart(snap.pts5h, snap.pts7d, snap.viewFrom, snap.viewTo, + snap.vpWidth, chartH, m.now(), snap.zoom, m.dateOrder, "projects", "") + m.viewport.SetContent(body) + m.viewport.SetXOffset(0) + return + } + + barsH := chartH + if chartH >= 6 { + barsH = chartH - 1 + } + sky := rasterizeSkyline(snap.values, snap.starts, snap.peak, snap.vpWidth, barsH, snap.zoom) + body := drawSkyline(sky, barsH, snap.unit) + if chartH >= 6 { + labelRow := buildXLabelsRow(synthLabelStarts(snap.viewFrom, snap.viewTo, snap.zoom), + snap.vpWidth, snap.zoom, m.now(), m.dateOrder) + body = lipgloss.JoinVertical(lipgloss.Left, body, labelRow) + } + m.viewport.SetContent(body) + m.viewport.SetXOffset(0) +} diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index a4a7a0a..6152da5 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -1,8 +1,11 @@ package tui import ( + "strings" "testing" "time" + + "github.com/charmbracelet/lipgloss" ) func TestLerpInt(t *testing.T) { @@ -52,3 +55,103 @@ func TestProjectsHeight_SpringBranchOverridesTarget(t *testing.T) { t.Errorf("in-slide projectsHeight() with showProjects=false=%d, want 7", got) } } + +func TestProjectsTopBorder_WidthAndCorners(t *testing.T) { + b := projectsTopBorder(80) + if w := lipgloss.Width(b); w != 80 { + t.Errorf("projectsTopBorder width=%d, want 80", w) + } + // Rounded border corners (strip styling via lipgloss.Width-agnostic contains). + if !strings.Contains(b, "╭") || !strings.Contains(b, "╮") { + t.Errorf("projectsTopBorder missing rounded corners: %q", b) + } +} + +func TestProjectsBandRows_RevealsBottomWithPhantomTop(t *testing.T) { + rows := []string{"TOPBORDER", "title", "r1", "r2", "BOTBORDER"} // 5-row box + + // animH=0 → no band. + if got := projectsBandRows(rows, 40, 0); got != nil { + t.Errorf("animH=0 band=%v, want nil", got) + } + // animH=1 → just the phantom top border. + band := projectsBandRows(rows, 40, 1) + if len(band) != 1 || !strings.Contains(band[0], "╭") { + t.Errorf("animH=1 band=%v, want [phantom-top]", band) + } + // animH=3 → phantom top + bottom 2 rows (r2, BOTBORDER). + band = projectsBandRows(rows, 40, 3) + if len(band) != 3 { + t.Fatalf("animH=3 len(band)=%d, want 3", len(band)) + } + if !strings.Contains(band[0], "╭") || band[1] != "r2" || band[2] != "BOTBORDER" { + t.Errorf("animH=3 band=%v, want [phantom, r2, BOTBORDER]", band) + } + // animH>=len → full box verbatim (settle frame). + band = projectsBandRows(rows, 40, 5) + if len(band) != 5 || band[0] != "TOPBORDER" { + t.Errorf("animH=5 band=%v, want full rows", band) + } +} + +func TestRenderProjectsAnimFrame_SetsViewportHeightToChartHeight(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + + // Hand-arm a mid-slide state (show, animH=5 of target 12). + m.showProjects = true + m.refreshProjects() + m.projectsSnap = projectsAnimSnapshot{ + boxRows: strings.Split(renderProjectsBox(m.projectAggs, m.w, 12), "\n"), + startH: 0, targetH: 12, + values: m.lastValues, starts: m.lastStarts, peak: m.peak, + unit: chartUnitCost, isLine: false, vpWidth: m.viewport.Width, + zoom: ZoomLevels[m.zoomIdx], viewFrom: m.lastChartFrom, viewTo: m.lastChartTo, + } + m.springActive = true + m.springKind = springKindProjects + m.projectsAnimH = 5 + + m.renderProjectsAnimFrame() + + wantChartH := m.h - 7 - 5 // 33-5 = 28 + if m.viewport.Height != wantChartH { + t.Errorf("viewport.Height=%d, want chartHeight=%d during slide", m.viewport.Height, wantChartH) + } + if m.chartHeight() != wantChartH { + t.Errorf("chartHeight()=%d, want %d", m.chartHeight(), wantChartH) + } + if m.viewport.View() == "" { + t.Error("viewport content empty after renderProjectsAnimFrame") + } +} + +func TestView_DuringSlide_HeightConservedAndPhantomBorder(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = true + m.refreshProjects() + m.projectsSnap = projectsAnimSnapshot{ + boxRows: strings.Split(renderProjectsBox(m.projectAggs, m.w, 12), "\n"), + startH: 0, targetH: 12, + values: m.lastValues, starts: m.lastStarts, peak: m.peak, + unit: chartUnitCost, isLine: false, vpWidth: m.viewport.Width, + zoom: ZoomLevels[m.zoomIdx], viewFrom: m.lastChartFrom, viewTo: m.lastChartTo, + } + m.springActive = true + m.springKind = springKindProjects + m.projectsAnimH = 5 + m.renderProjectsAnimFrame() + + frame := m.View() + if got := lipgloss.Height(frame); got != m.h { + t.Errorf("View height=%d, want %d (conserved every frame)", got, m.h) + } + if !strings.Contains(frame, "╭") { + t.Error("mid-slide frame missing phantom top border") + } +} From c3f4ffbf5a80c4f1c5c5cf8a18f5e05d2deec05f Mon Sep 17 00:00:00 2001 From: martinciu Date: Tue, 9 Jun 2026 22:13:08 +0200 Subject: [PATCH 03/17] tui: projects-slide tick handler + dispatch (#416) --- pkg/tui/projectsspring.go | 27 +++++++++++ pkg/tui/projectsspring_test.go | 89 ++++++++++++++++++++++++++++++++++ pkg/tui/springs.go | 3 ++ 3 files changed, 119 insertions(+) diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index 7838485..363fe2d 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -17,6 +17,7 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/martinciu/ccpulse/pkg/cache" @@ -107,3 +108,29 @@ func (m *Model) renderProjectsAnimFrame() { m.viewport.SetContent(body) m.viewport.SetXOffset(0) } + +// handleProjectsSpringTick advances one frame of the box slide: step the spring +// toward r=1, lerp the outer box height startH→targetH, re-render the frame, and +// settle when within phaseTransitionThreshold. On settle it commits the height, +// clears the spring, and restores steady state via refreshChart (which chains +// refreshProjects — the 1 settle query on show; a no-op on hide since +// showProjects was committed to false at arm). Returns nil to stop the loop. +func (m *Model) handleProjectsSpringTick(gen int) tea.Cmd { + r, vel := m.projectsSpring.Update(m.projectsSpringR, m.projectsSpringVel, 1.0) + m.projectsSpringR, m.projectsSpringVel = r, vel + m.projectsAnimH = lerpInt(m.projectsSnap.startH, m.projectsSnap.targetH, r) + + if math.Abs(1.0-r) < phaseTransitionThreshold { + m.projectsAnimH = m.projectsSnap.targetH + m.springActive = false + m.springKind = springKindNone + m.viewport.Height = m.chartHeight() + m.refreshChart() // steady-state restore (chart + chained refreshProjects) + return nil // stop the loop — idle TUI is zero-animation-cost + } + + m.renderProjectsAnimFrame() + return tea.Tick(time.Second/time.Duration(springFPS), func(time.Time) tea.Msg { + return springTickMsg{gen: gen} + }) +} diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index 6152da5..57b72db 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" ) @@ -155,3 +157,90 @@ func TestView_DuringSlide_HeightConservedAndPhantomBorder(t *testing.T) { t.Error("mid-slide frame missing phantom top border") } } + +// armProjectsShowForTest hand-builds a fully-armed SHOW slide (no key handler, +// so this task is testable before Task 4). Mirrors what beginProjectsAnimation +// will set up. +func armProjectsShowForTest(t *testing.T, m *Model) { + t.Helper() + m.showProjects = true + m.refreshProjects() + target := m.projectsTargetHeight() + m.projectsSnap = projectsAnimSnapshot{ + boxRows: strings.Split(renderProjectsBox(m.projectAggs, m.w, target), "\n"), + startH: 0, targetH: target, + values: m.lastValues, starts: m.lastStarts, peak: m.peak, + unit: chartUnitCost, isLine: false, vpWidth: m.viewport.Width, + zoom: ZoomLevels[m.zoomIdx], viewFrom: m.lastChartFrom, viewTo: m.lastChartTo, + } + m.projectsSpring = harmonica.NewSpring(harmonica.FPS(springFPS), phase2Frequency, phase2Damping) + m.projectsSpringR, m.projectsSpringVel = 0, 0 + m.projectsAnimH = 0 + m.springActive = true + m.springKind = springKindProjects + m.springGen++ +} + +func TestProjectsSpringTick_AdvancesThenSettles(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + armProjectsShowForTest(t, &m) + target := m.projectsSnap.targetH + + // One tick: ratio moves off 0, animH advances toward target. + updated, cmd := m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + if m.projectsSpringR <= 0 { + t.Errorf("after one tick: projectsSpringR=%g, want >0", m.projectsSpringR) + } + if cmd == nil { + t.Error("mid-slide tick returned nil cmd, want next tick scheduled") + } + if m.projectsAnimH < 0 || m.projectsAnimH > target { + t.Errorf("projectsAnimH=%d out of [0,%d]", m.projectsAnimH, target) + } + + // Drive to settle (never invoke the tick Cmd — it real-sleeps; construct msgs). + const maxTicks = 600 + var lastCmd tea.Cmd + for i := 0; i < maxTicks && m.springActive; i++ { + updated, lastCmd = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if m.springActive { + t.Fatalf("projects slide did not settle within %d ticks", maxTicks) + } + if m.springKind != springKindNone { + t.Errorf("after settle: springKind=%d, want springKindNone", m.springKind) + } + if lastCmd != nil { + t.Errorf("settle: cmd=%v, want nil (loop stops — idle TUI zero-cost)", lastCmd) + } + if m.projectsAnimH != target { + t.Errorf("after settle: projectsAnimH=%d, want target %d", m.projectsAnimH, target) + } + if !m.showProjects { + t.Error("after show settle: showProjects=false, want true (committed)") + } + if m.viewport.Height != m.chartHeight() { + t.Errorf("after settle: viewport.Height=%d, want chartHeight=%d", m.viewport.Height, m.chartHeight()) + } +} + +func TestProjectsSpringTick_StaleGenDropped(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + armProjectsShowForTest(t, &m) + + updated, cmd := m.Update(springTickMsg{gen: m.springGen - 1}) // superseded + m = updated.(Model) + if cmd != nil { + t.Errorf("stale-gen tick: cmd=%v, want nil (dropped)", cmd) + } + if !m.springActive { + t.Error("stale-gen tick must not settle the live animation") + } +} diff --git a/pkg/tui/springs.go b/pkg/tui/springs.go index 62e9a5b..df12a8f 100644 --- a/pkg/tui/springs.go +++ b/pkg/tui/springs.go @@ -35,6 +35,9 @@ func (m *Model) handleSpringTick(msg springTickMsg) tea.Cmd { if m.springKind == springKindZoom { return m.handleZoomSpringTick(m.springGen) } + if m.springKind == springKindProjects { + return m.handleProjectsSpringTick(m.springGen) + } gen := m.springGen switch m.springPhase { case springShrinking: From 27048b30d2c446d6758dbd149c695140e3807fa3 Mon Sep 17 00:00:00 2001 From: martinciu Date: Tue, 9 Jun 2026 22:17:39 +0200 Subject: [PATCH 04/17] tui: arm projects slide on 'p' + coexistence with u/z (#416) --- pkg/tui/model.go | 27 ++++-- pkg/tui/model_test.go | 12 +++ pkg/tui/projectsspring.go | 47 +++++++++ pkg/tui/projectsspring_test.go | 172 +++++++++++++++++++++++++++++++++ pkg/tui/series.go | 4 + 5 files changed, 252 insertions(+), 10 deletions(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 7e0f229..8b79cfa 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -652,16 +652,23 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case key.Matches(msg, m.keys.Unit): return m.handleUnitKey() case key.Matches(msg, m.keys.Projects): - // Hard layout cut (not a spring): toggling the box changes - // chartHeight, so resize the viewport widget and rebuild content - // at the new height — the same subset of handleWindowSize that - // matters when only the chart's available height changes. - // refreshChart chains refreshProjects, so an on-show requery for - // the current window falls out for free. - m.showProjects = !m.showProjects - m.viewport.Height = m.chartHeight() - m.refreshChart() - return nil + // Slide the box up (show) / down (hide) via a harmonica spring (#416). + // reduce_motion or a too-short terminal (no room for a box) → snap, the + // pre-#416 hard cut. + if m.deps.ReduceMotion || m.projectsTargetHeight() == 0 { + m.showProjects = !m.showProjects + m.viewport.Height = m.chartHeight() + m.refreshChart() + return nil + } + m.beginProjectsAnimation() + if !m.springActive { + return nil + } + gen := m.springGen + return tea.Tick(time.Second/time.Duration(springFPS), func(time.Time) tea.Msg { + return springTickMsg{gen: gen} + }) case key.Matches(msg, m.keys.ScrollLeft): m.scrollLeft(ZoomLevels[m.zoomIdx].ScrollStep) return m.scheduleProjectsTick() diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go index 33d2c5c..4a12bbe 100644 --- a/pkg/tui/model_test.go +++ b/pkg/tui/model_test.go @@ -5565,6 +5565,10 @@ func TestProjectsHeight_HiddenWhenToggledOff(t *testing.T) { func TestProjectsToggle_Key(t *testing.T) { m, cleanup := seedScrollTestModel(t, 200) defer cleanup() + // #416 added the slide animation on the 'p' key; this test asserts the + // pre-#416 synchronous hard-cut contract (chartHeight/viewport resize land + // immediately), which now lives on the reduce-motion snap path. + m.deps.ReduceMotion = true full := m.h - 7 // chartHeight when the box is hidden @@ -5602,6 +5606,10 @@ func TestProjectsToggle_Key(t *testing.T) { func TestProjectsToggle_AddsBoxToFrame(t *testing.T) { m, cleanup := seedScrollTestModel(t, 200) defer cleanup() + // Snap path (#416): the animated path reveals the box over the slide, so + // the full box title only appears at settle. This test asserts the + // immediate-appearance snap contract. + m.deps.ReduceMotion = true // Box is absent by default. if strings.Contains(m.View(), projectsTitle) { @@ -5629,6 +5637,10 @@ func TestProjectsToggle_InertUnderHelp(t *testing.T) { func TestProjectsToggle_ClearsAndRequeries(t *testing.T) { m, cleanup := seedScrollTestModel(t, 200) defer cleanup() + // Snap path (#416): on the animated hide path projectAggs is retained for + // the slide-down and only cleared at settle. This test asserts the + // immediate clear-on-hide snap contract. + m.deps.ReduceMotion = true // Show → requeried for the visible window. m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index 363fe2d..494a065 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -18,6 +18,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" "github.com/martinciu/ccpulse/pkg/cache" @@ -134,3 +135,49 @@ func (m *Model) handleProjectsSpringTick(gen int) tea.Cmd { return springTickMsg{gen: gen} }) } + +// beginProjectsAnimation arms the box slide. Aborts any in-flight u/z FIRST (only +// when one is running — calling refreshChart unconditionally would fire a wasted +// refreshProjects query on the hide path). Commits showProjects to its terminal +// value at arm so a later u/z abort reads the correct chartHeight with no extra +// wiring (see series.go abort block). Snapshots the box (one requery on show; the +// in-memory aggs on hide) and the chart inputs, seeds the spring, paints frame 0. +func (m *Model) beginProjectsAnimation() { + if m.springActive { + m.refreshChart() // abort in-flight u/z; restore steady chart inputs to snapshot + } + + show := !m.showProjects + m.showProjects = show // committed terminal state (the invariant that makes abort free) + + target := m.projectsTargetHeight() + if show { + m.refreshProjects() // THE one arm-time query: box was hidden (#414) → repopulate + m.projectsSnap.startH, m.projectsSnap.targetH = 0, target + } else { + // projectAggs already populated (box was showing) — no query. + m.projectsSnap.startH, m.projectsSnap.targetH = target, 0 + } + + m.projectsSnap.boxRows = strings.Split(renderProjectsBox(m.projectAggs, m.w, target), "\n") + m.projectsSnap.values = m.lastValues + m.projectsSnap.starts = m.lastStarts + m.projectsSnap.peak = m.peak + m.projectsSnap.pts5h = m.lastPts5h + m.projectsSnap.pts7d = m.lastPts7d + m.projectsSnap.unit = chartUnit(m.unitIdx) + m.projectsSnap.isLine = isLineMode(chartUnit(m.unitIdx)) + m.projectsSnap.vpWidth = m.viewport.Width + m.projectsSnap.zoom = ZoomLevels[m.zoomIdx] + m.projectsSnap.viewFrom, m.projectsSnap.viewTo = m.visibleWindow() + + m.projectsSpring = harmonica.NewSpring(harmonica.FPS(springFPS), phase2Frequency, phase2Damping) + m.projectsSpringR, m.projectsSpringVel = 0, 0 + m.projectsAnimH = m.projectsSnap.startH + m.springActive = true + m.springKind = springKindProjects + m.springGen++ + + // Paint frame 0 synchronously so the next View doesn't flash the target layout. + m.renderProjectsAnimFrame() +} diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index 57b72db..c23bded 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -1,6 +1,7 @@ package tui import ( + "reflect" "strings" "testing" "time" @@ -8,6 +9,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" + + "github.com/martinciu/ccpulse/pkg/cache" ) func TestLerpInt(t *testing.T) { @@ -244,3 +247,172 @@ func TestProjectsSpringTick_StaleGenDropped(t *testing.T) { t.Error("stale-gen tick must not settle the live animation") } } + +func TestProjectsKey_ShowFromIdle_ArmsAndQueriesOnce(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false // hidden by default (#414) → first 'p' is a show + m.refreshChart() // ensure steady chart inputs present; projectAggs stays nil (hidden) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("show 'p': springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + if !m.showProjects { + t.Error("show 'p': showProjects=false, want true (committed at arm)") + } + if m.projectsSnap.startH != 0 || m.projectsSnap.targetH != m.projectsTargetHeight() { + t.Errorf("show snap heights = (%d,%d), want (0,%d)", m.projectsSnap.startH, m.projectsSnap.targetH, m.projectsTargetHeight()) + } + if len(m.projectsSnap.boxRows) == 0 { + t.Error("show 'p': boxRows not snapshotted (arm requery missing)") + } + if cmd == nil { + t.Error("show 'p': cmd=nil, want first tick scheduled") + } + + // Zero-DB-per-frame contract: the arm query repopulated projectAggs once; + // driving mid-flight ticks must NOT reissue ProjectAggregates (the slice's + // backing array is untouched), and settle reissues exactly once (new array). + armPtr := projectAggsBackingPtr(m.projectAggs) + for range 3 { // safely mid-flight (critically-damped spring needs ~15+ ticks) + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("3 ticks settled the slide unexpectedly; can't probe mid-flight") + } + if projectAggsBackingPtr(m.projectAggs) != armPtr { + t.Error("projectAggs reassigned mid-slide → a per-tick refreshProjects ran (want zero DB per frame)") + } +} + +func TestProjectsKey_HideFromIdle_NoArmQuery(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = true + m.refreshChart() // box shown → projectAggs populated + if len(m.projectAggs) == 0 { + t.Fatal("seed: projectAggs empty, want populated before hide") + } + beforePtr := projectAggsBackingPtr(m.projectAggs) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("hide 'p': springActive=%v springKind=%d", m.springActive, m.springKind) + } + if m.showProjects { + t.Error("hide 'p': showProjects=true, want false (committed at arm)") + } + if m.projectsSnap.startH != m.projectsTargetHeight() || m.projectsSnap.targetH != 0 { + t.Errorf("hide snap heights=(%d,%d), want (%d,0)", m.projectsSnap.startH, m.projectsSnap.targetH, m.projectsTargetHeight()) + } + // No arm requery on hide: the snapshot reused the already-populated aggs. + if projectAggsBackingPtr(m.projectAggs) != beforePtr { + t.Error("hide 'p' reissued ProjectAggregates at arm, want 0 queries (reuse in-memory aggs)") + } +} + +func TestProjectsKey_ReduceMotion_Snaps(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.deps.ReduceMotion = true + m.showProjects = false + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if m.springActive { + t.Error("reduce_motion 'p': springActive=true, want snap") + } + if !m.showProjects { + t.Error("reduce_motion 'p': showProjects=false, want toggled on") + } + if cmd != nil { + t.Errorf("reduce_motion 'p': cmd=%v, want nil (synchronous cut)", cmd) + } + if m.viewport.Height != m.chartHeight() { + t.Errorf("reduce_motion 'p': viewport.Height=%d, want chartHeight=%d", m.viewport.Height, m.chartHeight()) + } +} + +func TestProjectsKey_TooShort_Snaps(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.h = 12 // m.h-7=5 < 9 → projectsTargetHeight()==0 + m.viewport.Height = m.chartHeight() + m.showProjects = false + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if m.springActive { + t.Error("too-short 'p': springActive=true, want snap") + } + if cmd != nil { + t.Errorf("too-short 'p': cmd=%v, want nil", cmd) + } +} + +func TestProjectsKey_AbortsInflightZoom(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + + // Arm a zoom, then press 'p' mid-flight. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) + m = updated.(Model) + if m.springKind != springKindZoom { + t.Fatalf("setup: springKind=%d, want zoom", m.springKind) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if m.springKind != springKindProjects || !m.springActive { + t.Errorf("'p' during zoom: springKind=%d active=%v, want projects/true (zoom aborted, slide armed)", m.springKind, m.springActive) + } +} + +func TestZoomKey_AbortsInflightProjectsSlide(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) // arm show slide + m = updated.(Model) + if m.springKind != springKindProjects { + t.Fatalf("setup: springKind=%d, want projects", m.springKind) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) + m = updated.(Model) + + if m.springKind != springKindZoom || !m.springActive { + t.Errorf("'z' during slide: springKind=%d active=%v, want zoom/true", m.springKind, m.springActive) + } + if !m.showProjects { + t.Error("'z' during show-slide: showProjects=false, want true (slide's committed terminal state)") + } +} + +// projectAggsBackingPtr returns the backing-array address of a ProjectAggregate +// slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice +// from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the +// zero-DB-per-frame contract without a cache interface seam. +func projectAggsBackingPtr(a []cache.ProjectAggregate) uintptr { + if len(a) == 0 { + return 0 + } + return reflect.ValueOf(a).Pointer() +} diff --git a/pkg/tui/series.go b/pkg/tui/series.go index d0292d7..aeee9bb 100644 --- a/pkg/tui/series.go +++ b/pkg/tui/series.go @@ -141,6 +141,10 @@ func (m *Model) refreshChart() { // remain populated but unread — guarded by springActive=false. // Next beginUnitAnimation re-makes the slices. Zoom scalars // (zoomSpringR/Vel/zoomSnap) likewise stay set but unread (#373). + // The projects slide (#416) rides this same abort: springActive=false + + // springKind=springKindNone drops it; projectsAnimH/projectsSnap stay set + // but unread, and showProjects was already committed at arm so the chart + // rebuild below reads the correct chartHeight. } // Snapshot the wall-clock scroll anchor BEFORE the rebuild overwrites From c0b09842aca1bcd91d1ea2787c2c49879dfc35a6 Mon Sep 17 00:00:00 2001 From: martinciu Date: Tue, 9 Jun 2026 22:24:09 +0200 Subject: [PATCH 05/17] tui: real-binary + bench coverage for projects slide (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/tui/integration_test.go | 32 +++++++++++++ pkg/tui/model_nowtick_test.go | 2 +- pkg/tui/projectsspring_bench_test.go | 43 +++++++++++++++++ pkg/tui/projectsspring_test.go | 69 +++++++++++++++++++++++++++- pkg/tui/zoomspring_test.go | 2 +- 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 pkg/tui/projectsspring_bench_test.go diff --git a/pkg/tui/integration_test.go b/pkg/tui/integration_test.go index 1fea78a..668c0bc 100644 --- a/pkg/tui/integration_test.go +++ b/pkg/tui/integration_test.go @@ -468,3 +468,35 @@ func TestProgram_EmptyToFirstChart(t *testing.T) { t.Errorf("final frame missing footer text 'z zoom' — program may not have rendered:\n%s", final) } } + +// TestProgram_ProjectsSlide_NoDeadlock drives the slide through a real program +// loop (teatest PTY) to catch bubbletea ordering races the in-process Update +// tests can't: press 'p', wait for a frame showing the projects title, quit +// cleanly. Asserts showProjects committed via FinalModel. +func TestProgram_ProjectsSlide_NoDeadlock(t *testing.T) { + c := newSeededCache(t) + m := New(Deps{Cache: c}) + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) + t.Cleanup(func() { _ = tm.Quit() }) + + tm.Send(tea.WindowSizeMsg{Width: 120, Height: 40}) + tm.Send(RefreshMsg{}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) // show slide (hidden by default) + + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return bytes.Contains(bts, []byte("Projects")) }, + teatest.WithDuration(2*time.Second), + ) + + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) + + final := tm.FinalModel(t, teatest.WithFinalTimeout(1*time.Second)) + fm, ok := final.(Model) + if !ok { + t.Fatalf("FinalModel: got %T", final) + } + if !fm.showProjects { + t.Error("after show slide: showProjects=false, want true") + } +} diff --git a/pkg/tui/model_nowtick_test.go b/pkg/tui/model_nowtick_test.go index afbbf6f..7bd5be7 100644 --- a/pkg/tui/model_nowtick_test.go +++ b/pkg/tui/model_nowtick_test.go @@ -57,7 +57,7 @@ func TestNextBoundary_24hLocalMidnight(t *testing.T) { // unitIdx selects the chart unit (e.g. int(chartUnitTokens), int(chartUnitRemaining)); // the zoom is fixed at the 15m level (zoomIdx 0). // Mirrors seedBarModel but injects the #311 clock seam. -func seedModelAt(t *testing.T, unitIdx, nBuckets int, now time.Time) (Model, *cache.Cache) { +func seedModelAt(t testing.TB, unitIdx, nBuckets int, now time.Time) (Model, *cache.Cache) { t.Helper() c, err := cache.Open(t.Context(), filepath.Join(t.TempDir(), "state.db")) if err != nil { diff --git a/pkg/tui/projectsspring_bench_test.go b/pkg/tui/projectsspring_bench_test.go new file mode 100644 index 0000000..33d5da4 --- /dev/null +++ b/pkg/tui/projectsspring_bench_test.go @@ -0,0 +1,43 @@ +package tui + +import ( + "strconv" + "testing" + "time" +) + +// benchModelForProjects builds a bar model armed mid-slide at the given viewport +// width, so BenchmarkProjectsAnimFrame measures only the in-memory per-frame +// render (rasterize + drawSkyline + label row). The DB-touching setup +// (seedBarModelWithMessages → refreshChart, armProjectsShowForTest → +// refreshProjects) runs once here, outside b.Loop(). +func benchModelForProjects(b *testing.B, vpWidth int, now time.Time) Model { + b.Helper() + m, c := seedBarModelWithMessages(b, int(chartUnitCost), now) + b.Cleanup(func() { _ = c.Close() }) + // Override geometry to the bench width and rebuild the chart inputs against + // it before arming, so the snapshot's vpWidth matches. + m.w = vpWidth + 2 + m.viewport.Width = vpWidth + m.refreshChart() + armProjectsShowForTest(b, &m) + m.projectsAnimH = m.projectsSnap.targetH / 2 // mid-slide + return m +} + +// BenchmarkProjectsAnimFrame times one full per-frame slide render (rasterize + +// drawSkyline + label row + band slice) at representative chart widths and a +// mid-slide height, confirming the redraw stays within the 60fps (16.7ms) +// budget. Mirrors the BenchmarkBarChartRender width sweep from CLAUDE.md. +func BenchmarkProjectsAnimFrame(b *testing.B) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + for _, w := range []int{100, 1000, 5000} { + b.Run(strconv.Itoa(w), func(b *testing.B) { + m := benchModelForProjects(b, w, now) + for b.Loop() { + m.renderProjectsAnimFrame() + _ = projectsBandRows(m.projectsSnap.boxRows, m.w, m.projectsAnimH) + } + }) + } +} diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index c23bded..92f5ab9 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -62,6 +62,7 @@ func TestProjectsHeight_SpringBranchOverridesTarget(t *testing.T) { } func TestProjectsTopBorder_WidthAndCorners(t *testing.T) { + withForcedColor(t) // assert on the real painted ANSI, not stripped output b := projectsTopBorder(80) if w := lipgloss.Width(b); w != 80 { t.Errorf("projectsTopBorder width=%d, want 80", w) @@ -70,6 +71,18 @@ func TestProjectsTopBorder_WidthAndCorners(t *testing.T) { if !strings.Contains(b, "╭") || !strings.Contains(b, "╮") { t.Errorf("projectsTopBorder missing rounded corners: %q", b) } + // The phantom border must carry the same colorMuted foreground as + // renderProjectsBox's real border (BorderForeground(colorMuted)); a colour + // mismatch would read as a visible seam at the slide cut. Fingerprint the + // envelope rather than hard-coding escape bytes. + muted := lipgloss.NewStyle().Foreground(colorMuted).Render(probeMarker) + open, _, ok := splitANSIEnvelope(muted) + if !ok { + t.Fatal("could not fingerprint colorMuted ANSI envelope") + } + if !strings.Contains(b, open) { + t.Errorf("phantom top border missing colorMuted foreground envelope %q in %q", open, b) + } } func TestProjectsBandRows_RevealsBottomWithPhantomTop(t *testing.T) { @@ -164,7 +177,7 @@ func TestView_DuringSlide_HeightConservedAndPhantomBorder(t *testing.T) { // armProjectsShowForTest hand-builds a fully-armed SHOW slide (no key handler, // so this task is testable before Task 4). Mirrors what beginProjectsAnimation // will set up. -func armProjectsShowForTest(t *testing.T, m *Model) { +func armProjectsShowForTest(t testing.TB, m *Model) { t.Helper() m.showProjects = true m.refreshProjects() @@ -416,3 +429,57 @@ func projectAggsBackingPtr(a []cache.ProjectAggregate) uintptr { } return reflect.ValueOf(a).Pointer() } + +// TestProjectsSlide_RealFrame_BoundaryMovesMonotonically drives a real show +// slide tick-by-tick and asserts on the actual painted frame (withForcedColor → +// real ANSI): the box band grows monotonically, the phantom top border is +// present every mid-slide frame, the true top border lands only at settle, and +// total height is conserved. Per the project's real-binary-verification rule, +// the assertion is on View() output (the painted frame), not internal counters. +func TestProjectsSlide_RealFrame_BoundaryMovesMonotonically(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + roundedTop := lipgloss.RoundedBorder().TopLeft // "╭" + prevBand := -1 + const maxTicks = 600 + for i := range maxTicks { + frame := m.View() + if h := lipgloss.Height(frame); h != m.h { + t.Fatalf("tick %d: frame height=%d, want %d (conserved)", i, h, m.h) + } + band := m.projectsAnimH + if band > 0 && band < m.projectsSnap.targetH { // mid-slide + if !strings.Contains(frame, roundedTop) { + t.Errorf("tick %d (band=%d): phantom top border absent", i, band) + } + if band < prevBand { + t.Errorf("tick %d: band=%d < prev=%d (non-monotonic)", i, band, prevBand) + } + } + prevBand = band + if !m.springActive { + break + } + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if m.springActive { + t.Fatalf("slide did not settle within %d ticks", maxTicks) + } + // Settle frame: full box present (title visible), at the steady target height. + final := m.View() + if !strings.Contains(final, projectsTitle) { + t.Error("settle frame missing the projects box title (full box not restored)") + } + if m.projectsAnimH != m.projectsSnap.targetH { + t.Errorf("settle animH=%d, want target %d", m.projectsAnimH, m.projectsSnap.targetH) + } +} diff --git a/pkg/tui/zoomspring_test.go b/pkg/tui/zoomspring_test.go index b036f3e..9aba414 100644 --- a/pkg/tui/zoomspring_test.go +++ b/pkg/tui/zoomspring_test.go @@ -160,7 +160,7 @@ func TestZoomKey_Remaining_ReduceMotion_Snaps(t *testing.T) { // lastChart* / hasData reflect the seeded data. unitIdx is chartUnitCost or // chartUnitTokens. 60 buckets comfortably exceed visibleBuckets() so the chart // is data-filled (not warming up) across the seeded viewport. -func seedBarModelWithMessages(t *testing.T, unitIdx int, now time.Time) (Model, *cache.Cache) { +func seedBarModelWithMessages(t testing.TB, unitIdx int, now time.Time) (Model, *cache.Cache) { t.Helper() m, c := seedModelAt(t, unitIdx, 60, now) m.introPending = false From 9f9495d9aebf5f86c21d19147f3ec07772f51ee9 Mon Sep 17 00:00:00 2001 From: martinciu Date: Wed, 10 Jun 2026 23:31:43 +0200 Subject: [PATCH 06/17] tui: render projects-slide chart frames via the steady pipeline (#416) 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). --- pkg/tui/projectsspring.go | 54 +++++++--------- pkg/tui/projectsspring_bench_test.go | 2 +- pkg/tui/projectsspring_test.go | 93 ++++++++++++++++++++-------- 3 files changed, 91 insertions(+), 58 deletions(-) diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index 494a065..cb4cab4 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -77,37 +77,27 @@ func projectsBandRows(rows []string, width, animH int) []string { return band } -// renderProjectsAnimFrame rebuilds the viewport chart body at the frame's -// (animated) height from in-memory snapshot state — bar mode re-rasterizes the -// skyline so bars rescale into the new row count; line mode re-builds the -// windowed line chart. No DB, no ntcharts rebuild beyond the windowed line -// canvas. The box band is composed separately by View. -func (m *Model) renderProjectsAnimFrame() { - chartH := m.chartHeight() // derives from projectsAnimH via the projectsHeight lever +// renderProjectsFrame paints one slide frame entirely through the STEADY +// rendering pipelines at the lever-derived (animated) chart height — the +// property that makes the slide's endpoint frames byte-identical to the +// steady views (#416 round two; round one's parallel skyline/snapshot path +// produced mismatched endpoints, shifted+recolored x-labels and an empty +// box). Bar modes go through renderWindow (visible slice, flush-right +// slack, on-screen peak, in-bar labels); remaining mode re-issues the +// steady full-canvas line build + offset re-apply. All inputs are +// in-memory — zero DB per frame. +func (m *Model) renderProjectsFrame() { + chartH := m.chartHeight() m.viewport.Height = chartH - snap := m.projectsSnap - - if snap.isLine { - body := buildLineChart(snap.pts5h, snap.pts7d, snap.viewFrom, snap.viewTo, - snap.vpWidth, chartH, m.now(), snap.zoom, m.dateOrder, "projects", "") - m.viewport.SetContent(body) - m.viewport.SetXOffset(0) + if chartUnit(m.unitIdx) == chartUnitRemaining { + zoom := ZoomLevels[m.zoomIdx] + m.viewport.SetContent(buildLineChart(m.lastPts5h, m.lastPts7d, + m.lastChartFrom, m.lastChartTo, m.lastCanvasW, chartH, + m.now(), zoom, m.dateOrder, "projects", "")) + m.setX(m.viewportXOffset) return } - - barsH := chartH - if chartH >= 6 { - barsH = chartH - 1 - } - sky := rasterizeSkyline(snap.values, snap.starts, snap.peak, snap.vpWidth, barsH, snap.zoom) - body := drawSkyline(sky, barsH, snap.unit) - if chartH >= 6 { - labelRow := buildXLabelsRow(synthLabelStarts(snap.viewFrom, snap.viewTo, snap.zoom), - snap.vpWidth, snap.zoom, m.now(), m.dateOrder) - body = lipgloss.JoinVertical(lipgloss.Left, body, labelRow) - } - m.viewport.SetContent(body) - m.viewport.SetXOffset(0) + m.renderWindow() } // handleProjectsSpringTick advances one frame of the box slide: step the spring @@ -130,7 +120,7 @@ func (m *Model) handleProjectsSpringTick(gen int) tea.Cmd { return nil // stop the loop — idle TUI is zero-animation-cost } - m.renderProjectsAnimFrame() + m.renderProjectsFrame() return tea.Tick(time.Second/time.Duration(springFPS), func(time.Time) tea.Msg { return springTickMsg{gen: gen} }) @@ -178,6 +168,8 @@ func (m *Model) beginProjectsAnimation() { m.springKind = springKindProjects m.springGen++ - // Paint frame 0 synchronously so the next View doesn't flash the target layout. - m.renderProjectsAnimFrame() + // The viewport is deliberately NOT repainted here: frame 0 of the slide IS + // the current steady frame (show starts at height 0 = the box-hidden + // layout; hide starts at the current target). That no-touch property is + // half of the endpoint-identity guarantee (#416 round two). } diff --git a/pkg/tui/projectsspring_bench_test.go b/pkg/tui/projectsspring_bench_test.go index 33d5da4..1dce203 100644 --- a/pkg/tui/projectsspring_bench_test.go +++ b/pkg/tui/projectsspring_bench_test.go @@ -35,7 +35,7 @@ func BenchmarkProjectsAnimFrame(b *testing.B) { b.Run(strconv.Itoa(w), func(b *testing.B) { m := benchModelForProjects(b, w, now) for b.Loop() { - m.renderProjectsAnimFrame() + m.renderProjectsFrame() _ = projectsBandRows(m.projectsSnap.boxRows, m.w, m.projectsAnimH) } }) diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index 92f5ab9..a02e746 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -112,37 +112,27 @@ func TestProjectsBandRows_RevealsBottomWithPhantomTop(t *testing.T) { } } -func TestRenderProjectsAnimFrame_SetsViewportHeightToChartHeight(t *testing.T) { +// renderProjectsFrame must keep viewport.Height in lockstep with the +// lever-derived chartHeight every frame (round-one finding ccpulse-416.1). +func TestRenderProjectsFrame_SetsViewportHeightToChartHeight(t *testing.T) { withForcedColor(t) - now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) defer c.Close() + m.showProjects = false + m.refreshChart() - // Hand-arm a mid-slide state (show, animH=5 of target 12). - m.showProjects = true - m.refreshProjects() - m.projectsSnap = projectsAnimSnapshot{ - boxRows: strings.Split(renderProjectsBox(m.projectAggs, m.w, 12), "\n"), - startH: 0, targetH: 12, - values: m.lastValues, starts: m.lastStarts, peak: m.peak, - unit: chartUnitCost, isLine: false, vpWidth: m.viewport.Width, - zoom: ZoomLevels[m.zoomIdx], viewFrom: m.lastChartFrom, viewTo: m.lastChartTo, - } - m.springActive = true - m.springKind = springKindProjects - m.projectsAnimH = 5 - - m.renderProjectsAnimFrame() - - wantChartH := m.h - 7 - 5 // 33-5 = 28 - if m.viewport.Height != wantChartH { - t.Errorf("viewport.Height=%d, want chartHeight=%d during slide", m.viewport.Height, wantChartH) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + for range 4 { // advance a few frames so projectsAnimH is mid-flight + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) } - if m.chartHeight() != wantChartH { - t.Errorf("chartHeight()=%d, want %d", m.chartHeight(), wantChartH) + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight") } - if m.viewport.View() == "" { - t.Error("viewport content empty after renderProjectsAnimFrame") + if m.viewport.Height != m.chartHeight() { + t.Errorf("viewport.Height=%d, want chartHeight()=%d", m.viewport.Height, m.chartHeight()) } } @@ -163,7 +153,7 @@ func TestView_DuringSlide_HeightConservedAndPhantomBorder(t *testing.T) { m.springActive = true m.springKind = springKindProjects m.projectsAnimH = 5 - m.renderProjectsAnimFrame() + m.renderProjectsFrame() frame := m.View() if got := lipgloss.Height(frame); got != m.h { @@ -419,6 +409,57 @@ func TestZoomKey_AbortsInflightProjectsSlide(t *testing.T) { } } +// TestProjectsSlide_EndpointIdentity_BarMode is the headline #416-round-two +// property: the slide's frame 0 is byte-identical to the steady pre-slide +// View, and the settle frame is byte-identical to the steady post-slide View +// — both directions, under forced color so styling drift fails the test. +func TestProjectsSlide_EndpointIdentity_BarMode(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false // hidden by default (#414) → first 'p' is a show + m.refreshChart() + + m = assertSlideEndpoints(t, m, "show") + m = assertSlideEndpoints(t, m, "hide") + _ = m +} + +// assertSlideEndpoints presses 'p', asserts frame-0 identity, drives the +// spring to settle via constructed springTickMsg (never the real tea.Tick +// Cmd), and asserts settle identity against a fresh steady re-render. +func assertSlideEndpoints(t *testing.T, m Model, dir string) Model { + t.Helper() + pre := m.View() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if cmd == nil { + t.Fatalf("%s: 'p' returned nil cmd, want first tick scheduled", dir) + } + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("%s: springActive=%v kind=%d, want true/projects", dir, m.springActive, m.springKind) + } + if got := m.View(); got != pre { + t.Errorf("%s: frame 0 differs from steady pre-slide view\nframe0:\n%s\nsteady:\n%s", dir, got, pre) + } + + for i := 0; m.springActive; i++ { + if i > 600 { + t.Fatalf("%s: slide did not settle within 600 ticks", dir) + } + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + settled := m.View() + m.refreshChart() // independent steady re-render of the post-slide state + if post := m.View(); settled != post { + t.Errorf("%s: settle frame differs from steady post-slide view\nsettle:\n%s\nsteady:\n%s", dir, settled, post) + } + return m +} + // projectAggsBackingPtr returns the backing-array address of a ProjectAggregate // slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice // from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the From 0b6fa4443c315dc76f756375bad60b2fa0cbfaa4 Mon Sep 17 00:00:00 2001 From: martinciu Date: Wed, 10 Jun 2026 23:35:57 +0200 Subject: [PATCH 07/17] tui: re-flow projects box at animated height; drop band/phantom path (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/tui/model.go | 18 ++-- pkg/tui/projects_view.go | 34 +++++++ pkg/tui/projects_view_test.go | 31 +++++++ pkg/tui/projectsspring.go | 38 ++------ pkg/tui/projectsspring_bench_test.go | 1 - pkg/tui/projectsspring_test.go | 128 +++++++++++++++------------ 6 files changed, 148 insertions(+), 102 deletions(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 8b79cfa..5882bdd 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -734,17 +734,15 @@ func (m Model) View() string { parts := []string{header, sep, body} // The projects box sits between chart and footer, suppressed while the // help overlay is up (help replaces the chart body, so the box would be - // out of place). + // out of place). projectsHeight() returns the animated height mid-slide, + // so the SAME render path produces steady and slide frames — the box + // re-flows at each height: real borders and title from the first frames, + // cells filling top-down, the "…N more" overflow recounting as rows fit + // (#416 round two; round one's pre-rendered bottom-slice revealed blank + // padding first). if !m.showHelp { - switch { - case m.springActive && m.springKind == springKindProjects: - if band := projectsBandRows(m.projectsSnap.boxRows, m.w, m.projectsAnimH); len(band) > 0 { - parts = append(parts, strings.Join(band, "\n")) - } - default: - if ph := m.projectsHeight(); ph > 0 { - parts = append(parts, renderProjectsBox(m.projectAggs, m.w, ph)) - } + if ph := m.projectsHeight(); ph > 0 { + parts = append(parts, renderProjectsBox(m.projectAggs, m.w, ph)) } } parts = append(parts, sep, footer) diff --git a/pkg/tui/projects_view.go b/pkg/tui/projects_view.go index 0688508..b3e7bad 100644 --- a/pkg/tui/projects_view.go +++ b/pkg/tui/projects_view.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "math" + "strings" "github.com/charmbracelet/lipgloss" @@ -26,7 +27,16 @@ const ( // (ProjectAggregates guarantees this) and therefore lands in the final cell. // Empty aggs render a centered placeholder. When aggs exceed the cell budget // (cols × bodyRows), the final cell reads "…N more". +// +// Heights 1–3 occur only mid-slide (#416: the steady target is ≥ 4 or 0) and +// degrade gracefully — 1: top border, 2: closed border shell, 3: shell around +// the title row — always exactly `height` rows so View's per-frame height +// conservation holds at every animated height. func renderProjectsBox(aggs []cache.ProjectAggregate, width, height int) string { + if height <= 2 { + return projectsBoxShell(width, height) + } + box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorMuted). @@ -42,6 +52,13 @@ func renderProjectsBox(aggs []cache.ProjectAggregate, width, height int) string lipgloss.NewStyle().Foreground(colorMuted).Render("no activity in this window"))) } + // Height 3: a single inner row — the title, no body. The general layout + // below always emits title + ≥1 body row (≥4 rows total), which would + // overflow the box. + if innerH < 2 { + return box.Render(lipgloss.NewStyle().Foreground(colorMuted).Render(projectsTitle)) + } + // One row is spent on the title, so cells share the remaining innerH-1. bodyRows := max(innerH-1, 1) @@ -94,6 +111,23 @@ func renderProjectsBox(aggs []cache.ProjectAggregate, width, height int) string return box.Render(lipgloss.JoinVertical(lipgloss.Left, title, body)) } +// projectsBoxShell renders the box's border rows alone at the degenerate +// heights the slide passes through (1: top border, 2: top+bottom — the fully +// squashed box), matching renderProjectsBox's RoundedBorder + colorMuted so +// the shell reads as the same box. lipgloss cannot emit a bordered block +// with zero content rows, hence the manual border rows. +func projectsBoxShell(width, height int) string { + b := lipgloss.RoundedBorder() + inner := max(width-2, 0) + style := lipgloss.NewStyle().Foreground(colorMuted) + top := style.Render(b.TopLeft + strings.Repeat(b.Top, inner) + b.TopRight) + if height <= 1 { + return top + } + bottom := style.Render(b.BottomLeft + strings.Repeat(b.Bottom, inner) + b.BottomRight) + return lipgloss.JoinVertical(lipgloss.Left, top, bottom) +} + // Slot widths for the fixed right-hand columns. Each value is right-aligned // in its own slot so the columns line up vertically across stacked cells. // diff --git a/pkg/tui/projects_view_test.go b/pkg/tui/projects_view_test.go index 28ae8f5..1351a08 100644 --- a/pkg/tui/projects_view_test.go +++ b/pkg/tui/projects_view_test.go @@ -72,6 +72,37 @@ func TestRenderProjectsBox_NoProjectLast(t *testing.T) { } } +// TestRenderProjectsBox_DegenerateHeights pins the #416 mid-slide contract: +// every height the slide passes through renders EXACTLY that many rows +// (View's per-frame height conservation depends on it) — 1: top border, +// 2: closed border shell, 3: shell around the title row, 4+: the full +// title+body layout. +func TestRenderProjectsBox_DegenerateHeights(t *testing.T) { + for h := 1; h <= 5; h++ { + got := renderProjectsBox(sampleAggs(), 80, h) + rows := strings.Split(got, "\n") + if len(rows) != h { + t.Errorf("height %d: rendered %d rows, want exactly %d:\n%s", h, len(rows), h, got) + continue + } + if !strings.Contains(rows[0], "╭") || !strings.Contains(rows[0], "╮") { + t.Errorf("height %d: first row is not a top border: %q", h, rows[0]) + } + if h >= 2 { + last := rows[len(rows)-1] + if !strings.Contains(last, "╰") || !strings.Contains(last, "╯") { + t.Errorf("height %d: last row is not a bottom border: %q", h, last) + } + } + if h >= 3 && !strings.Contains(got, projectsTitle) { + t.Errorf("height %d: title missing", h) + } + if h >= 5 && !strings.Contains(got, "dotfiles") { + t.Errorf("height %d: top spender missing", h) + } + } +} + func TestRenderProjectsBox_Overflow(t *testing.T) { // 1 column (narrow) × bodyRows=2 → capacity 2; 10 aggs must overflow // into a "…N more" final cell rather than silently dropping rows. diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index cb4cab4..1154ec4 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -5,11 +5,12 @@ // animations are mutually exclusive — refreshChart aborts any in-flight one) and // is disambiguated by Model.springKind == springKindProjects. // -// The crux (see spec): the CHART rescales (re-rasterized at the interpolated -// height each frame, bars shrink/grow into fewer/more rows) while the BOX slices -// (rendered once at target height at arm, bottom rows revealed each frame with a -// phantom top border). Both read only in-memory snapshot state — no DB, no -// ntcharts rebuild, per frame. +// The crux (round two, see spec): every frame is produced by the STEADY +// rendering pipelines at the animated height — the chart via renderWindow / +// buildLineChart at the lever-derived chartHeight, the box re-flowed by the +// steady View path at projectsHeight(). Endpoint frames are byte-identical to +// the steady views by construction. All per-frame inputs are in-memory — no +// DB per frame. package tui import ( @@ -19,7 +20,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/harmonica" - "github.com/charmbracelet/lipgloss" "github.com/martinciu/ccpulse/pkg/cache" ) @@ -51,32 +51,6 @@ func lerpInt(a, b int, r float64) int { return int(math.Round(float64(a) + (float64(b)-float64(a))*r)) } -// projectsTopBorder renders a phantom rounded top-border line `width` cols wide, -// matching renderProjectsBox's RoundedBorder + colorMuted, so the sliced box -// reads as a complete bordered box at the cut throughout the slide. -func projectsTopBorder(width int) string { - b := lipgloss.RoundedBorder() - inner := max(width-2, 0) - line := b.TopLeft + strings.Repeat(b.Top, inner) + b.TopRight - return lipgloss.NewStyle().Foreground(colorMuted).Render(line) -} - -// projectsBandRows returns the bottom `animH` rows of the once-rendered box -// `rows`, with the topmost visible row replaced by a phantom top border. animH<=0 -// → nil (nothing to show); animH>=len → the full box verbatim (settle frame). -func projectsBandRows(rows []string, width, animH int) []string { - if animH <= 0 || len(rows) == 0 { - return nil - } - if animH >= len(rows) { - return rows - } - band := make([]string, 0, animH) - band = append(band, projectsTopBorder(width)) - band = append(band, rows[len(rows)-(animH-1):]...) // bottom (animH-1) rows incl. real bottom border - return band -} - // renderProjectsFrame paints one slide frame entirely through the STEADY // rendering pipelines at the lever-derived (animated) chart height — the // property that makes the slide's endpoint frames byte-identical to the diff --git a/pkg/tui/projectsspring_bench_test.go b/pkg/tui/projectsspring_bench_test.go index 1dce203..944ae69 100644 --- a/pkg/tui/projectsspring_bench_test.go +++ b/pkg/tui/projectsspring_bench_test.go @@ -36,7 +36,6 @@ func BenchmarkProjectsAnimFrame(b *testing.B) { m := benchModelForProjects(b, w, now) for b.Loop() { m.renderProjectsFrame() - _ = projectsBandRows(m.projectsSnap.boxRows, m.w, m.projectsAnimH) } }) } diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index a02e746..126b1da 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -61,57 +61,6 @@ func TestProjectsHeight_SpringBranchOverridesTarget(t *testing.T) { } } -func TestProjectsTopBorder_WidthAndCorners(t *testing.T) { - withForcedColor(t) // assert on the real painted ANSI, not stripped output - b := projectsTopBorder(80) - if w := lipgloss.Width(b); w != 80 { - t.Errorf("projectsTopBorder width=%d, want 80", w) - } - // Rounded border corners (strip styling via lipgloss.Width-agnostic contains). - if !strings.Contains(b, "╭") || !strings.Contains(b, "╮") { - t.Errorf("projectsTopBorder missing rounded corners: %q", b) - } - // The phantom border must carry the same colorMuted foreground as - // renderProjectsBox's real border (BorderForeground(colorMuted)); a colour - // mismatch would read as a visible seam at the slide cut. Fingerprint the - // envelope rather than hard-coding escape bytes. - muted := lipgloss.NewStyle().Foreground(colorMuted).Render(probeMarker) - open, _, ok := splitANSIEnvelope(muted) - if !ok { - t.Fatal("could not fingerprint colorMuted ANSI envelope") - } - if !strings.Contains(b, open) { - t.Errorf("phantom top border missing colorMuted foreground envelope %q in %q", open, b) - } -} - -func TestProjectsBandRows_RevealsBottomWithPhantomTop(t *testing.T) { - rows := []string{"TOPBORDER", "title", "r1", "r2", "BOTBORDER"} // 5-row box - - // animH=0 → no band. - if got := projectsBandRows(rows, 40, 0); got != nil { - t.Errorf("animH=0 band=%v, want nil", got) - } - // animH=1 → just the phantom top border. - band := projectsBandRows(rows, 40, 1) - if len(band) != 1 || !strings.Contains(band[0], "╭") { - t.Errorf("animH=1 band=%v, want [phantom-top]", band) - } - // animH=3 → phantom top + bottom 2 rows (r2, BOTBORDER). - band = projectsBandRows(rows, 40, 3) - if len(band) != 3 { - t.Fatalf("animH=3 len(band)=%d, want 3", len(band)) - } - if !strings.Contains(band[0], "╭") || band[1] != "r2" || band[2] != "BOTBORDER" { - t.Errorf("animH=3 band=%v, want [phantom, r2, BOTBORDER]", band) - } - // animH>=len → full box verbatim (settle frame). - band = projectsBandRows(rows, 40, 5) - if len(band) != 5 || band[0] != "TOPBORDER" { - t.Errorf("animH=5 band=%v, want full rows", band) - } -} - // renderProjectsFrame must keep viewport.Height in lockstep with the // lever-derived chartHeight every frame (round-one finding ccpulse-416.1). func TestRenderProjectsFrame_SetsViewportHeightToChartHeight(t *testing.T) { @@ -136,7 +85,12 @@ func TestRenderProjectsFrame_SetsViewportHeightToChartHeight(t *testing.T) { } } -func TestView_DuringSlide_HeightConservedAndPhantomBorder(t *testing.T) { +// TestView_DuringSlide_HeightConservedRealBorder probes a mid-flight frame: +// total height is conserved (chartHeight() + projectsHeight() == m.h - 7 and +// the rendered frame is exactly m.h rows), and the box band carries the REAL +// renderProjectsBox top border + title — re-flowed at the animated height, +// not a phantom-topped bottom slice (#416 round two). +func TestView_DuringSlide_HeightConservedRealBorder(t *testing.T) { withForcedColor(t) now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) @@ -144,23 +98,36 @@ func TestView_DuringSlide_HeightConservedAndPhantomBorder(t *testing.T) { m.showProjects = true m.refreshProjects() m.projectsSnap = projectsAnimSnapshot{ - boxRows: strings.Split(renderProjectsBox(m.projectAggs, m.w, 12), "\n"), - startH: 0, targetH: 12, - values: m.lastValues, starts: m.lastStarts, peak: m.peak, - unit: chartUnitCost, isLine: false, vpWidth: m.viewport.Width, - zoom: ZoomLevels[m.zoomIdx], viewFrom: m.lastChartFrom, viewTo: m.lastChartTo, + startH: 0, targetH: 12, } m.springActive = true m.springKind = springKindProjects m.projectsAnimH = 5 m.renderProjectsFrame() + if got, want := m.chartHeight()+m.projectsHeight(), m.h-7; got != want { + t.Errorf("chartHeight+projectsHeight=%d, want %d (height lever conserved)", got, want) + } frame := m.View() if got := lipgloss.Height(frame); got != m.h { t.Errorf("View height=%d, want %d (conserved every frame)", got, m.h) } - if !strings.Contains(frame, "╭") { - t.Error("mid-slide frame missing phantom top border") + // animH=5 ≥ 4: the box band must be the real re-flowed box — top border + // with rounded corners and the title row right beneath it. The header is + // also a rounded-bordered block, so the box's top border is the LAST ╭ + // row in the frame. + lines := strings.Split(frame, "\n") + topIdx := -1 + for i, line := range lines { + if strings.Contains(line, "╭") { + topIdx = i + } + } + if topIdx == -1 { + t.Fatal("mid-slide frame missing the box top border") + } + if topIdx+1 >= len(lines) || !strings.Contains(lines[topIdx+1], projectsTitle) { + t.Errorf("row beneath the top border lacks the title %q (box not re-flowed)", projectsTitle) } } @@ -460,6 +427,49 @@ func assertSlideEndpoints(t *testing.T, m Model, dir string) Model { return m } +// TestProjectsSlide_BoxContentPresentEarly guards the round-one "box rose +// empty" defect: as soon as the box band is a few rows tall it must carry +// the real top border, the title, and (one row later) the top spender — +// renderProjectsBox re-flowed at the animated height, not a blank-padded +// pre-render sliced bottom-first. +func TestProjectsSlide_BoxContentPresentEarly(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if len(m.projectAggs) == 0 { + t.Fatal("arm did not populate projectAggs (show-path requery missing)") + } + topLabel := m.projectAggs[0].Label + + sawTitle := false + for i := 0; m.springActive && i < 600; i++ { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + frame := m.View() + if lipgloss.Height(frame) != m.h { + t.Fatalf("tick %d: frame height %d != terminal height %d", i, lipgloss.Height(frame), m.h) + } + if m.springActive && m.projectsAnimH >= 4 { + if !strings.Contains(frame, projectsTitle) { + t.Fatalf("animH=%d: frame lacks box title %q — box rendering empty", m.projectsAnimH, projectsTitle) + } + sawTitle = true + } + if m.springActive && m.projectsAnimH >= 5 && !strings.Contains(frame, topLabel) { + t.Fatalf("animH=%d: frame lacks top spender %q — content not re-flowed", m.projectsAnimH, topLabel) + } + } + if !sawTitle { + t.Fatal("slide settled without ever sampling a frame at animH >= 4") + } +} + // projectAggsBackingPtr returns the backing-array address of a ProjectAggregate // slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice // from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the From 3bd51898a770d751bc400f23a055527164ab140b Mon Sep 17 00:00:00 2001 From: martinciu Date: Wed, 10 Jun 2026 23:37:16 +0200 Subject: [PATCH 08/17] tui: steady y-overlay + x-label stability during projects slide (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/tui/model.go | 17 ++++++++------ pkg/tui/projectsspring_test.go | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 5882bdd..815395e 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -781,15 +781,18 @@ func (m Model) renderChartBody(rawBody string) string { } return barZoomYLabel(rawBody, m.zoomSnap, ZoomLevels[m.zoomIdx], m.chartHeight(), m.zoomSpringR) case m.springActive && m.springKind == springKindProjects: - // The chart isn't morphing data, only height — render the steady-state - // y-axis at the frame's (animated) chartHeight, from frozen snapshot - // unit/peak/zoom. MUST precede the generic springActive case, which reads - // m.springRatios (the unit-toggle state, unset here). - if m.projectsSnap.isLine { + // Height-only animation: the y-overlay is EXACTLY the steady-state + // overlay at the frame's (animated) chartHeight — same live inputs + // as the steady cases below, so endpoint frames match them + // byte-for-byte (#416 round two). m.peak is recomputed by + // renderWindow each frame from the same fixed window (constant + // during the slide). MUST precede the generic springActive case, + // which reads m.springRatios (unit-toggle state, unset here). + if chartUnit(m.unitIdx) == chartUnitRemaining { return overlayYTicks(rawBody, m.chartHeight(), 1.0) } - return overlayYLabel(rawBody, m.projectsSnap.peak, m.projectsSnap.unit, - m.chartHeight(), 1.0, m.projectsSnap.zoom.hasInBarNumbers()) + return overlayYLabel(rawBody, m.peak, chartUnit(m.unitIdx), + m.chartHeight(), 1.0, ZoomLevels[m.zoomIdx].hasInBarNumbers()) case m.springActive: var maxR float64 for _, r := range m.springRatios { diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index 126b1da..bef9003 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -2,6 +2,7 @@ package tui import ( "reflect" + "regexp" "strings" "testing" "time" @@ -470,6 +471,46 @@ func TestProjectsSlide_BoxContentPresentEarly(t *testing.T) { } } +// TestProjectsSlide_XLabelRowStable guards round-one defects 3+4: during the +// slide the x-axis label row must keep its exact content, column position +// and ANSI styling — the steady renderer's own label row, not a synthetic +// re-creation. Asserted by requiring the steady view's label line to appear +// verbatim (bytes, incl. color) in every sampled mid-slide frame. +func TestProjectsSlide_XLabelRowStable(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + labelRow := findXLabelRow(t, m.View()) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + for i := 0; m.springActive && i < 600; i++ { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + if m.springActive && m.chartHeight() >= 6 && !strings.Contains(m.View(), labelRow) { + t.Fatalf("tick %d (chartH=%d): steady x-label row missing/altered mid-slide\nwant line: %q", i, m.chartHeight(), labelRow) + } + } +} + +// findXLabelRow returns the first View line carrying >= 2 HH:MM time labels +// — the bar chart's x-axis row. +func findXLabelRow(t *testing.T, view string) string { + t.Helper() + re := regexp.MustCompile(`\d{1,2}:\d{2}`) + for line := range strings.SplitSeq(view, "\n") { + if len(re.FindAllString(line, -1)) >= 2 { + return line + } + } + t.Fatal("no x-label row found in steady view") + return "" +} + // projectAggsBackingPtr returns the backing-array address of a ProjectAggregate // slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice // from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the From fb7efb788a44c060282b006538d8b4d6b57e710b Mon Sep 17 00:00:00 2001 From: martinciu Date: Wed, 10 Jun 2026 23:41:04 +0200 Subject: [PATCH 09/17] tui: dissolve projects-slide snapshot; re-arm reverses from current height (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/tui/model.go | 23 +++--- pkg/tui/projectsspring.go | 92 ++++++++---------------- pkg/tui/projectsspring_bench_test.go | 2 +- pkg/tui/projectsspring_test.go | 102 ++++++++++++++++++--------- pkg/tui/series.go | 7 +- 5 files changed, 114 insertions(+), 112 deletions(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 815395e..4564eb1 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -269,16 +269,20 @@ type Model struct { zoomSpringR float64 zoomSpringVel float64 zoomSnap zoomAnimSnapshot - // Projects-box slide (#416). Single-phase spring on a 0→1 ratio driving - // projectsAnimH (the animated OUTER box height). projectsSnap freezes - // everything the per-frame tick reads so no frame touches the DB. Mutually - // exclusive with the unit/zoom springs via the shared springActive flag + - // springKind tag. + // Projects-box slide (#416): single-phase spring on the box's OUTER + // height. projectsAnimH is the animated height the projectsHeight() + // lever returns mid-slide; projectsSlideFrom/To are the endpoints of + // the in-flight slide (re-arm starts From at the current height). + // Frames render through the STEADY pipelines (renderProjectsFrame), so + // no snapshot state exists — endpoint frames equal the steady views by + // construction. Mutually exclusive with the unit/zoom springs via the + // shared springActive flag + springKind tag. projectsSpring harmonica.Spring projectsSpringR float64 projectsSpringVel float64 projectsAnimH int - projectsSnap projectsAnimSnapshot + projectsSlideFrom int + projectsSlideTo int // nowGen is bumped each time the live-advance tick is re-armed (zoom // change). scheduleNowTick captures the current value into the scheduled // nowTickMsg; the handler drops ticks whose gen doesn't match, so a zoom @@ -653,9 +657,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { return m.handleUnitKey() case key.Matches(msg, m.keys.Projects): // Slide the box up (show) / down (hide) via a harmonica spring (#416). - // reduce_motion or a too-short terminal (no room for a box) → snap, the - // pre-#416 hard cut. - if m.deps.ReduceMotion || m.projectsTargetHeight() == 0 { + // reduce_motion, a too-short terminal (no room for a box), or an + // empty/cleared chart (renderWindow would no-op against no content) → + // snap, the pre-#416 hard cut. + if m.deps.ReduceMotion || m.projectsTargetHeight() == 0 || m.lastCanvasW == 0 { m.showProjects = !m.showProjects m.viewport.Height = m.chartHeight() m.refreshChart() diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index 1154ec4..7b8a1d9 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -15,39 +15,18 @@ package tui import ( "math" - "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/harmonica" - - "github.com/martinciu/ccpulse/pkg/cache" ) -// projectsAnimSnapshot captures, once at arm time, everything the per-frame -// slide tick needs so nothing it reads can shift mid-animation. -type projectsAnimSnapshot struct { - boxRows []string // box pre-rendered at the steady target height, split into rows (the SLICE source) - startH int // slide start outer height (show: 0, hide: target) - targetH int // slide end outer height (show: target, hide: 0) - - // Chart inputs — frozen so the per-frame re-rasterize/re-build reads no DB. - values []float64 - starts []time.Time - peak float64 - pts5h []cache.UtilizationPoint - pts7d []cache.UtilizationPoint - unit chartUnit - isLine bool - vpWidth int - zoom ZoomLevel - viewFrom time.Time // line mode: stable visible window (no horizontal squeeze in this slide) - viewTo time.Time -} - -// lerpInt linearly interpolates between integer heights a and b at parameter r, -// rounding to the nearest row. r is clamped to [0,1] by the caller's spring. +// lerpInt linearly interpolates between integer heights a and b at +// parameter r, rounding to the nearest row. r is clamped to [0,1] here — +// the critically-damped spring approaches 1 asymptotically but nothing +// guarantees it never lands marginally outside the interval. func lerpInt(a, b int, r float64) int { + r = min(max(r, 0), 1) return int(math.Round(float64(a) + (float64(b)-float64(a))*r)) } @@ -83,10 +62,10 @@ func (m *Model) renderProjectsFrame() { func (m *Model) handleProjectsSpringTick(gen int) tea.Cmd { r, vel := m.projectsSpring.Update(m.projectsSpringR, m.projectsSpringVel, 1.0) m.projectsSpringR, m.projectsSpringVel = r, vel - m.projectsAnimH = lerpInt(m.projectsSnap.startH, m.projectsSnap.targetH, r) + m.projectsAnimH = lerpInt(m.projectsSlideFrom, m.projectsSlideTo, r) if math.Abs(1.0-r) < phaseTransitionThreshold { - m.projectsAnimH = m.projectsSnap.targetH + m.projectsAnimH = m.projectsSlideTo m.springActive = false m.springKind = springKindNone m.viewport.Height = m.chartHeight() @@ -100,50 +79,35 @@ func (m *Model) handleProjectsSpringTick(gen int) tea.Cmd { }) } -// beginProjectsAnimation arms the box slide. Aborts any in-flight u/z FIRST (only -// when one is running — calling refreshChart unconditionally would fire a wasted -// refreshProjects query on the hide path). Commits showProjects to its terminal -// value at arm so a later u/z abort reads the correct chartHeight with no extra -// wiring (see series.go abort block). Snapshots the box (one requery on show; the -// in-memory aggs on hide) and the chart inputs, seeds the spring, paints frame 0. +// beginProjectsAnimation arms the box slide. A re-arm mid-slide reverses +// from the CURRENT animated height (every intermediate height renders +// correctly under re-flow — no snap to an extreme first); an in-flight u/z +// is hard-cut via refreshChart exactly as u and z do to each other. +// showProjects commits at arm (keeps u/z aborts free of projects-specific +// wiring). Show pays THE one arm-time ProjectAggregates query via +// refreshProjects (the box was unloaded while hidden, #414); hide pays +// none. The viewport is deliberately NOT repainted: frame 0 of the slide +// IS the current steady frame (show starts at height 0 = the box-hidden +// layout; hide starts at the current target; re-arm wherever the slide +// was) — that no-touch property is half of endpoint identity. func (m *Model) beginProjectsAnimation() { - if m.springActive { - m.refreshChart() // abort in-flight u/z; restore steady chart inputs to snapshot + from := m.projectsHeight() // animH mid-slide, steady extreme otherwise + if m.springActive && m.springKind != springKindProjects { + m.refreshChart() // abort in-flight u/z; restores steady chart content } - show := !m.showProjects - m.showProjects = show // committed terminal state (the invariant that makes abort free) - - target := m.projectsTargetHeight() - if show { - m.refreshProjects() // THE one arm-time query: box was hidden (#414) → repopulate - m.projectsSnap.startH, m.projectsSnap.targetH = 0, target - } else { - // projectAggs already populated (box was showing) — no query. - m.projectsSnap.startH, m.projectsSnap.targetH = target, 0 + m.showProjects = !m.showProjects + to := 0 + if m.showProjects { + to = m.projectsTargetHeight() + m.refreshProjects() // THE one arm-time query on the show path } - - m.projectsSnap.boxRows = strings.Split(renderProjectsBox(m.projectAggs, m.w, target), "\n") - m.projectsSnap.values = m.lastValues - m.projectsSnap.starts = m.lastStarts - m.projectsSnap.peak = m.peak - m.projectsSnap.pts5h = m.lastPts5h - m.projectsSnap.pts7d = m.lastPts7d - m.projectsSnap.unit = chartUnit(m.unitIdx) - m.projectsSnap.isLine = isLineMode(chartUnit(m.unitIdx)) - m.projectsSnap.vpWidth = m.viewport.Width - m.projectsSnap.zoom = ZoomLevels[m.zoomIdx] - m.projectsSnap.viewFrom, m.projectsSnap.viewTo = m.visibleWindow() + m.projectsSlideFrom, m.projectsSlideTo = from, to + m.projectsAnimH = from m.projectsSpring = harmonica.NewSpring(harmonica.FPS(springFPS), phase2Frequency, phase2Damping) m.projectsSpringR, m.projectsSpringVel = 0, 0 - m.projectsAnimH = m.projectsSnap.startH m.springActive = true m.springKind = springKindProjects m.springGen++ - - // The viewport is deliberately NOT repainted here: frame 0 of the slide IS - // the current steady frame (show starts at height 0 = the box-hidden - // layout; hide starts at the current target). That no-touch property is - // half of the endpoint-identity guarantee (#416 round two). } diff --git a/pkg/tui/projectsspring_bench_test.go b/pkg/tui/projectsspring_bench_test.go index 944ae69..7aca1a4 100644 --- a/pkg/tui/projectsspring_bench_test.go +++ b/pkg/tui/projectsspring_bench_test.go @@ -21,7 +21,7 @@ func benchModelForProjects(b *testing.B, vpWidth int, now time.Time) Model { m.viewport.Width = vpWidth m.refreshChart() armProjectsShowForTest(b, &m) - m.projectsAnimH = m.projectsSnap.targetH / 2 // mid-slide + m.projectsAnimH = m.projectsSlideTo / 2 // mid-slide return m } diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index bef9003..7f1bd35 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -8,7 +8,6 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" "github.com/martinciu/ccpulse/pkg/cache" @@ -25,8 +24,10 @@ func TestLerpInt(t *testing.T) { {0, 12, 0.5, 6}, {12, 0, 0.5, 6}, {12, 0, 1, 0}, - {0, 10, 0.24, 2}, // 2.4 rounds to 2 - {0, 10, 0.25, 3}, // 2.5 rounds to 3 (math.Round) + {0, 10, 0.24, 2}, // 2.4 rounds to 2 + {0, 10, 0.25, 3}, // 2.5 rounds to 3 (math.Round) + {0, 10, -0.2, 0}, // r clamps to 0 (spring may undershoot marginally) + {0, 10, 1.3, 10}, // r clamps to 1 (spring may overshoot marginally) } for _, c := range cases { if got := lerpInt(c.a, c.b, c.r); got != c.want { @@ -98,9 +99,7 @@ func TestView_DuringSlide_HeightConservedRealBorder(t *testing.T) { defer c.Close() m.showProjects = true m.refreshProjects() - m.projectsSnap = projectsAnimSnapshot{ - startH: 0, targetH: 12, - } + m.projectsSlideFrom, m.projectsSlideTo = 0, 12 m.springActive = true m.springKind = springKindProjects m.projectsAnimH = 5 @@ -132,27 +131,13 @@ func TestView_DuringSlide_HeightConservedRealBorder(t *testing.T) { } } -// armProjectsShowForTest hand-builds a fully-armed SHOW slide (no key handler, -// so this task is testable before Task 4). Mirrors what beginProjectsAnimation -// will set up. +// armProjectsShowForTest arms a SHOW slide through the production arm path +// (the box starts hidden; beginProjectsAnimation toggles it on, pays the +// arm-time aggs query, and seeds the spring without repainting frame 0). func armProjectsShowForTest(t testing.TB, m *Model) { t.Helper() - m.showProjects = true - m.refreshProjects() - target := m.projectsTargetHeight() - m.projectsSnap = projectsAnimSnapshot{ - boxRows: strings.Split(renderProjectsBox(m.projectAggs, m.w, target), "\n"), - startH: 0, targetH: target, - values: m.lastValues, starts: m.lastStarts, peak: m.peak, - unit: chartUnitCost, isLine: false, vpWidth: m.viewport.Width, - zoom: ZoomLevels[m.zoomIdx], viewFrom: m.lastChartFrom, viewTo: m.lastChartTo, - } - m.projectsSpring = harmonica.NewSpring(harmonica.FPS(springFPS), phase2Frequency, phase2Damping) - m.projectsSpringR, m.projectsSpringVel = 0, 0 - m.projectsAnimH = 0 - m.springActive = true - m.springKind = springKindProjects - m.springGen++ + m.showProjects = false + m.beginProjectsAnimation() } func TestProjectsSpringTick_AdvancesThenSettles(t *testing.T) { @@ -161,7 +146,7 @@ func TestProjectsSpringTick_AdvancesThenSettles(t *testing.T) { m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) defer c.Close() armProjectsShowForTest(t, &m) - target := m.projectsSnap.targetH + target := m.projectsSlideTo // One tick: ratio moves off 0, animH advances toward target. updated, cmd := m.Update(springTickMsg{gen: m.springGen}) @@ -236,11 +221,11 @@ func TestProjectsKey_ShowFromIdle_ArmsAndQueriesOnce(t *testing.T) { if !m.showProjects { t.Error("show 'p': showProjects=false, want true (committed at arm)") } - if m.projectsSnap.startH != 0 || m.projectsSnap.targetH != m.projectsTargetHeight() { - t.Errorf("show snap heights = (%d,%d), want (0,%d)", m.projectsSnap.startH, m.projectsSnap.targetH, m.projectsTargetHeight()) + if m.projectsSlideFrom != 0 || m.projectsSlideTo != m.projectsTargetHeight() { + t.Errorf("show slide from/to = (%d,%d), want (0,%d)", m.projectsSlideFrom, m.projectsSlideTo, m.projectsTargetHeight()) } - if len(m.projectsSnap.boxRows) == 0 { - t.Error("show 'p': boxRows not snapshotted (arm requery missing)") + if len(m.projectAggs) == 0 { + t.Error("show 'p': projectAggs empty after arm (requery missing)") } if cmd == nil { t.Error("show 'p': cmd=nil, want first tick scheduled") @@ -283,8 +268,8 @@ func TestProjectsKey_HideFromIdle_NoArmQuery(t *testing.T) { if m.showProjects { t.Error("hide 'p': showProjects=true, want false (committed at arm)") } - if m.projectsSnap.startH != m.projectsTargetHeight() || m.projectsSnap.targetH != 0 { - t.Errorf("hide snap heights=(%d,%d), want (%d,0)", m.projectsSnap.startH, m.projectsSnap.targetH, m.projectsTargetHeight()) + if m.projectsSlideFrom != m.projectsTargetHeight() || m.projectsSlideTo != 0 { + t.Errorf("hide slide from/to=(%d,%d), want (%d,0)", m.projectsSlideFrom, m.projectsSlideTo, m.projectsTargetHeight()) } // No arm requery on hide: the snapshot reused the already-populated aggs. if projectAggsBackingPtr(m.projectAggs) != beforePtr { @@ -511,6 +496,53 @@ func findXLabelRow(t *testing.T, view string) string { return "" } +// TestProjectsKey_RearmMidSlide_ReversesFromCurrentHeight: a second 'p' +// mid-slide reverses the motion from wherever the box currently is — no +// snap to an extreme first (re-flow rendering makes every intermediate +// height a valid start), no u/z-style hard-cut abort. +func TestProjectsKey_RearmMidSlide_ReversesFromCurrentHeight(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + genShow := m.springGen + for range 6 { // mid-flight + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 6 ticks; cannot probe re-arm") + } + mid := m.projectsAnimH + if mid <= 0 || mid >= m.projectsTargetHeight() { + t.Fatalf("animH=%d not strictly mid-flight (target %d)", mid, m.projectsTargetHeight()) + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if cmd == nil { + t.Fatal("re-arm 'p': cmd=nil, want new tick loop") + } + if m.showProjects { + t.Error("re-arm 'p': showProjects=true, want false (reversed to hide)") + } + if m.springGen == genShow { + t.Error("re-arm 'p': springGen not bumped — stale show ticks would still apply") + } + if m.projectsSlideFrom != mid || m.projectsSlideTo != 0 { + t.Errorf("re-arm from/to = (%d,%d), want (%d,0) — must reverse from current height", + m.projectsSlideFrom, m.projectsSlideTo, mid) + } + if m.projectsAnimH != mid { + t.Errorf("re-arm animH=%d, want %d (frame 0 of the reversal = current frame)", m.projectsAnimH, mid) + } +} + // projectAggsBackingPtr returns the backing-array address of a ProjectAggregate // slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice // from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the @@ -548,7 +580,7 @@ func TestProjectsSlide_RealFrame_BoundaryMovesMonotonically(t *testing.T) { t.Fatalf("tick %d: frame height=%d, want %d (conserved)", i, h, m.h) } band := m.projectsAnimH - if band > 0 && band < m.projectsSnap.targetH { // mid-slide + if band > 0 && band < m.projectsSlideTo { // mid-slide if !strings.Contains(frame, roundedTop) { t.Errorf("tick %d (band=%d): phantom top border absent", i, band) } @@ -571,7 +603,7 @@ func TestProjectsSlide_RealFrame_BoundaryMovesMonotonically(t *testing.T) { if !strings.Contains(final, projectsTitle) { t.Error("settle frame missing the projects box title (full box not restored)") } - if m.projectsAnimH != m.projectsSnap.targetH { - t.Errorf("settle animH=%d, want target %d", m.projectsAnimH, m.projectsSnap.targetH) + if m.projectsAnimH != m.projectsSlideTo { + t.Errorf("settle animH=%d, want target %d", m.projectsAnimH, m.projectsSlideTo) } } diff --git a/pkg/tui/series.go b/pkg/tui/series.go index aeee9bb..1e7d108 100644 --- a/pkg/tui/series.go +++ b/pkg/tui/series.go @@ -142,9 +142,10 @@ func (m *Model) refreshChart() { // Next beginUnitAnimation re-makes the slices. Zoom scalars // (zoomSpringR/Vel/zoomSnap) likewise stay set but unread (#373). // The projects slide (#416) rides this same abort: springActive=false + - // springKind=springKindNone drops it; projectsAnimH/projectsSnap stay set - // but unread, and showProjects was already committed at arm so the chart - // rebuild below reads the correct chartHeight. + // springKind=springKindNone drops it; projectsAnimH and the slide + // from/to endpoints stay set but unread, and showProjects was already + // committed at arm so the chart rebuild below reads the correct + // chartHeight. } // Snapshot the wall-clock scroll anchor BEFORE the rebuild overwrites From c904fe6c066022152f0c275bb5fd355451b1c555 Mon Sep 17 00:00:00 2001 From: martinciu Date: Wed, 10 Jun 2026 23:41:53 +0200 Subject: [PATCH 10/17] tui: line-mode endpoint identity for projects slide (#416) 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. --- pkg/tui/projectsspring_test.go | 18 ++++++++++++++++++ pkg/tui/zoomspring_test.go | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index 7f1bd35..849f87f 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -413,6 +413,24 @@ func assertSlideEndpoints(t *testing.T, m Model, dir string) Model { return m } +// TestProjectsSlide_EndpointIdentity_LineMode mirrors the bar-mode headline +// test in remaining (line) mode: the per-frame build is the steady +// full-canvas buildLineChart at the lever height + the steady offset +// re-apply — closing round-one finding ccpulse-416.2 (the old frame skipped +// the steady path's windowing/offset semantics entirely). +func TestProjectsSlide_EndpointIdentity_LineMode(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedRemainingModelWithSamples(t, 60, now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + m = assertSlideEndpoints(t, m, "show-line") + m = assertSlideEndpoints(t, m, "hide-line") + _ = m +} + // TestProjectsSlide_BoxContentPresentEarly guards the round-one "box rose // empty" defect: as soon as the box band is a few rows tall it must carry // the real top border, the title, and (one row later) the top spender — diff --git a/pkg/tui/zoomspring_test.go b/pkg/tui/zoomspring_test.go index 9aba414..b6f7189 100644 --- a/pkg/tui/zoomspring_test.go +++ b/pkg/tui/zoomspring_test.go @@ -66,7 +66,7 @@ func TestVisibleWindow_RemainingGeometry(t *testing.T) { // seedRemainingModelWithSamples builds a remaining-mode model at the 15m zoom // with `n` usage samples spaced 15m apart ending at `now`, then refreshes so // lastPts5h / lastChart* / hasData reflect the seeded data. -func seedRemainingModelWithSamples(t *testing.T, n int, now time.Time) (Model, *cache.Cache) { +func seedRemainingModelWithSamples(t testing.TB, n int, now time.Time) (Model, *cache.Cache) { t.Helper() m, c := seedModelAt(t, int(chartUnitRemaining), 0, now) for i := range n { From 3be1dfae7f33f06460d79f4663a6566375fff801 Mon Sep 17 00:00:00 2001 From: martinciu Date: Wed, 10 Jun 2026 23:43:07 +0200 Subject: [PATCH 11/17] tui: bench projects-slide frame render, bar + line modes (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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% --- pkg/tui/projectsspring_bench_test.go | 70 +++++++++++++++------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/pkg/tui/projectsspring_bench_test.go b/pkg/tui/projectsspring_bench_test.go index 7aca1a4..3339c28 100644 --- a/pkg/tui/projectsspring_bench_test.go +++ b/pkg/tui/projectsspring_bench_test.go @@ -1,42 +1,46 @@ package tui import ( - "strconv" "testing" "time" ) -// benchModelForProjects builds a bar model armed mid-slide at the given viewport -// width, so BenchmarkProjectsAnimFrame measures only the in-memory per-frame -// render (rasterize + drawSkyline + label row). The DB-touching setup -// (seedBarModelWithMessages → refreshChart, armProjectsShowForTest → -// refreshProjects) runs once here, outside b.Loop(). -func benchModelForProjects(b *testing.B, vpWidth int, now time.Time) Model { - b.Helper() - m, c := seedBarModelWithMessages(b, int(chartUnitCost), now) - b.Cleanup(func() { _ = c.Close() }) - // Override geometry to the bench width and rebuild the chart inputs against - // it before arming, so the snapshot's vpWidth matches. - m.w = vpWidth + 2 - m.viewport.Width = vpWidth - m.refreshChart() - armProjectsShowForTest(b, &m) - m.projectsAnimH = m.projectsSlideTo / 2 // mid-slide - return m -} - -// BenchmarkProjectsAnimFrame times one full per-frame slide render (rasterize + -// drawSkyline + label row + band slice) at representative chart widths and a -// mid-slide height, confirming the redraw stays within the 60fps (16.7ms) -// budget. Mirrors the BenchmarkBarChartRender width sweep from CLAUDE.md. +// BenchmarkProjectsAnimFrame measures one slide-frame render — the work a +// single springTickMsg does besides the O(1) spring step — in both chart +// modes (round-one finding ccpulse-416.3: line mode was unmeasured). The +// per-frame budget is 16.7ms (60fps); renderWindow's own docs put the bar +// path at ~5ms at viewport width. Heights alternate mid-slide values so the +// rebuild cost reflects varying-height frames, not a memoized best case. func BenchmarkProjectsAnimFrame(b *testing.B) { - now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) - for _, w := range []int{100, 1000, 5000} { - b.Run(strconv.Itoa(w), func(b *testing.B) { - m := benchModelForProjects(b, w, now) - for b.Loop() { - m.renderProjectsFrame() - } - }) - } + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + b.Run("bar", func(b *testing.B) { + m, c := seedBarModelWithMessages(b, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + m.beginProjectsAnimation() // show: arm + 1 aggs query (outside the loop) + target := m.projectsTargetHeight() + b.ReportAllocs() + i := 0 + for b.Loop() { + m.projectsAnimH = 1 + (i % max(target, 2)) // sweep mid-slide heights + m.renderProjectsFrame() + i++ + } + }) + b.Run("line", func(b *testing.B) { + m, c := seedRemainingModelWithSamples(b, 60, now) + defer c.Close() + m.showProjects = false + m.refreshChart() + m.beginProjectsAnimation() + target := m.projectsTargetHeight() + b.ReportAllocs() + i := 0 + for b.Loop() { + m.projectsAnimH = 1 + (i % max(target, 2)) + m.renderProjectsFrame() + i++ + } + }) } From 607c2ffb2682d119b29648dc53865ad82e548173 Mon Sep 17 00:00:00 2001 From: martinciu Date: Wed, 10 Jun 2026 23:47:25 +0200 Subject: [PATCH 12/17] tui: extract handleProjectsKey; gofumpt fallout (#416) The empty-chart guard pushed handleKey over the gocyclo gate; the projects branch moves into its own handler, mirroring handleZoomKey / handleUnitKey. --- pkg/tui/model.go | 40 +++++++++++++++++++--------------- pkg/tui/projectsspring_test.go | 8 +++---- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 4564eb1..fe4ee4f 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -656,24 +656,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case key.Matches(msg, m.keys.Unit): return m.handleUnitKey() case key.Matches(msg, m.keys.Projects): - // Slide the box up (show) / down (hide) via a harmonica spring (#416). - // reduce_motion, a too-short terminal (no room for a box), or an - // empty/cleared chart (renderWindow would no-op against no content) → - // snap, the pre-#416 hard cut. - if m.deps.ReduceMotion || m.projectsTargetHeight() == 0 || m.lastCanvasW == 0 { - m.showProjects = !m.showProjects - m.viewport.Height = m.chartHeight() - m.refreshChart() - return nil - } - m.beginProjectsAnimation() - if !m.springActive { - return nil - } - gen := m.springGen - return tea.Tick(time.Second/time.Duration(springFPS), func(time.Time) tea.Msg { - return springTickMsg{gen: gen} - }) + return m.handleProjectsKey() case key.Matches(msg, m.keys.ScrollLeft): m.scrollLeft(ZoomLevels[m.zoomIdx].ScrollStep) return m.scheduleProjectsTick() @@ -717,6 +700,27 @@ func (m *Model) handleUnitKey() tea.Cmd { }) } +// handleProjectsKey slides the projects box up (show) / down (hide) via a +// harmonica spring (#416). reduce_motion, a too-short terminal (no room for +// a box), or an empty/cleared chart (renderWindow would no-op against no +// content) → snap, the pre-#416 hard cut. +func (m *Model) handleProjectsKey() tea.Cmd { + if m.deps.ReduceMotion || m.projectsTargetHeight() == 0 || m.lastCanvasW == 0 { + m.showProjects = !m.showProjects + m.viewport.Height = m.chartHeight() + m.refreshChart() + return nil + } + m.beginProjectsAnimation() + if !m.springActive { + return nil + } + gen := m.springGen + return tea.Tick(time.Second/time.Duration(springFPS), func(time.Time) tea.Msg { + return springTickMsg{gen: gen} + }) +} + // View implements tea.Model; it renders the full TUI frame — header quota bars, separator, and token histogram. func (m Model) View() string { if m.w == 0 { diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index 849f87f..694bcab 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -24,10 +24,10 @@ func TestLerpInt(t *testing.T) { {0, 12, 0.5, 6}, {12, 0, 0.5, 6}, {12, 0, 1, 0}, - {0, 10, 0.24, 2}, // 2.4 rounds to 2 - {0, 10, 0.25, 3}, // 2.5 rounds to 3 (math.Round) - {0, 10, -0.2, 0}, // r clamps to 0 (spring may undershoot marginally) - {0, 10, 1.3, 10}, // r clamps to 1 (spring may overshoot marginally) + {0, 10, 0.24, 2}, // 2.4 rounds to 2 + {0, 10, 0.25, 3}, // 2.5 rounds to 3 (math.Round) + {0, 10, -0.2, 0}, // r clamps to 0 (spring may undershoot marginally) + {0, 10, 1.3, 10}, // r clamps to 1 (spring may overshoot marginally) } for _, c := range cases { if got := lerpInt(c.a, c.b, c.r); got != c.want { From 56f495038344f519979413eeabed7aaadb793890 Mon Sep 17 00:00:00 2001 From: martinciu Date: Thu, 11 Jun 2026 10:35:47 +0200 Subject: [PATCH 13/17] tui: window line-mode projects-slide frames to the viewport (#416) 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 --- pkg/tui/projectsspring.go | 23 +++++++++---- pkg/tui/projectsspring_bench_test.go | 49 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index 7b8a1d9..83eb361 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -36,18 +36,29 @@ func lerpInt(a, b int, r float64) int { // steady views (#416 round two; round one's parallel skyline/snapshot path // produced mismatched endpoints, shifted+recolored x-labels and an empty // box). Bar modes go through renderWindow (visible slice, flush-right -// slack, on-screen peak, in-bar labels); remaining mode re-issues the -// steady full-canvas line build + offset re-apply. All inputs are -// in-memory — zero DB per frame. +// slack, on-screen peak, in-bar labels); remaining mode uses a WINDOWED +// line build at viewport width (#180 rationale: full-canvas rebuild at +// canvasW=2880 blows the 60fps budget — ~41ms/frame at 30-day history vs. +// the 16.7ms allowance). The windowed render is pixel-identical inside the +// visible region because buildLineChart maps time→col linearly via +// WithTimeRange, so the settle transition to refreshChart's full-canvas +// path does not visibly snap. m.viewportXOffset is NOT changed here (the +// logical scroll position must survive to the settle frame, where +// refreshChart restores the full canvas and re-applies the offset via +// setX). All inputs are in-memory — zero DB per frame. func (m *Model) renderProjectsFrame() { chartH := m.chartHeight() m.viewport.Height = chartH if chartUnit(m.unitIdx) == chartUnitRemaining { zoom := ZoomLevels[m.zoomIdx] - m.viewport.SetContent(buildLineChart(m.lastPts5h, m.lastPts7d, - m.lastChartFrom, m.lastChartTo, m.lastCanvasW, chartH, + vpW := m.viewport.Width + viewFrom, viewTo := m.visibleWindow() + slicedPts5h := slicePointsInRange(m.lastPts5h, viewFrom, viewTo) + slicedPts7d := slicePointsInRange(m.lastPts7d, viewFrom, viewTo) + m.viewport.SetContent(buildLineChart(slicedPts5h, slicedPts7d, + viewFrom, viewTo, vpW, chartH, m.now(), zoom, m.dateOrder, "projects", "")) - m.setX(m.viewportXOffset) + m.viewport.SetXOffset(0) return } m.renderWindow() diff --git a/pkg/tui/projectsspring_bench_test.go b/pkg/tui/projectsspring_bench_test.go index 3339c28..65bccbd 100644 --- a/pkg/tui/projectsspring_bench_test.go +++ b/pkg/tui/projectsspring_bench_test.go @@ -3,8 +3,38 @@ package tui import ( "testing" "time" + + "github.com/martinciu/ccpulse/pkg/anthro" + "github.com/martinciu/ccpulse/pkg/cache" ) +// seedRemainingModelWith30dMessages builds a remaining-mode model with ~30 +// days of 15m-spaced message history (2880 buckets) so that +// EarliestMessageTime widens the canvas to ~2880 cols — the realistic +// worst-case that the line-mode windowing optimisation must stay inside the +// 60fps budget for. Usage samples are also seeded so hasData is true and +// lastPts5h/lastPts7d are populated. Composes seedModelAt (messages) and +// the usage-sample insertion pattern from seedRemainingModelWithSamples. +func seedRemainingModelWith30dMessages(b *testing.B, now time.Time) (Model, *cache.Cache) { + b.Helper() + // 2880 = 30 days × 96 buckets/day at 15m zoom — wide enough to force a + // realistic-history canvas. seedModelAt spaces messages 15m apart. + m, c := seedModelAt(b, int(chartUnitRemaining), 2880, now) + // Add 60 usage samples so the line chart has real utilisation data. + for i := range 60 { + when := now.Add(-time.Duration(i) * 15 * time.Minute) + resets := when.Add(2 * time.Hour) + u := anthro.Usage{ + FiveHour: &anthro.Bucket{Utilization: 20.0 + float64(i)*2.0, ResetsAt: &resets}, + } + if err := c.RecordUsageSample(b.Context(), u, when); err != nil { + b.Fatalf("RecordUsageSample: %v", err) + } + } + m.refreshChart() + return m, c +} + // BenchmarkProjectsAnimFrame measures one slide-frame render — the work a // single springTickMsg does besides the O(1) spring step — in both chart // modes (round-one finding ccpulse-416.3: line mode was unmeasured). The @@ -43,4 +73,23 @@ func BenchmarkProjectsAnimFrame(b *testing.B) { i++ } }) + b.Run("line_30d", func(b *testing.B) { + m, c := seedRemainingModelWith30dMessages(b, now) + defer c.Close() + // Guard: fixture must have a realistic-history canvas width. + if m.lastCanvasW < 2000 { + b.Fatalf("fixture canvas %d, want realistic-history width (>=2000)", m.lastCanvasW) + } + m.showProjects = false + m.refreshChart() + m.beginProjectsAnimation() + target := m.projectsTargetHeight() + b.ReportAllocs() + i := 0 + for b.Loop() { + m.projectsAnimH = 1 + (i % max(target, 2)) + m.renderProjectsFrame() + i++ + } + }) } From 3276811f266245872d9c42a2572cb74609619fc6 Mon Sep 17 00:00:00 2001 From: martinciu Date: Thu, 11 Jun 2026 11:07:21 +0200 Subject: [PATCH 14/17] tui: sync viewport height after mid-slide resize aborts the projects spring (#416) --- pkg/tui/model.go | 12 ++++++++- pkg/tui/projectsspring_test.go | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index fe4ee4f..5a0ee26 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -458,16 +458,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // handleWindowSize re-lays out the viewport, progress bars, and help width on // terminal resize, then re-queries the chart and (re)arms the intro. +// +// viewport.Width is set before refreshChart because renderWindow reads +// m.viewport.Width to build the content. viewport.Height is set AFTER +// refreshChart: refreshChart aborts any in-flight projects slide +// (springActive=false, springKind=None), which changes what chartHeight() +// returns. Assigning Height before the abort would bake the mid-slide value +// into the viewport, leaving it desynced until the next resize or 'p' press. func (m *Model) handleWindowSize(msg tea.WindowSizeMsg) tea.Cmd { m.w, m.h = msg.Width, msg.Height m.viewport.Width = m.chartWidth() - m.viewport.Height = m.chartHeight() // help.Width controls when ShortHelp ellipsizes; if left at 0 // the footer can wrap onto the body row and break chartHeight(). m.help.Width = m.w m.progress = newProgressBar(m.progressWidth()) m.progress7d = newProgressBar(m.progressWidth()) m.refreshChart() + // Assign after refreshChart so the abort of any in-flight spring is + // reflected in the height (chartHeight() reads projectsHeight(), which + // reads projectsAnimH when springKind==springKindProjects). + m.viewport.Height = m.chartHeight() return m.maybeArmIntro() } diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index 694bcab..cda4d9e 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -561,6 +561,51 @@ func TestProjectsKey_RearmMidSlide_ReversesFromCurrentHeight(t *testing.T) { } } +// TestWindowSize_MidSlide_ViewportHeightSynced is the regression test for the +// handleWindowSize desync found in ccpulse-416.17: before the fix, viewport.Height +// was assigned from chartHeight() BEFORE refreshChart() aborted the in-flight +// projects spring. refreshChart sets springActive=false and springKind=None, which +// changes projectsHeight() (and therefore chartHeight()), so the pre-abort +// assignment baked the mid-slide animated value into the viewport. Every frame +// until the next resize or 'p' press over- or under-filled the terminal by up to +// projectsMaxRows rows. The fix moves the Height assignment to after refreshChart. +func TestWindowSize_MidSlide_ViewportHeightSynced(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + // Arm the show slide. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("setup: springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + + // Advance 3-4 ticks so projectsAnimH is mid-flight (never invoke the real + // tea.Tick Cmd — it real-sleeps; drive via constructed springTickMsg). + for range 4 { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight behaviour") + } + + // Fire a resize mid-slide. handleWindowSize must abort the spring and then + // re-assign viewport.Height from the post-abort chartHeight(). + updated, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(Model) + + if got, want := m.viewport.Height, m.chartHeight(); got != want { + t.Errorf("after mid-slide resize: viewport.Height=%d, want chartHeight()=%d (desynced)", got, want) + } + if got := lipgloss.Height(m.View()); got != m.h { + t.Errorf("after mid-slide resize: View height=%d, want terminal height %d", got, m.h) + } +} + // projectAggsBackingPtr returns the backing-array address of a ProjectAggregate // slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice // from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the From 7057c0b722cbff7c51423a50cc08f59d6430caa9 Mon Sep 17 00:00:00 2001 From: martinciu Date: Thu, 11 Jun 2026 11:19:30 +0200 Subject: [PATCH 15/17] tui: keep the steady x-label row during line-mode projects slide (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/tui/projectsspring.go | 29 ++++++++++++++++++-------- pkg/tui/projectsspring_test.go | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go index 83eb361..bfb8c9a 100644 --- a/pkg/tui/projectsspring.go +++ b/pkg/tui/projectsspring.go @@ -19,6 +19,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/harmonica" + "github.com/charmbracelet/x/ansi" ) // lerpInt linearly interpolates between integer heights a and b at @@ -39,13 +40,17 @@ func lerpInt(a, b int, r float64) int { // slack, on-screen peak, in-bar labels); remaining mode uses a WINDOWED // line build at viewport width (#180 rationale: full-canvas rebuild at // canvasW=2880 blows the 60fps budget — ~41ms/frame at 30-day history vs. -// the 16.7ms allowance). The windowed render is pixel-identical inside the -// visible region because buildLineChart maps time→col linearly via -// WithTimeRange, so the settle transition to refreshChart's full-canvas -// path does not visibly snap. m.viewportXOffset is NOT changed here (the -// logical scroll position must survive to the settle frame, where -// refreshChart restores the full canvas and re-applies the offset via -// setX). All inputs are in-memory — zero DB per frame. +// the 16.7ms allowance). The braille body maps time→col linearly via +// WithTimeRange, so the windowed plot lines up with the steady view; the +// x-label row is NOT synthesized for the window (that drops any label +// straddling the window's left edge, whose clipped tail the steady +// viewport still shows) — it is built for the FULL canvas exactly as the +// steady path does, then cut to the visible columns with the same +// ansi.Cut the viewport applies to content, keeping the row byte-stable +// mid-slide. m.viewportXOffset is NOT changed here (the logical scroll +// position must survive to the settle frame, where refreshChart restores +// the full canvas and re-applies the offset via setX). All inputs are +// in-memory — zero DB per frame. func (m *Model) renderProjectsFrame() { chartH := m.chartHeight() m.viewport.Height = chartH @@ -55,9 +60,17 @@ func (m *Model) renderProjectsFrame() { viewFrom, viewTo := m.visibleWindow() slicedPts5h := slicePointsInRange(m.lastPts5h, viewFrom, viewTo) slicedPts7d := slicePointsInRange(m.lastPts7d, viewFrom, viewTo) + // xOff mirrors what setX last applied (m.viewportXOffset is the + // bucket-indexed shadow, already clamped); the cut therefore lands + // on the same columns the steady viewport shows. + xOff := m.viewportXOffset * zoom.stride() + labelRow := ansi.Cut( + renderXLabels(synthLabelStarts(m.lastChartFrom, m.lastChartTo, zoom), + m.lastCanvasW, zoom, m.now(), m.dateOrder), + xOff, xOff+vpW) m.viewport.SetContent(buildLineChart(slicedPts5h, slicedPts7d, viewFrom, viewTo, vpW, chartH, - m.now(), zoom, m.dateOrder, "projects", "")) + m.now(), zoom, m.dateOrder, "projects", labelRow)) m.viewport.SetXOffset(0) return } diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index cda4d9e..af07d89 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -500,6 +500,43 @@ func TestProjectsSlide_XLabelRowStable(t *testing.T) { } } +// TestProjectsSlide_XLabelRowStable_LineMode is the remaining-mode (line chart) +// sibling of TestProjectsSlide_XLabelRowStable. It pins the windowed per-frame +// buildLineChart fidelity: every mid-flight frame rendered by the WINDOWED +// renderProjectsFrame remaining branch (slicePointsInRange + viewport.Width + +// SetXOffset(0)) must keep the steady label row verbatim and must preserve the +// terminal frame height. The endpoint-identity test (TestProjectsSlide_EndpointIdentity_LineMode) +// sees only frame-0 and the settle frame — this test covers the frames in between. +// +// Threshold: buildLineChart emits the x-label row only when chartH >= 6 +// (identical to bar mode); frames below that height have no label row to check. +func TestProjectsSlide_XLabelRowStable_LineMode(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedRemainingModelWithSamples(t, 60, now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + labelRow := findXLabelRow(t, m.View()) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + for i := 0; m.springActive && i < 600; i++ { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + frame := m.View() + if lipgloss.Height(frame) != m.h { + t.Fatalf("tick %d: frame height %d != terminal height %d", i, lipgloss.Height(frame), m.h) + } + // buildLineChart emits the x-label row only when chartH >= 6; skip the + // assertion for shorter frames where no label row is rendered. + if m.springActive && m.chartHeight() >= 6 && !strings.Contains(frame, labelRow) { + t.Fatalf("tick %d (chartH=%d): steady x-label row missing/altered mid-slide (windowed line build)\nwant line: %q", i, m.chartHeight(), labelRow) + } + } +} + // findXLabelRow returns the first View line carrying >= 2 HH:MM time labels // — the bar chart's x-axis row. func findXLabelRow(t *testing.T, view string) string { From b2a2197c8302521067b23a1356f4b925837a4fbc Mon Sep 17 00:00:00 2001 From: martinciu Date: Thu, 11 Jun 2026 11:23:23 +0200 Subject: [PATCH 16/17] tui: assert the painted box boundary in the slide monotonicity test (#416) --- pkg/tui/projectsspring_test.go | 52 +++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index af07d89..a511732 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -655,11 +655,21 @@ func projectAggsBackingPtr(a []cache.ProjectAggregate) uintptr { } // TestProjectsSlide_RealFrame_BoundaryMovesMonotonically drives a real show -// slide tick-by-tick and asserts on the actual painted frame (withForcedColor → -// real ANSI): the box band grows monotonically, the phantom top border is -// present every mid-slide frame, the true top border lands only at settle, and -// total height is conserved. Per the project's real-binary-verification rule, -// the assertion is on View() output (the painted frame), not internal counters. +// slide tick-by-tick and asserts on the PAINTED BOUNDARY in View() output +// (withForcedColor → real ANSI), not on internal counters. +// +// Property: as the box grows (mid-flight, animH in (0, projectsSlideTo)), the +// row index of the box's top border — the LAST "╭" row in the frame — must +// move UP monotonically (decreasing or equal row index). The header is also a +// rounded-bordered block, so the box's top border is always the last ╭ row; +// this is the same detection used by TestView_DuringSlide_HeightConservedRealBorder. +// +// When animH == 0 there is no box band, and the last ╭ row is the header's — +// a much smaller (higher) index. Monotonic tracking is gated on animH > 0 to +// avoid a spurious first sample against the header-only frame. +// +// Cheap riders: per-tick height conservation (Fatalf), and settle assertions +// (title present, animH == projectsSlideTo). func TestProjectsSlide_RealFrame_BoundaryMovesMonotonically(t *testing.T) { withForcedColor(t) now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) @@ -671,8 +681,21 @@ func TestProjectsSlide_RealFrame_BoundaryMovesMonotonically(t *testing.T) { updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) m = updated.(Model) - roundedTop := lipgloss.RoundedBorder().TopLeft // "╭" - prevBand := -1 + // lastRoundedTopRow returns the row index of the last "╭" line in frame, or + // -1 if none. The box's top border is the last such row (the header's ╭ + // rows sit above it). + lastRoundedTopRow := func(frame string) int { + lines := strings.Split(frame, "\n") + idx := -1 + for i, line := range lines { + if strings.Contains(line, "╭") { + idx = i + } + } + return idx + } + + prevBoxTopRow := -1 // -1 = not yet tracking (animH still 0) const maxTicks = 600 for i := range maxTicks { frame := m.View() @@ -680,15 +703,16 @@ func TestProjectsSlide_RealFrame_BoundaryMovesMonotonically(t *testing.T) { t.Fatalf("tick %d: frame height=%d, want %d (conserved)", i, h, m.h) } band := m.projectsAnimH - if band > 0 && band < m.projectsSlideTo { // mid-slide - if !strings.Contains(frame, roundedTop) { - t.Errorf("tick %d (band=%d): phantom top border absent", i, band) - } - if band < prevBand { - t.Errorf("tick %d: band=%d < prev=%d (non-monotonic)", i, band, prevBand) + if band > 0 && band < m.projectsSlideTo { // mid-slide, box is present + boxTopRow := lastRoundedTopRow(frame) + if boxTopRow == -1 { + t.Errorf("tick %d (animH=%d): no ╭ row found in frame (box top border missing)", i, band) + } else if prevBoxTopRow != -1 && boxTopRow > prevBoxTopRow { + // Box top should move UP (lower row index) as the slide grows. + t.Errorf("tick %d: box top row moved DOWN: %d → %d (non-monotonic; box should rise)", i, prevBoxTopRow, boxTopRow) } + prevBoxTopRow = boxTopRow } - prevBand = band if !m.springActive { break } From 297b4185df8c53dff419fddee262f7c8594800f0 Mon Sep 17 00:00:00 2001 From: martinciu Date: Thu, 11 Jun 2026 21:31:45 +0200 Subject: [PATCH 17/17] tui: re-sync viewport height when refreshChart aborts the projects slide (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/tui/projectsspring_test.go | 99 ++++++++++++++++++++++++++++++++++ pkg/tui/series.go | 7 ++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go index a511732..d804bbc 100644 --- a/pkg/tui/projectsspring_test.go +++ b/pkg/tui/projectsspring_test.go @@ -643,6 +643,105 @@ func TestWindowSize_MidSlide_ViewportHeightSynced(t *testing.T) { } } +// TestRefreshMsg_MidSlide_ViewportHeightSynced is the RefreshMsg sibling of +// TestWindowSize_MidSlide_ViewportHeightSynced. Before the fix, refreshChart's +// spring-abort block cleared springActive/springKind, which changed +// projectsHeight() (and therefore chartHeight()), but nothing re-assigned +// m.viewport.Height — it kept the per-frame value renderProjectsFrame had +// last written. Every subsequent View() would paint more or fewer rows than +// m.h until the next resize or 'p'. The fix adds m.viewport.Height = +// m.chartHeight() inside the abort block (same desync class, watcher-refresh +// abort path). +func TestRefreshMsg_MidSlide_ViewportHeightSynced(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + // Arm the show slide. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("setup: springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + + // Advance 4 ticks so projectsAnimH is mid-flight (never invoke the real + // tea.Tick Cmd — it real-sleeps; drive via constructed springTickMsg). + for range 4 { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight behaviour") + } + + // Fire a RefreshMsg mid-slide. refreshChart must abort the spring and + // re-assign viewport.Height from the post-abort chartHeight(). + updated, _ = m.Update(RefreshMsg{}) + m = updated.(Model) + + if m.springActive { + t.Error("after RefreshMsg mid-slide: springActive=true, want false (slide aborted)") + } + if got, want := m.viewport.Height, m.chartHeight(); got != want { + t.Errorf("after mid-slide RefreshMsg: viewport.Height=%d, want chartHeight()=%d (desynced)", got, want) + } + if got := lipgloss.Height(m.View()); got != m.h { + t.Errorf("after mid-slide RefreshMsg: View height=%d, want terminal height %d", got, m.h) + } +} + +// TestNowTick_MidSlide_ViewportHeightSynced is the nowTickMsg sibling of +// TestWindowSize_MidSlide_ViewportHeightSynced. Before the fix, handleNowTick's +// animatingViewport guard excluded springKindProjects, so nowTickMsg called +// refreshChart during a projects slide; the abort block cleared springActive/Kind +// but left viewport.Height at the mid-slide per-frame value. The fix adds +// m.viewport.Height = m.chartHeight() inside the abort block (same desync class, +// live-advance abort path). Note: handleNowTick returns a non-nil Cmd to +// reschedule the next tick — never invoke it (it real-sleeps up to 1h); ignore. +func TestNowTick_MidSlide_ViewportHeightSynced(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + // Arm the show slide. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("setup: springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + + // Advance 4 ticks so projectsAnimH is mid-flight (never invoke the real + // tea.Tick Cmd — it real-sleeps; drive via constructed springTickMsg). + for range 4 { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight behaviour") + } + + // Fire nowTickMsg mid-slide. handleNowTick calls refreshChart (not guarded + // for springKindProjects), which must abort the spring and re-assign + // viewport.Height from the post-abort chartHeight(). Ignore the returned + // Cmd — it reschedules a real tea.Tick that would real-sleep. + updated, _ = m.Update(nowTickMsg{gen: m.nowGen}) + m = updated.(Model) + + if m.springActive { + t.Error("after nowTickMsg mid-slide: springActive=true, want false (slide aborted)") + } + if got, want := m.viewport.Height, m.chartHeight(); got != want { + t.Errorf("after mid-slide nowTickMsg: viewport.Height=%d, want chartHeight()=%d (desynced)", got, want) + } + if got := lipgloss.Height(m.View()); got != m.h { + t.Errorf("after mid-slide nowTickMsg: View height=%d, want terminal height %d", got, m.h) + } +} + // projectAggsBackingPtr returns the backing-array address of a ProjectAggregate // slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice // from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the diff --git a/pkg/tui/series.go b/pkg/tui/series.go index 1e7d108..8201c2e 100644 --- a/pkg/tui/series.go +++ b/pkg/tui/series.go @@ -145,7 +145,12 @@ func (m *Model) refreshChart() { // springKind=springKindNone drops it; projectsAnimH and the slide // from/to endpoints stay set but unread, and showProjects was already // committed at arm so the chart rebuild below reads the correct - // chartHeight. + // chartHeight. Re-sync the viewport widget's Height here: aborting a + // projects slide changes what chartHeight() returns, and renderProjectsFrame + // last wrote a mid-slide value into viewport.Height; without this line every + // subsequent View() paints the wrong number of rows. For unit/zoom aborts + // the assignment is a no-op (their animations never change the height). + m.viewport.Height = m.chartHeight() } // Snapshot the wall-clock scroll anchor BEFORE the rebuild overwrites