diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 19:35:08 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 19:35:08 +0200 |
| commit | 013e46d7856a604d4890a880b8bbfb4b8c58202b (patch) | |
| tree | f8b100ccd04a30b212f0fe728c91736087c60fc1 /internal/tui/flamegraph | |
| parent | 0b2d40cf7ff9b26bfd020488b537bdfdd6f852ae (diff) | |
feat(tui): add flamegraph click lineage undo and scope quit key
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 5 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 185 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 122 |
3 files changed, 304 insertions, 8 deletions
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index 06e6d0d..e69d845 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -17,6 +17,7 @@ func (m *Model) clearSnapshotState(clearSearch bool) { m.zoomRoot = nil m.zoomPath = "" m.zoomStack = nil + m.zoomLineWidth = 0 m.selectedIdx = 0 m.snapshot = nil m.globalTotal = 0 @@ -81,7 +82,7 @@ func (m Model) toolbarLine() string { state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]") } order := m.currentFieldPresetLabel() - line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter:zoom | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel()) + line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter/click:zoom | click ancestor:undo | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel()) if m.searchQuery != "" { line += " | filter:" + m.searchQuery } @@ -103,7 +104,7 @@ func (m Model) helpOverlay() string { if width <= 0 { width = 80 } - help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter zoom u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order b metric ? help" + help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter/click zoom click ancestor undo u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order b metric ? help" return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width)) } diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index cc208ae..1d01f66 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -41,6 +41,7 @@ type LiveTrieSource interface { type zoomState struct { path string previousSelectedIdx int + lineWidth int } type flameKeyMap struct { @@ -81,10 +82,11 @@ type Model struct { width int height int - selectedIdx int - zoomStack []zoomState - zoomRoot *snapshotNode - zoomPath string + selectedIdx int + zoomStack []zoomState + zoomRoot *snapshotNode + zoomPath string + zoomLineWidth int searchActive bool searchInput textinput.Model @@ -181,6 +183,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, animTickCmd() } return m, nil + case tea.MouseClickMsg: + _ = m.handleMouseClick(msg) + return m, nil case tea.KeyPressMsg: if m.searchActive { handled := false @@ -339,6 +344,7 @@ func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) { m.zoomStack = nil m.zoomRoot = nil m.zoomPath = "" + m.zoomLineWidth = 0 m.subtreeSet = make(map[int]bool) m.filterVisible = make(map[int]bool) m.animation = NewAnimationState(30, 6.0, 1.0) @@ -461,7 +467,11 @@ func (m *Model) rebuildFrames(animate bool) { } else { root = m.snapshot } - m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) + targetFrames := buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) + if m.zoomPath != "" { + targetFrames = m.withZoomLineage(targetFrames) + } + m.targetFrames = targetFrames m.animation.SetTargets(m.targetFrames) if animate && len(m.frames) > 0 && !m.animation.Settled() { m.animating = true @@ -522,13 +532,18 @@ func (m *Model) zoomIn() { m.statusMessage = "Zoom failed: selected node is unavailable" return } + selectedWidth := m.frames[m.selectedIdx].Width + if selectedWidth < 1 { + selectedWidth = 1 + } m.zoomStack = append(m.zoomStack, zoomState{ path: m.zoomPath, previousSelectedIdx: m.selectedIdx, + lineWidth: m.zoomLineWidth, }) m.zoomRoot = target m.zoomPath = selectedPath - m.selectedIdx = 0 + m.zoomLineWidth = selectedWidth m.rebuildFrames(true) m.statusMessage = "Zoom: " + compactFramePath(selectedPath) } @@ -543,8 +558,10 @@ func (m *Model) zoomUndo() { m.zoomPath = last.path if m.zoomPath == "" { m.zoomRoot = nil + m.zoomLineWidth = 0 } else { m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) + m.zoomLineWidth = last.lineWidth } m.selectedIdx = last.previousSelectedIdx m.rebuildFrames(true) @@ -563,6 +580,7 @@ func (m *Model) zoomReset() { m.zoomRoot = nil m.zoomPath = "" m.zoomStack = nil + m.zoomLineWidth = 0 m.rebuildFrames(false) m.statusMessage = "Zoom reset to root" } @@ -1025,3 +1043,158 @@ func (m *Model) ensureSelectionVisible() { m.selectedIdx = bestIdx } } + +func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool { + if msg.Button != tea.MouseLeft { + return false + } + idx := m.frameIndexAt(msg.X, msg.Y) + if idx < 0 { + return false + } + clickedPath := m.frames[idx].Path + currentRoot := m.currentRootPath() + if m.zoomPath != "" && (clickedPath == currentRoot || hasPathBoundaryPrefix(currentRoot, clickedPath)) { + for steps := 0; steps < len(m.zoomStack)+1 && m.currentRootPath() != clickedPath; steps++ { + m.zoomUndo() + } + if sel := m.frameIndexByPath(clickedPath); sel >= 0 { + m.selectedIdx = sel + } + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + return true + } + m.selectedIdx = idx + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + m.zoomIn() + return true +} + +func (m Model) frameIndexAt(x, y int) int { + if len(m.frames) == 0 || m.width <= 0 || m.height <= 0 { + return -1 + } + if x < 0 || x >= m.width || y < 0 { + return -1 + } + + extraLines := 1 // selection status line + if m.showHelp { + extraLines++ + } + renderHeight := m.height - extraLines + if renderHeight < 3 { + renderHeight = 3 + } + availableRows := renderHeight - 2 // flame toolbar + frame-status line + if availableRows < 1 { + return -1 + } + + // Row 0 is flame toolbar, rows 1..availableRows are bars, last row is status. + if y < 1 || y > availableRows { + return -1 + } + dataRow := y - 1 + + maxRow := maxFrameRowForSet(m.frames, nil) + barHeight := computeBarHeight(availableRows, maxRow+1, maxBarVisualHeight) + visibleDepthRows := availableRows / barHeight + if visibleDepthRows < 1 { + visibleDepthRows = 1 + } + rowOffset := 0 + if maxRow+1 > visibleDepthRows { + rowOffset = maxRow + 1 - visibleDepthRows + } + renderedRows := (maxRow - rowOffset + 1) * barHeight + padTop := 0 + if renderedRows < availableRows { + padTop = availableRows - renderedRows + } + if dataRow < padTop { + return -1 + } + + depthFromTop := (dataRow - padTop) / barHeight + targetRow := maxRow - depthFromTop + + best := -1 + bestWidth := int(^uint(0) >> 1) + for idx, frame := range m.frames { + if frame.Row != targetRow || frame.Col >= m.width { + continue + } + right := min(m.width, frame.Col+frame.Width) + if x < frame.Col || x >= right { + continue + } + if frame.Width < bestWidth { + best = idx + bestWidth = frame.Width + } + } + return best +} + +func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { + if len(frames) == 0 || m.snapshot == nil { + return frames + } + parts := strings.Split(m.zoomPath, pathSeparator) + if len(parts) <= 1 { + return frames + } + + lineWidth := m.zoomLineWidth + if lineWidth <= 0 { + lineWidth = frames[0].Width + } + lineWidth = min(max(lineWidth, 3), max(3, m.width/3)) + if lineWidth >= m.width-2 { + return frames + } + gutter := lineWidth + 1 + if m.width-gutter < minFlameWidth/2 { + return frames + } + + rowShift := len(parts) - 1 + out := make([]tuiFrame, 0, len(frames)+len(parts)) + for _, frame := range frames { + if frame.Path == m.zoomPath { + continue + } + frame.Col += gutter + frame.Row += rowShift + frame.Depth += rowShift + out = append(out, frame) + } + + rootTotal := snapshotTotal(m.snapshot) + for depth := range parts { + path := strings.Join(parts[:depth+1], pathSeparator) + node := findNodeByPath(m.snapshot, path) + total := uint64(0) + if node != nil { + total = snapshotTotal(node) + } + percent := 0.0 + if rootTotal > 0 { + percent = 100 * float64(total) / float64(rootTotal) + } + name := parts[depth] + out = append(out, tuiFrame{ + Name: name, + Col: 0, + Row: depth, + Width: lineWidth, + Total: total, + Percent: percent, + Fill: terminalFrameColor(name), + Depth: depth, + Path: path, + }) + } + return out +} diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index 74ce8d9..bbd2005 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -239,6 +239,93 @@ func TestPausedStateStillAllowsNavigation(t *testing.T) { } } +func TestMouseClickSelectsFrameAndZooms(t *testing.T) { + m := newZoomModel() + targetPath := "root" + pathSeparator + "A" + targetIdx := mustFrameIndex(t, m.frames, targetPath) + + x, y, ok := firstClickablePointForFrame(m, targetIdx) + if !ok { + t.Fatalf("expected clickable point for %q", targetPath) + } + + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + + if got := m.zoomPath; got != targetPath { + t.Fatalf("expected mouse click to zoom into %q, got %q", targetPath, got) + } + if got := m.frames[m.selectedIdx].Path; got != targetPath { + t.Fatalf("expected clicked frame to remain selected after zoom, got %q", got) + } +} + +func TestMouseClickOutsideBarsDoesNotChangeSelectionOrZoom(t *testing.T) { + m := newZoomModel() + beforeSelection := m.selectedIdx + beforeZoom := m.zoomPath + + next, _ := m.Update(tea.MouseClickMsg{X: 1, Y: 0, Button: tea.MouseLeft}) // toolbar row + m = next.(Model) + + if m.selectedIdx != beforeSelection { + t.Fatalf("expected toolbar click to preserve selection, got idx %d want %d", m.selectedIdx, beforeSelection) + } + if m.zoomPath != beforeZoom { + t.Fatalf("expected toolbar click to preserve zoom path, got %q want %q", m.zoomPath, beforeZoom) + } +} + +func TestZoomKeepsNarrowLineageRail(t *testing.T) { + m := newZoomModel() + targetPath := "root" + pathSeparator + "A" + targetIdx := mustFrameIndex(t, m.frames, targetPath) + selectedWidth := m.frames[targetIdx].Width + expectedRailWidth := min(max(selectedWidth, 3), max(3, m.width/3)) + + m.selectedIdx = targetIdx + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + + rootIdx := mustFrameIndex(t, m.frames, "root") + zoomIdx := mustFrameIndex(t, m.frames, targetPath) + if m.frames[rootIdx].Width != expectedRailWidth { + t.Fatalf("expected root lineage width %d, got %d", expectedRailWidth, m.frames[rootIdx].Width) + } + if m.frames[zoomIdx].Width != expectedRailWidth { + t.Fatalf("expected zoom lineage width %d, got %d", expectedRailWidth, m.frames[zoomIdx].Width) + } + if m.frames[rootIdx].Col != 0 || m.frames[zoomIdx].Col != 0 { + t.Fatalf("expected lineage rail at column 0, got root=%d zoom=%d", m.frames[rootIdx].Col, m.frames[zoomIdx].Col) + } +} + +func TestMouseClickOnLineageAncestorUndoesToThatZoomLevel(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want { + t.Fatalf("expected nested zoom path %q, got %q", want, got) + } + + ancestorPath := "root" + pathSeparator + "A" + ancestorIdx := mustFrameIndex(t, m.frames, ancestorPath) + x, y, ok := firstClickablePointForFrame(m, ancestorIdx) + if !ok { + t.Fatalf("expected clickable lineage point for ancestor %q", ancestorPath) + } + + next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + m = next.(Model) + if got := m.zoomPath; got != ancestorPath { + t.Fatalf("expected click on lineage ancestor to undo zoom to %q, got %q", ancestorPath, got) + } +} + func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) { trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") coreflamegraph.SeedTestFlameData(trie) @@ -985,3 +1072,38 @@ func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model { next, _ := m.Update(keyMsg) return next.(Model) } + +func firstClickablePointForFrame(m Model, frameIdx int) (x, y int, ok bool) { + if frameIdx < 0 || frameIdx >= len(m.frames) { + return 0, 0, false + } + frame := m.frames[frameIdx] + left := frame.Col + right := min(m.width, frame.Col+frame.Width) + if left < 0 { + left = 0 + } + if right <= left { + return 0, 0, false + } + for row := 0; row < m.height; row++ { + for col := left; col < right; col++ { + if m.frameIndexAt(col, row) == frameIdx { + return col, row, true + } + } + } + return 0, 0, false +} + +func settleFlameAnimation(t *testing.T, m Model) Model { + t.Helper() + for i := 0; i < 240 && m.animating; i++ { + next, _ := m.Update(animTickMsg{}) + m = next.(Model) + } + if m.animating { + t.Fatalf("expected flame animation to settle within 240 ticks") + } + return m +} |
