diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 08:02:58 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 08:02:58 +0200 |
| commit | c6ec3b3ee34c9e77daa7159e8c164e413c2101b5 (patch) | |
| tree | 0b7bba3228caddbdc24614c1734560a64dcd7770 /internal/tui/flamegraph | |
| parent | 1955effb0daa5269d1b4069f3fb5175dbc29793f (diff) | |
Improve flamegraph selection, filter, and zoom feedback
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 5 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 25 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 42 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 67 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 28 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 5 |
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) { |
