summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 08:02:58 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 08:02:58 +0200
commitc6ec3b3ee34c9e77daa7159e8c164e413c2101b5 (patch)
tree0b7bba3228caddbdc24614c1734560a64dcd7770 /internal/tui/flamegraph
parent1955effb0daa5269d1b4069f3fb5175dbc29793f (diff)
Improve flamegraph selection, filter, and zoom feedback
Diffstat (limited to 'internal/tui/flamegraph')
-rw-r--r--internal/tui/flamegraph/controls.go5
-rw-r--r--internal/tui/flamegraph/model.go25
-rw-r--r--internal/tui/flamegraph/model_test.go42
-rw-r--r--internal/tui/flamegraph/renderer.go67
-rw-r--r--internal/tui/flamegraph/renderer_test.go28
-rw-r--r--internal/tui/flamegraph/search.go5
6 files changed, 156 insertions, 16 deletions
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index b0a2933..74a248d 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -63,7 +63,10 @@ func (m Model) toolbarLine() string {
state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]")
}
order := m.currentFieldPresetLabel()
- line := fmt.Sprintf("%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | p:pause", state, order)
+ line := fmt.Sprintf("%s | view:%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | p:pause", state, compactFramePath(m.currentRootPath()), order)
+ if m.searchQuery != "" {
+ line += " | filter:" + m.searchQuery
+ }
if m.statusMessage != "" {
line += " | " + m.statusMessage
}
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index bbf4af3..4453452 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -353,12 +353,18 @@ func (m *Model) rebuildFrames(animate bool) {
func (m *Model) zoomIn() {
if len(m.frames) == 0 || m.snapshot == nil {
+ m.statusMessage = "Zoom unavailable: no frame selected"
return
}
m.clampSelection()
selectedPath := m.frames[m.selectedIdx].Path
+ if selectedPath == m.currentRootPath() {
+ m.statusMessage = "Zoom unchanged: selected frame is current view root"
+ return
+ }
target := findNodeByPath(m.snapshot, selectedPath)
if target == nil {
+ m.statusMessage = "Zoom failed: selected node is unavailable"
return
}
m.zoomStack = append(m.zoomStack, zoomState{
@@ -369,10 +375,12 @@ func (m *Model) zoomIn() {
m.zoomPath = selectedPath
m.selectedIdx = 0
m.rebuildFrames(true)
+ m.statusMessage = "Zoom: " + compactFramePath(selectedPath)
}
func (m *Model) zoomUndo() {
if len(m.zoomStack) == 0 || m.snapshot == nil {
+ m.statusMessage = "Zoom undo unavailable"
return
}
last := m.zoomStack[len(m.zoomStack)-1]
@@ -385,16 +393,23 @@ func (m *Model) zoomUndo() {
}
m.selectedIdx = last.previousSelectedIdx
m.rebuildFrames(true)
+ if m.zoomPath == "" {
+ m.statusMessage = "Zoom: root"
+ return
+ }
+ m.statusMessage = "Zoom: " + compactFramePath(m.zoomPath)
}
func (m *Model) zoomReset() {
if m.zoomRoot == nil && len(m.zoomStack) == 0 {
+ m.statusMessage = "Zoom already at root"
return
}
m.zoomRoot = nil
m.zoomPath = ""
m.zoomStack = nil
m.rebuildFrames(false)
+ m.statusMessage = "Zoom reset to root"
}
func (m *Model) moveVertical(delta int) {
@@ -492,3 +507,13 @@ func abs(v int) int {
func animTickCmd() tea.Cmd {
return tea.Tick(animFrameDuration, func(time.Time) tea.Msg { return animTickMsg{} })
}
+
+func (m Model) currentRootPath() string {
+ if m.zoomPath != "" {
+ return m.zoomPath
+ }
+ if len(m.frames) == 0 {
+ return ""
+ }
+ return m.frames[0].Path
+}
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index 8bd1b79..ccb5c94 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -166,6 +166,19 @@ func TestZoomInUndoResetAndNestedZoom(t *testing.T) {
}
}
+func TestZoomInOnCurrentRootSetsStatusMessage(t *testing.T) {
+ m := newZoomModel()
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root")
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.zoomPath != "" {
+ t.Fatalf("expected zoom path to remain root, got %q", m.zoomPath)
+ }
+ if m.statusMessage != "Zoom unchanged: selected frame is current view root" {
+ t.Fatalf("unexpected status message: %q", m.statusMessage)
+ }
+}
+
func TestZoomTransitionAnimatesToNewLayout(t *testing.T) {
m := newZoomModel()
pathA := "root" + pathSeparator + "A"
@@ -243,6 +256,35 @@ func TestSearchEscapeClearsState(t *testing.T) {
if m.searchQuery != "" || len(m.matchIndices) != 0 {
t.Fatalf("expected search state to reset on escape, got query=%q matches=%d", m.searchQuery, len(m.matchIndices))
}
+ if m.statusMessage != "Filter cleared" {
+ t.Fatalf("expected filter cleared status message, got %q", m.statusMessage)
+ }
+}
+
+func TestSearchSubmitSetsFilterStatusMessage(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "alpha", Path: "root" + pathSeparator + "alpha"},
+ {Name: "beta", Path: "root" + pathSeparator + "beta"},
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.statusMessage != `Filter "a": 2 matches` {
+ t.Fatalf("unexpected status after applying filter: %q", m.statusMessage)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"})
+ for _, r := range []rune{'z', 'z'} {
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.statusMessage != `Filter "zz": no matches` {
+ t.Fatalf("unexpected status for unmatched filter: %q", m.statusMessage)
+ }
}
func TestControlPauseToggle(t *testing.T) {
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
index f06f6bc..4f376a5 100644
--- a/internal/tui/flamegraph/renderer.go
+++ b/internal/tui/flamegraph/renderer.go
@@ -165,17 +165,19 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
selectedIdx = 0
}
selected := frames[selectedIdx]
+ viewPath := compactFramePath(frames[0].Path)
+ filterActive := strings.TrimSpace(searchQuery) != ""
if subtreeSet == nil {
subtreeSet = computeSubtreeSet(frames, selectedIdx)
}
- toolbar := fmt.Sprintf("Flame | frames:%d | rows:%d", len(frames), availableRows)
+ toolbar := fmt.Sprintf("Flame | view:%s | frames:%d | rows:%d", viewPath, len(frames), availableRows)
if truncated {
toolbar += " | showing deepest levels"
}
toolbar = padOrTrim(toolbar, width)
- status := fmt.Sprintf("Selected: %s %.2f%% total=%d depth=%d", selected.Name, selected.Percent, selected.Total, selected.Depth)
- if searchQuery != "" {
+ status := fmt.Sprintf("Selected: %s [%s] %.2f%% total=%d depth=%d", selected.Name, compactFramePath(selected.Path), selected.Percent, selected.Total, selected.Depth)
+ if filterActive {
matches := orderedMatchIndices(matchSet)
pos := 0
if len(matches) > 0 {
@@ -183,11 +185,11 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr
pos = idx + 1
}
}
- status = fmt.Sprintf("Search %q %d/%d matches", searchQuery, pos, len(matches))
+ status += fmt.Sprintf(" | Filter %q %d/%d", searchQuery, pos, len(matches))
}
status = padOrTrim(status, width)
- rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive)
+ rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive)
var b strings.Builder
b.Grow((width + 1) * (len(rows) + 2))
@@ -206,7 +208,7 @@ type indexedFrame struct {
frame tuiFrame
}
-func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive bool) []string {
+func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string {
rowsByDepth := make(map[int][]indexedFrame)
for idx, frame := range frames {
if frame.Row < rowOffset || frame.Row > maxRow {
@@ -221,12 +223,12 @@ func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPa
sort.Slice(framesAtRow, func(i, j int) bool {
return framesAtRow[i].frame.Col < framesAtRow[j].frame.Col
})
- rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive))
+ rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive))
}
return rows
}
-func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive bool) string {
+func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) string {
if len(frames) == 0 {
return strings.Repeat(" ", width)
}
@@ -251,8 +253,8 @@ func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet
if cellWidth <= 0 {
continue
}
- label := padOrTrim(frame.Name, cellWidth)
- style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive)
+ label := frameLabel(frame.Name, cellWidth, item.idx == selectedIdx, matchSet != nil && matchSet[item.idx])
+ style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive)
cell := style.Render(label)
b.WriteString(cell)
cursor = frame.Col + cellWidth
@@ -301,7 +303,8 @@ func hasPathBoundaryPrefix(value, prefix string) bool {
return value[len(prefix)] == pathSeparatorByte
}
-func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive bool) lipgloss.Style {
+func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) lipgloss.Style {
+ _ = searchActive
base := lipgloss.NewStyle().
Foreground(common.ColorBackground).
Background(frame.Fill)
@@ -316,18 +319,24 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat
}
if isSelected {
- return base.Bold(true).Reverse(true).Underline(true)
+ selectedBg := lipgloss.Color("226")
+ selectedFg := lipgloss.Color("16")
+ if !isDark {
+ selectedBg = lipgloss.Color("160")
+ selectedFg = lipgloss.Color("15")
+ }
+ return base.Background(selectedBg).Foreground(selectedFg).Bold(true).Underline(true)
}
if isMatch {
- style := base.Background(matchColor)
+ style := base.Background(matchColor).Foreground(lipgloss.Color("15"))
if inSubtree {
return style.Bold(true)
}
return style.Faint(true)
}
- if searchActive {
+ if filterActive {
return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true)
}
@@ -341,6 +350,36 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat
return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true)
}
+func frameLabel(name string, width int, isSelected, isMatch bool) string {
+ if width <= 0 {
+ return ""
+ }
+ if isSelected {
+ if width == 1 {
+ return ">"
+ }
+ return ">" + padOrTrim(name, width-2) + "<"
+ }
+ if isMatch {
+ if width == 1 {
+ return "*"
+ }
+ return "*" + padOrTrim(name, width-1)
+ }
+ return padOrTrim(name, width)
+}
+
+func compactFramePath(path string) string {
+ if path == "" {
+ return "root"
+ }
+ parts := strings.Split(path, pathSeparator)
+ if len(parts) <= 3 {
+ return strings.Join(parts, "/")
+ }
+ return strings.Join([]string{parts[0], "...", parts[len(parts)-1]}, "/")
+}
+
type relation int
const (
diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go
index eb111b8..2bd93fc 100644
--- a/internal/tui/flamegraph/renderer_test.go
+++ b/internal/tui/flamegraph/renderer_test.go
@@ -150,7 +150,7 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) {
frames := BuildTerminalLayout(snapshot, 80, 6)
out := RenderTerminalView(frames, 80, 6, 1, nil, nil, true, false, "")
- if !strings.Contains(out, "Flame | frames:2") {
+ if !strings.Contains(out, "Flame | view:root | frames:2") {
t.Fatalf("expected toolbar to include frame count, got %q", out)
}
if !strings.Contains(out, "Selected: child") {
@@ -158,6 +158,32 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) {
}
}
+func TestFrameLabelAddsSelectionAndMatchMarkers(t *testing.T) {
+ if got := frameLabel("child", 7, true, false); got != ">child<" {
+ t.Fatalf("expected selected marker label, got %q", got)
+ }
+ if got := frameLabel("child", 6, false, true); got != "*child" {
+ t.Fatalf("expected match marker label, got %q", got)
+ }
+}
+
+func TestRenderTerminalViewShowsPersistentFilterContext(t *testing.T) {
+ snapshot := &snapshotNode{
+ Name: "root",
+ Total: 10,
+ Children: []*snapshotNode{
+ {Name: "child", Total: 10},
+ },
+ }
+ frames := BuildTerminalLayout(snapshot, 80, 6)
+ matchSet := map[int]bool{1: true}
+
+ out := RenderTerminalView(frames, 80, 6, 1, nil, matchSet, true, false, "child")
+ if !strings.Contains(out, `Filter "child"`) {
+ t.Fatalf("expected filter context in status line, got %q", out)
+ }
+}
+
func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) {
snapshot := &snapshotNode{
Name: "root",
diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go
index f42c36e..9ec8ccd 100644
--- a/internal/tui/flamegraph/search.go
+++ b/internal/tui/flamegraph/search.go
@@ -19,6 +19,7 @@ func (m *Model) clearSearch() {
clearBoolMap(m.matchIndices)
m.searchInput.SetValue("")
m.searchInput.Blur()
+ m.statusMessage = "Filter cleared"
}
func (m *Model) applySearchQuery(raw string) {
@@ -30,6 +31,7 @@ func (m *Model) applySearchQuery(raw string) {
clearBoolMap(m.matchIndices)
}
if query == "" {
+ m.statusMessage = "Filter cleared"
return
}
@@ -40,7 +42,10 @@ func (m *Model) applySearchQuery(raw string) {
}
if len(m.matchIndices) > 0 {
m.jumpMatch(1)
+ m.statusMessage = fmt.Sprintf("Filter %q: %d matches", query, len(m.matchIndices))
+ return
}
+ m.statusMessage = fmt.Sprintf("Filter %q: no matches", query)
}
func (m *Model) jumpMatch(direction int) {