diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:33:33 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:33:33 +0200 |
| commit | 6948ab9b8880b318e43590bba8ecab77552348c3 (patch) | |
| tree | 1493c2c4a8b385784d6d14fcea9b75a13d68670f /internal | |
| parent | c432bae0f3afaa05766ca8fcb1a3916f67d747a1 (diff) | |
task 357: add flamegraph subtree highlighting
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/flamegraph/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 106 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 23 |
3 files changed, 113 insertions, 18 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 765e784..f13298e 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -90,7 +90,7 @@ func (m Model) Update(tea.Msg) (tea.Model, tea.Cmd) { // View renders the flamegraph viewport. func (m Model) View() tea.View { - content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx) + content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.matchIndices, m.isDark) if m.snapshot != nil && len(m.frames) == 0 { content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) } diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 09d4af2..f837bfc 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -106,7 +106,7 @@ func terminalFrameColor(name string) color.Color { } // RenderTerminalView renders a terminal flamegraph viewport from laid out frames. -func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int) string { +func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, matchSet map[int]bool, isDark bool) string { if width < minFlameWidth { return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") } @@ -130,6 +130,7 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int) strin selectedIdx = 0 } selected := frames[selectedIdx] + subtreeSet := computeSubtreeSet(frames, selectedIdx) toolbar := fmt.Sprintf("Flame | frames:%d | rows:%d", len(frames), availableRows) if truncated { @@ -139,7 +140,7 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int) strin status := fmt.Sprintf("Selected: %s %.2f%% total=%d depth=%d", selected.Name, selected.Percent, selected.Total, selected.Depth) status = padOrTrim(status, width) - rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path) + rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark) var b strings.Builder b.Grow((width + 1) * (len(rows) + 2)) @@ -153,34 +154,40 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int) strin return b.String() } -func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string) []string { - rowsByDepth := make(map[int][]tuiFrame) - for _, frame := range frames { +type indexedFrame struct { + idx int + frame tuiFrame +} + +func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark bool) []string { + rowsByDepth := make(map[int][]indexedFrame) + for idx, frame := range frames { if frame.Row < rowOffset || frame.Row > maxRow { continue } - rowsByDepth[frame.Row] = append(rowsByDepth[frame.Row], frame) + rowsByDepth[frame.Row] = append(rowsByDepth[frame.Row], indexedFrame{idx: idx, frame: frame}) } rows := make([]string, 0, maxRow-rowOffset+1) for row := maxRow; row >= rowOffset; row-- { framesAtRow := rowsByDepth[row] sort.Slice(framesAtRow, func(i, j int) bool { - return framesAtRow[i].Col < framesAtRow[j].Col + return framesAtRow[i].frame.Col < framesAtRow[j].frame.Col }) - rows = append(rows, renderRow(framesAtRow, width, selectedPath)) + rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark)) } return rows } -func renderRow(frames []tuiFrame, width int, selectedPath string) string { +func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark bool) string { if len(frames) == 0 { return strings.Repeat(" ", width) } var b strings.Builder b.Grow(width + 8) cursor := 0 - for _, frame := range frames { + for _, item := range frames { + frame := item.frame if frame.Col >= width { continue } @@ -198,10 +205,7 @@ func renderRow(frames []tuiFrame, width int, selectedPath string) string { continue } label := padOrTrim(frame.Name, cellWidth) - style := lipgloss.NewStyle().Width(cellWidth).Foreground(common.ColorBackground).Background(frame.Fill) - if frame.Path == selectedPath { - style = style.Bold(true).Underline(true) - } + style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark).Width(cellWidth) cell := style.Render(label) b.WriteString(cell) cursor = frame.Col + cellWidth @@ -212,6 +216,80 @@ func renderRow(frames []tuiFrame, width int, selectedPath string) string { return b.String() } +func computeSubtreeSet(frames []tuiFrame, selectedIdx int) map[int]bool { + subtree := make(map[int]bool) + if selectedIdx < 0 || selectedIdx >= len(frames) { + return subtree + } + selectedPath := frames[selectedIdx].Path + for idx, frame := range frames { + path := frame.Path + if path == selectedPath || + strings.HasPrefix(path, selectedPath+pathSeparator) || + strings.HasPrefix(selectedPath, path+pathSeparator) { + subtree[idx] = true + } + } + return subtree +} + +func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark bool) lipgloss.Style { + base := lipgloss.NewStyle(). + Foreground(common.ColorBackground). + Background(frame.Fill) + + isSelected := idx == selectedIdx + inSubtree := subtreeSet[idx] + isMatch := matchSet != nil && matchSet[idx] + + matchColor := lipgloss.Color("160") + if !isDark { + matchColor = lipgloss.Color("124") + } + + if isSelected { + return base.Bold(true).Reverse(true).Underline(true) + } + + if isMatch { + style := base.Background(matchColor) + if inSubtree { + return style.Bold(true) + } + return style.Faint(true) + } + + if inSubtree { + if frameRelation(frame.Path, selectedPath) == relationAncestor { + return base.BorderLeft(true).BorderForeground(common.ColorAccent) + } + return base + } + + return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true) +} + +type relation int + +const ( + relationNone relation = iota + relationAncestor + relationDescendant +) + +func frameRelation(path, selectedPath string) relation { + if path == selectedPath { + return relationDescendant + } + if strings.HasPrefix(selectedPath, path+pathSeparator) { + return relationAncestor + } + if strings.HasPrefix(path, selectedPath+pathSeparator) { + return relationDescendant + } + return relationNone +} + func maxFrameRow(frames []tuiFrame) int { maxRow := 0 for _, frame := range frames { diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index 32f260f..0827a1a 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -109,7 +109,7 @@ func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) { } func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { - out := RenderTerminalView(nil, 50, 10, 0) + out := RenderTerminalView(nil, 50, 10, 0, nil, true) if !strings.Contains(out, "terminal too narrow") { t.Fatalf("expected narrow terminal warning, got %q", out) } @@ -125,7 +125,7 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 80, 6) - out := RenderTerminalView(frames, 80, 6, 1) + out := RenderTerminalView(frames, 80, 6, 1, nil, true) if !strings.Contains(out, "Flame | frames:2") { t.Fatalf("expected toolbar to include frame count, got %q", out) } @@ -161,12 +161,29 @@ func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) { }, } frames := BuildTerminalLayout(snapshot, 80, 10) - out := RenderTerminalView(frames, 80, 4, 0) + out := RenderTerminalView(frames, 80, 4, 0, nil, true) if !strings.Contains(out, "showing deepest levels") { t.Fatalf("expected truncation hint in toolbar, got %q", out) } } +func TestComputeSubtreeSetIncludesAncestorsAndDescendants(t *testing.T) { + frames := []tuiFrame{ + {Path: "root"}, + {Path: "root" + pathSeparator + "A"}, + {Path: "root" + pathSeparator + "A" + pathSeparator + "A1"}, + {Path: "root" + pathSeparator + "B"}, + } + + set := computeSubtreeSet(frames, 1) + if !set[0] || !set[1] || !set[2] { + t.Fatalf("expected root/A/A1 to be in selected subtree: %#v", set) + } + if set[3] { + t.Fatalf("did not expect sibling branch B in subtree: %#v", set) + } +} + func mustFindFrame(t *testing.T, frames []tuiFrame, path string) tuiFrame { t.Helper() for _, frame := range frames { |
