summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/flamegraph')
-rw-r--r--internal/tui/flamegraph/animation.go5
-rw-r--r--internal/tui/flamegraph/model.go66
-rw-r--r--internal/tui/flamegraph/model_test.go54
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
}