diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:49:20 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:49:20 +0200 |
| commit | 476cfb1aa569b42e7bab6cbfd5e548c9e85ee07c (patch) | |
| tree | 74c2a854955e24ff1dd638b4cfd8a0120b576cc4 /internal/tui/flamegraph | |
| parent | fd75c310b7571a48db9135360d99a331638721bc (diff) | |
task 363: animate flamegraph data refresh transitions
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/animation.go | 5 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 66 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 54 |
3 files changed, 111 insertions, 14 deletions
diff --git a/internal/tui/flamegraph/animation.go b/internal/tui/flamegraph/animation.go index 900231d..de3635d 100644 --- a/internal/tui/flamegraph/animation.go +++ b/internal/tui/flamegraph/animation.go @@ -117,6 +117,11 @@ func (a AnimationState) CurrentFrames() []tuiFrame { return frames } +// Settled reports whether all active springs are at rest. +func (a AnimationState) Settled() bool { + return a.settled +} + func isSpringSettled(s frameSpring) bool { return math.Abs(s.currentW-s.targetW) < springEpsilon && math.Abs(s.currentCol-s.targetCol) < springEpsilon && diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 11475b8..c1693b1 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -7,6 +7,7 @@ import ( coreflamegraph "ior/internal/flamegraph" common "ior/internal/tui/common" "sort" + "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" @@ -20,6 +21,10 @@ type snapshotNode struct { Children []*snapshotNode `json:"c,omitempty"` } +type animTickMsg struct{} + +const animFrameDuration = 33 * time.Millisecond + type zoomState struct { path string previousSelectedIdx int @@ -74,10 +79,11 @@ type Model struct { fieldPresets [][]string fieldIndex int - springs []frameSpring - paused bool - isDark bool - keys flameKeyMap + animation AnimationState + animating bool + paused bool + isDark bool + keys flameKeyMap } // tuiFrame stores one terminal flamegraph frame cell. @@ -112,8 +118,9 @@ func NewModel(liveTrie *coreflamegraph.LiveTrie) Model { {"tracepoint", "comm", "path"}, {"pid", "path", "tracepoint"}, }, - isDark: true, - keys: defaultFlameKeyMap(), + isDark: true, + keys: defaultFlameKeyMap(), + animation: NewAnimationState(30, 6.0, 1.0), } } @@ -125,6 +132,18 @@ func (m Model) Init() tea.Cmd { // Update handles incoming messages. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case animTickMsg: + if !m.animating { + return m, nil + } + m.animating = m.animation.Tick(0) + m.frames = m.animation.CurrentFrames() + m.clampSelection() + m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + if m.animating { + return m, animTickCmd() + } + return m, nil case tea.KeyPressMsg: if m.searchActive { switch msg.String() { @@ -209,6 +228,8 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { m.zoomRoot = nil m.zoomPath = "" m.subtreeSet = make(map[int]bool) + m.animation = NewAnimationState(30, 6.0, 1.0) + m.animating = false } // RefreshFromLiveTrie loads a new snapshot when the source version changes. @@ -235,7 +256,7 @@ func (m *Model) RefreshFromLiveTrie() bool { } else { m.zoomRoot = nil } - m.rebuildFrames() + m.rebuildFrames(true) m.lastVersion = version return true } @@ -245,6 +266,14 @@ func (m Model) LastVersion() uint64 { return m.lastVersion } +// AnimationCmd returns a frame animation tick command when animation is active. +func (m Model) AnimationCmd() tea.Cmd { + if !m.animating { + return nil + } + return animTickCmd() +} + // Paused reports whether live refresh is paused. func (m Model) Paused() bool { return m.paused @@ -254,7 +283,7 @@ func (m Model) Paused() bool { func (m *Model) SetViewport(width, height int) { m.width = width m.height = height - m.rebuildFrames() + m.rebuildFrames(false) } // SetDarkMode sets the active color theme mode. @@ -263,7 +292,7 @@ func (m *Model) SetDarkMode(isDark bool) { m.searchInput.SetStyles(textinput.DefaultStyles(isDark)) } -func (m *Model) rebuildFrames() { +func (m *Model) rebuildFrames(animate bool) { var root *snapshotNode rootPath := "" if m.zoomRoot != nil { @@ -273,7 +302,14 @@ func (m *Model) rebuildFrames() { root = m.snapshot } m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) - m.frames = append(m.frames[:0], m.targetFrames...) + m.animation.SetTargets(m.targetFrames) + if animate && len(m.frames) > 0 && !m.animation.Settled() { + m.animating = true + m.frames = m.animation.CurrentFrames() + } else { + m.animating = false + m.frames = append(m.frames[:0], m.targetFrames...) + } m.clampSelection() m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) } @@ -295,7 +331,7 @@ func (m *Model) zoomIn() { m.zoomRoot = target m.zoomPath = selectedPath m.selectedIdx = 0 - m.rebuildFrames() + m.rebuildFrames(false) } func (m *Model) zoomUndo() { @@ -311,7 +347,7 @@ func (m *Model) zoomUndo() { m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) } m.selectedIdx = last.previousSelectedIdx - m.rebuildFrames() + m.rebuildFrames(false) } func (m *Model) zoomReset() { @@ -321,7 +357,7 @@ func (m *Model) zoomReset() { m.zoomRoot = nil m.zoomPath = "" m.zoomStack = nil - m.rebuildFrames() + m.rebuildFrames(false) } func (m *Model) moveVertical(delta int) { @@ -415,3 +451,7 @@ func abs(v int) int { } return v } + +func animTickCmd() tea.Cmd { + return tea.Tick(animFrameDuration, func(time.Time) tea.Msg { return animTickMsg{} }) +} diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index 5271dee..45e0c48 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -275,6 +275,58 @@ func TestControlHelpToggle(t *testing.T) { } } +func TestDataRefreshAnimationConvergesOverTicks(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + {Name: "A", Total: 60}, + {Name: "B", Total: 40}, + }, + } + m.rebuildFrames(false) + initial := append([]tuiFrame(nil), m.frames...) + + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + {Name: "A", Total: 20}, + {Name: "B", Total: 80}, + }, + } + m.rebuildFrames(true) + if !m.animating { + t.Fatalf("expected animation to start after animated rebuild") + } + + next, _ := m.Update(animTickMsg{}) + m = next.(Model) + if len(m.frames) != len(initial) { + t.Fatalf("expected frame count to remain stable during animation") + } + + for i := 0; i < 180 && m.animating; i++ { + next, _ = m.Update(animTickMsg{}) + m = next.(Model) + } + if m.animating { + t.Fatalf("expected animation to settle within 180 ticks") + } + if len(m.frames) != len(m.targetFrames) { + t.Fatalf("expected settled frame count to match targets") + } + for i := range m.frames { + if m.frames[i].Width != m.targetFrames[i].Width || m.frames[i].Col != m.targetFrames[i].Col { + t.Fatalf("frame %d did not converge to target: got col=%d width=%d want col=%d width=%d", + i, m.frames[i].Col, m.frames[i].Width, m.targetFrames[i].Col, m.targetFrames[i].Width) + } + } +} + func newZoomModel() Model { m := NewModel(nil) m.width = 120 @@ -294,7 +346,7 @@ func newZoomModel() Model { {Name: "B", Total: 40}, }, } - m.rebuildFrames() + m.rebuildFrames(false) return m } |
