summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 22:33:33 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 22:33:33 +0200
commit6948ab9b8880b318e43590bba8ecab77552348c3 (patch)
tree1493c2c4a8b385784d6d14fcea9b75a13d68670f /internal
parentc432bae0f3afaa05766ca8fcb1a3916f67d747a1 (diff)
task 357: add flamegraph subtree highlighting
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/flamegraph/model.go2
-rw-r--r--internal/tui/flamegraph/renderer.go106
-rw-r--r--internal/tui/flamegraph/renderer_test.go23
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 {