Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
962ed48
tui: scaffold projects-slide state + height lever (#416)
martinciu Jun 9, 2026
31cf8b3
tui: per-frame projects-slide render (band slice + chart rescale) (#416)
martinciu Jun 9, 2026
c3f4ffb
tui: projects-slide tick handler + dispatch (#416)
martinciu Jun 9, 2026
27048b3
tui: arm projects slide on 'p' + coexistence with u/z (#416)
martinciu Jun 9, 2026
c0b0984
tui: real-binary + bench coverage for projects slide (#416)
martinciu Jun 9, 2026
9f9495d
tui: render projects-slide chart frames via the steady pipeline (#416)
martinciu Jun 10, 2026
0b6fa44
tui: re-flow projects box at animated height; drop band/phantom path …
martinciu Jun 10, 2026
3bd5189
tui: steady y-overlay + x-label stability during projects slide (#416)
martinciu Jun 10, 2026
fb7efb7
tui: dissolve projects-slide snapshot; re-arm reverses from current h…
martinciu Jun 10, 2026
c904fe6
tui: line-mode endpoint identity for projects slide (#416)
martinciu Jun 10, 2026
3be1dfa
tui: bench projects-slide frame render, bar + line modes (#416)
martinciu Jun 10, 2026
607c2ff
tui: extract handleProjectsKey; gofumpt fallout (#416)
martinciu Jun 10, 2026
56f4950
tui: window line-mode projects-slide frames to the viewport (#416)
martinciu Jun 11, 2026
3276811
tui: sync viewport height after mid-slide resize aborts the projects …
martinciu Jun 11, 2026
7057c0b
tui: keep the steady x-label row during line-mode projects slide (#416)
martinciu Jun 11, 2026
b2a2197
tui: assert the painted box boundary in the slide monotonicity test (…
martinciu Jun 11, 2026
297b418
tui: re-sync viewport height when refreshChart aborts the projects sl…
martinciu Jun 11, 2026
c5efe3a
Merge branch 'main' into 416-animate-projects-box
martinciu Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions pkg/tui/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
86 changes: 71 additions & 15 deletions pkg/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const (
springKindNone springKind = iota
springKindUnit
springKindZoom
springKindProjects
)

// Model is the root Bubble Tea model for the chart view.
Expand Down Expand Up @@ -268,6 +269,20 @@ type Model struct {
zoomSpringR float64
zoomSpringVel float64
zoomSnap zoomAnimSnapshot
// 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
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
Expand Down Expand Up @@ -443,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()
}

Expand Down Expand Up @@ -590,9 +615,10 @@ func (m *Model) handleProjectsTick(msg projectsTickMsg) {
// the bar-height normalization base, and applyProjectsResize calls
// renderWindow (bar mode) / buildLineChart (remaining mode) which would
// overwrite it, corrupting the spring frames and flashing steady-state
// content (#420). The deferred recompute is never lost — both spring settle
// paths call refreshChart (pkg/tui/springs.go settle, pkg/tui/zoomspring.go
// settle), whose pre-paint refreshProjects + height re-sync catches it.
// content (#420). The deferred recompute is never lost — every spring
// settle path calls refreshChart (pkg/tui/springs.go, pkg/tui/zoomspring.go,
// and the projects slide in pkg/tui/projectsspring.go, #416), whose
// pre-paint refreshProjects + height re-sync catches it.
if m.springActive {
return
}
Expand Down Expand Up @@ -654,16 +680,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):
// 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
return m.handleProjectsKey()
case key.Matches(msg, m.keys.ScrollLeft):
m.scrollLeft(ZoomLevels[m.zoomIdx].ScrollStep)
return m.scheduleProjectsTick()
Expand Down Expand Up @@ -707,6 +724,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 {
Expand All @@ -729,7 +767,12 @@ 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 {
if ph := m.projectsHeight(); ph > 0 {
parts = append(parts, renderProjectsBox(m.projectAggs, m.w, ph))
Expand Down Expand Up @@ -770,6 +813,19 @@ 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:
// 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.peak, chartUnit(m.unitIdx),
m.chartHeight(), 1.0, ZoomLevels[m.zoomIdx].hasInBarNumbers())
case m.springActive:
var maxR float64
for _, r := range m.springRatios {
Expand Down
2 changes: 1 addition & 1 deletion pkg/tui/model_nowtick_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions pkg/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5692,6 +5692,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

Expand Down Expand Up @@ -5729,6 +5733,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) {
Expand Down Expand Up @@ -5756,6 +5764,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'}})
Expand Down
34 changes: 34 additions & 0 deletions pkg/tui/projects_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"fmt"
"math"
"strings"

"github.com/charmbracelet/lipgloss"

Expand Down Expand Up @@ -36,7 +37,16 @@ func projectCellCols(outerWidth int) int {
// (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).
Expand All @@ -52,6 +62,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)

Expand Down Expand Up @@ -104,6 +121,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.
//
Expand Down
31 changes: 31 additions & 0 deletions pkg/tui/projects_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading