summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 22:39:21 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 22:39:21 +0200
commit4e464d082e0c83f33f4b4659859b8a9be58987e1 (patch)
tree4005e44389c67e0d1daa64dfbe032d096c78f129 /internal
parent3307447e4ae159b11bbe262ad161d6e3c571ee4c (diff)
task 359: add flamegraph zoom interactions
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/flamegraph/model.go96
-rw-r--r--internal/tui/flamegraph/model_test.go72
-rw-r--r--internal/tui/flamegraph/renderer.go7
-rw-r--r--internal/tui/flamegraph/zoom.go39
4 files changed, 204 insertions, 10 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index 8c8d434..c4ca94a 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -20,8 +20,8 @@ type snapshotNode struct {
}
type zoomState struct {
- path string
- selectedIdx int
+ path string
+ previousSelectedIdx int
}
type frameSpring struct{}
@@ -31,6 +31,9 @@ type flameKeyMap struct {
MoveDeeper key.Binding
PrevSibling key.Binding
NextSibling key.Binding
+ ZoomIn key.Binding
+ ZoomUndo key.Binding
+ ZoomReset key.Binding
}
func defaultFlameKeyMap() flameKeyMap {
@@ -39,6 +42,9 @@ func defaultFlameKeyMap() flameKeyMap {
MoveDeeper: key.NewBinding(key.WithKeys("k", "up")),
PrevSibling: key.NewBinding(key.WithKeys("h", "left")),
NextSibling: key.NewBinding(key.WithKeys("l", "right")),
+ ZoomIn: key.NewBinding(key.WithKeys("enter")),
+ ZoomUndo: key.NewBinding(key.WithKeys("backspace", "u")),
+ ZoomReset: key.NewBinding(key.WithKeys("esc")),
}
}
@@ -56,6 +62,7 @@ type Model struct {
selectedIdx int
zoomStack []zoomState
zoomRoot *snapshotNode
+ zoomPath string
searchActive bool
searchQuery string
@@ -111,6 +118,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
prev := m.selectedIdx
switch {
+ case key.Matches(msg, m.keys.ZoomIn):
+ m.zoomIn()
+ case key.Matches(msg, m.keys.ZoomUndo):
+ m.zoomUndo()
+ case key.Matches(msg, m.keys.ZoomReset):
+ m.zoomReset()
case key.Matches(msg, m.keys.MoveShallower):
m.moveVertical(-1)
case key.Matches(msg, m.keys.MoveDeeper):
@@ -144,6 +157,9 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) {
m.selectedIdx = 0
m.frames = nil
m.targetFrames = nil
+ m.zoomStack = nil
+ m.zoomRoot = nil
+ m.zoomPath = ""
m.subtreeSet = make(map[int]bool)
}
@@ -162,11 +178,13 @@ func (m *Model) RefreshFromLiveTrie() bool {
if err := json.Unmarshal(payload, &snapshot); err != nil {
return false
}
- m.targetFrames = BuildTerminalLayout(&snapshot, m.width, m.height)
- m.frames = append(m.frames[:0], m.targetFrames...)
- m.clampSelection()
- m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx)
m.snapshot = &snapshot
+ if m.zoomPath != "" {
+ m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath)
+ } else {
+ m.zoomRoot = nil
+ }
+ m.rebuildFrames()
m.lastVersion = version
return true
}
@@ -180,10 +198,7 @@ func (m Model) LastVersion() uint64 {
func (m *Model) SetViewport(width, height int) {
m.width = width
m.height = height
- m.targetFrames = BuildTerminalLayout(m.snapshot, width, height)
- m.frames = append(m.frames[:0], m.targetFrames...)
- m.clampSelection()
- m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx)
+ m.rebuildFrames()
}
// SetDarkMode sets the active color theme mode.
@@ -191,6 +206,67 @@ func (m *Model) SetDarkMode(isDark bool) {
m.isDark = isDark
}
+func (m *Model) rebuildFrames() {
+ var root *snapshotNode
+ rootPath := ""
+ if m.zoomRoot != nil {
+ root = m.zoomRoot
+ rootPath = m.zoomPath
+ } else {
+ root = m.snapshot
+ }
+ m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath)
+ m.frames = append(m.frames[:0], m.targetFrames...)
+ m.clampSelection()
+ m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx)
+}
+
+func (m *Model) zoomIn() {
+ if len(m.frames) == 0 || m.snapshot == nil {
+ return
+ }
+ m.clampSelection()
+ selectedPath := m.frames[m.selectedIdx].Path
+ target := findNodeByPath(m.snapshot, selectedPath)
+ if target == nil {
+ return
+ }
+ m.zoomStack = append(m.zoomStack, zoomState{
+ path: m.zoomPath,
+ previousSelectedIdx: m.selectedIdx,
+ })
+ m.zoomRoot = target
+ m.zoomPath = selectedPath
+ m.selectedIdx = 0
+ m.rebuildFrames()
+}
+
+func (m *Model) zoomUndo() {
+ if len(m.zoomStack) == 0 || m.snapshot == nil {
+ return
+ }
+ last := m.zoomStack[len(m.zoomStack)-1]
+ m.zoomStack = m.zoomStack[:len(m.zoomStack)-1]
+ m.zoomPath = last.path
+ if m.zoomPath == "" {
+ m.zoomRoot = nil
+ } else {
+ m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath)
+ }
+ m.selectedIdx = last.previousSelectedIdx
+ m.rebuildFrames()
+}
+
+func (m *Model) zoomReset() {
+ if m.zoomRoot == nil && len(m.zoomStack) == 0 {
+ return
+ }
+ m.zoomRoot = nil
+ m.zoomPath = ""
+ m.zoomStack = nil
+ m.rebuildFrames()
+}
+
func (m *Model) moveVertical(delta int) {
if len(m.frames) == 0 {
return
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index 4569296..f79b095 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -126,6 +126,78 @@ func TestKeyboardNavigationSingleNodeClamped(t *testing.T) {
}
}
+func TestZoomInUndoResetAndNestedZoom(t *testing.T) {
+ m := newZoomModel()
+
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want {
+ t.Fatalf("expected zoomPath %q, got %q", want, got)
+ }
+ if len(m.zoomStack) != 1 || m.zoomStack[0].path != "" {
+ t.Fatalf("expected one zoom stack entry from root, got %#v", m.zoomStack)
+ }
+ if m.zoomRoot == nil || m.zoomRoot.Name != "A" {
+ t.Fatalf("expected zoomRoot A, got %+v", m.zoomRoot)
+ }
+
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want {
+ t.Fatalf("expected nested zoomPath %q, got %q", want, got)
+ }
+ if len(m.zoomStack) != 2 || m.zoomStack[1].path != "root"+pathSeparator+"A" {
+ t.Fatalf("expected nested zoom stack to preserve parent path, got %#v", m.zoomStack)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyBackspace})
+ if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want {
+ t.Fatalf("expected zoomPath after undo %q, got %q", want, got)
+ }
+ if len(m.zoomStack) != 1 {
+ t.Fatalf("expected one stack entry after undo, got %d", len(m.zoomStack))
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
+ if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 {
+ t.Fatalf("expected zoom reset to root state, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack))
+ }
+}
+
+func newZoomModel() Model {
+ m := NewModel(nil)
+ m.width = 120
+ m.height = 30
+ m.snapshot = &snapshotNode{
+ Name: "root",
+ Total: 100,
+ Children: []*snapshotNode{
+ {
+ Name: "A",
+ Total: 60,
+ Children: []*snapshotNode{
+ {Name: "A1", Total: 30},
+ {Name: "A2", Total: 30},
+ },
+ },
+ {Name: "B", Total: 40},
+ },
+ }
+ m.rebuildFrames()
+ return m
+}
+
+func mustFrameIndex(t *testing.T, frames []tuiFrame, path string) int {
+ t.Helper()
+ for idx, frame := range frames {
+ if frame.Path == path {
+ return idx
+ }
+ }
+ t.Fatalf("frame path %q not found", path)
+ return -1
+}
+
func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model {
t.Helper()
next, _ := m.Update(keyMsg)
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
index 8b2aff0..5e223a6 100644
--- a/internal/tui/flamegraph/renderer.go
+++ b/internal/tui/flamegraph/renderer.go
@@ -18,6 +18,10 @@ const minFlameWidth = 60
// BuildTerminalLayout converts a live trie snapshot into terminal frame cells.
func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame {
+ return buildTerminalLayoutWithPath(snapshot, width, height, "")
+}
+
+func buildTerminalLayoutWithPath(snapshot *snapshotNode, width, height int, rootPath string) []tuiFrame {
if snapshot == nil || width <= 0 || height <= 0 {
return nil
}
@@ -27,6 +31,9 @@ func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame {
}
rootName := frameName(snapshot.Name, 0)
+ if rootPath != "" {
+ rootName = rootPath
+ }
frames := make([]tuiFrame, 0, len(snapshot.Children)+1)
collectTerminalLayout(&frames, snapshot, rootTotal, width, height, 0, 0, rootName)
return frames
diff --git a/internal/tui/flamegraph/zoom.go b/internal/tui/flamegraph/zoom.go
new file mode 100644
index 0000000..7a3aa42
--- /dev/null
+++ b/internal/tui/flamegraph/zoom.go
@@ -0,0 +1,39 @@
+package flamegraph
+
+import "strings"
+
+func findNodeByPath(root *snapshotNode, path string) *snapshotNode {
+ if root == nil {
+ return nil
+ }
+ if path == "" {
+ return root
+ }
+ parts := strings.Split(path, pathSeparator)
+ if len(parts) == 0 {
+ return root
+ }
+ rootName := frameName(root.Name, 0)
+ if parts[0] == rootName {
+ parts = parts[1:]
+ }
+
+ node := root
+ for _, part := range parts {
+ next := findChildByName(node, part)
+ if next == nil {
+ return nil
+ }
+ node = next
+ }
+ return node
+}
+
+func findChildByName(node *snapshotNode, name string) *snapshotNode {
+ for _, child := range node.Children {
+ if child.Name == name || frameName(child.Name, 1) == name {
+ return child
+ }
+ }
+ return nil
+}