diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-26 22:45:23 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-26 22:45:23 +0300 |
| commit | fb5a9c1f5c99559cb013a6ff396eb56a7d1f7be6 (patch) | |
| tree | d1f7bc3667ea20799893148b40bc936303d60f67 | |
| parent | 5533d521ae2183342771ace001624c89e75a994f (diff) | |
flamegraph: variable leaf bar heights for height metric (uo)
| -rw-r--r-- | internal/tui/flamegraph/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 70 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 70 |
3 files changed, 128 insertions, 14 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index b1f3f1e..fe9b73b 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -510,7 +510,7 @@ func (m Model) renderViewContent() string { renderHeight = 3 } - content := RenderTerminalView(m.frames, m.width, renderHeight, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, m.countFieldLabel(), m.isDark, m.searchActive, m.searchQuery) + content := RenderTerminalView(m.frames, m.width, renderHeight, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, m.countFieldLabel(), strings.TrimSpace(m.heightField) != "", m.isDark, m.searchActive, m.searchQuery) content = replaceHeaderLine(content, m.toolbarLine()) if m.searchActive { content = replaceFooterLine(content, m.searchFooter()) diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 3a35de9..e5bbae2 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -227,6 +227,7 @@ type renderViewParams struct { rowOffset int maxRow int barHeight int + leafBarHeight int availableRows int visibleFrames int truncated bool @@ -234,12 +235,17 @@ type renderViewParams struct { // computeRenderParams derives the row-layout parameters for a given frame set // and viewport height. -func computeRenderParams(frames []tuiFrame, height int) renderViewParams { +func computeRenderParams(frames []tuiFrame, height int, heightMetricActive bool) renderViewParams { availableRows := height - 2 // toolbar + frame-status line maxRow := maxFrameRowForSet(frames, nil) totalDepthRows := maxRow + 1 barHeight := computeBarHeight(availableRows, totalDepthRows, maxBarVisualHeight) + leafBarHeight := barHeight visibleDepthRows := availableRows / barHeight + if heightMetricActive { + barHeight = 1 + visibleDepthRows = availableRows + } if visibleDepthRows < 1 { visibleDepthRows = 1 } @@ -249,10 +255,18 @@ func computeRenderParams(frames []tuiFrame, height int) renderViewParams { rowOffset = maxRow + 1 - visibleDepthRows truncated = true } + if heightMetricActive { + visibleNonLeafRows := max(0, maxRow-rowOffset) + leafBarHeight = availableRows - visibleNonLeafRows + if leafBarHeight < 1 { + leafBarHeight = 1 + } + } return renderViewParams{ rowOffset: rowOffset, maxRow: maxRow, barHeight: barHeight, + leafBarHeight: leafBarHeight, availableRows: availableRows, visibleFrames: countVisibleFrames(frames, nil), truncated: truncated, @@ -313,7 +327,7 @@ func buildNormalStatus(selected tuiFrame, metricLabel string, globalTotal uint64 // RenderTerminalView renders a terminal flamegraph viewport from laid out frames. // The function is split into helpers (computeRenderParams, buildToolbar, // buildFilteredStatus, buildNormalStatus) to keep each piece under 50 lines. -func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet, filterSet map[int]bool, globalTotal uint64, metricLabel string, isDark, searchActive bool, searchQuery string) string { +func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet, filterSet map[int]bool, globalTotal uint64, metricLabel string, heightMetricActive, isDark, searchActive bool, searchQuery string) string { if width < minFlameWidth { return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") } @@ -342,7 +356,7 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr if subtreeSet == nil { subtreeSet = computeSubtreeSet(frames, selectedIdx) } - params := computeRenderParams(frames, height) + params := computeRenderParams(frames, height, heightMetricActive) toolbar := buildToolbar(frames, width, params) var status string if filterIsActive { @@ -350,11 +364,11 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr } else { status = buildNormalStatus(selected, metricLabel, globalTotal) } - return renderViewRows(toolbar, status, rowsForRender(frames, width, params.rowOffset, params.maxRow, params.barHeight, params.availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterIsActive), width) + return renderViewRows(toolbar, status, rowsForRender(frames, width, params.rowOffset, params.maxRow, params.barHeight, params.leafBarHeight, params.availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, heightMetricActive, isDark, searchActive, filterIsActive), width) } -func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { - return buildRenderRows(frames, width, rowOffset, maxRow, barHeight, availableRows, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive) +func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow, barHeight, leafBarHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, heightMetricActive, isDark, searchActive, filterActive bool) []string { + return buildRenderRows(frames, width, rowOffset, maxRow, barHeight, leafBarHeight, availableRows, selectedPath, subtreeSet, matchSet, selectedIdx, heightMetricActive, isDark, searchActive, filterActive) } func renderViewRows(toolbar, status string, rows []string, width int) string { @@ -376,7 +390,7 @@ type indexedFrame struct { frame tuiFrame } -func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { +func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, leafBarHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, heightMetricActive, isDark, searchActive, filterActive bool) []string { rowsByDepth := make(map[int][]indexedFrame) for idx, frame := range frames { if frame.Row < rowOffset || frame.Row > maxRow { @@ -395,6 +409,14 @@ func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, ava slices.SortFunc(framesAtRow, func(a, b indexedFrame) int { return cmp.Compare(a.frame.Col, b.frame.Col) }) + if heightMetricActive && row == maxRow { + frameHeights := leafFrameHeights(framesAtRow, leafBarHeight) + for h := leafBarHeight - 1; h >= 0; h-- { + showLabels := h == 0 + rows = append(rows, renderLeafRowBand(framesAtRow, frameHeights, h, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive, showLabels)) + } + continue + } for repeat := 0; repeat < barHeight; repeat++ { showLabels := repeat == barHeight/2 rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive, showLabels)) @@ -418,6 +440,40 @@ func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, ava return rows } +func leafFrameHeights(frames []indexedFrame, leafBarHeight int) map[int]int { + heights := make(map[int]int, len(frames)) + if leafBarHeight < 1 { + leafBarHeight = 1 + } + maxHeightTotal := uint64(0) + for _, item := range frames { + if item.frame.HeightTotal > maxHeightTotal { + maxHeightTotal = item.frame.HeightTotal + } + } + for _, item := range frames { + frameHeight := 1 + if maxHeightTotal > 0 { + scaled := math.Round(float64(leafBarHeight) * (float64(item.frame.HeightTotal) / float64(maxHeightTotal))) + frameHeight = int(scaled) + } + frameHeight = max(1, frameHeight) + frameHeight = min(leafBarHeight, frameHeight) + heights[item.idx] = frameHeight + } + return heights +} + +func renderLeafRowBand(frames []indexedFrame, frameHeights map[int]int, band, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive, showLabels bool) string { + visible := make([]indexedFrame, 0, len(frames)) + for _, item := range frames { + if frameHeights[item.idx] > band { + visible = append(visible, item) + } + } + return renderRow(visible, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive, showLabels) +} + func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive, showLabels bool) string { if len(frames) == 0 { return strings.Repeat(" ", width) diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index 354b40a..f34d23d 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -152,7 +152,7 @@ func TestTerminalFrameColorSemanticPalette(t *testing.T) { } func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { - out := RenderTerminalView(nil, 50, 10, 0, nil, nil, nil, 0, "events", true, false, "") + out := RenderTerminalView(nil, 50, 10, 0, nil, nil, nil, 0, "events", false, true, false, "") if !strings.Contains(out, "terminal too narrow") { t.Fatalf("expected narrow terminal warning, got %q", out) } @@ -177,7 +177,7 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 80, 6) - out := RenderTerminalView(frames, 80, 6, 1, nil, nil, nil, 0, "events", true, false, "") + out := RenderTerminalView(frames, 80, 6, 1, nil, nil, nil, 0, "events", false, true, false, "") if !strings.Contains(out, "Flame | view:root | frames:2") { t.Fatalf("expected toolbar to include frame count, got %q", out) } @@ -196,7 +196,7 @@ func TestRenderTerminalViewFillsAvailableHeightForShallowTree(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 100, 20) - out := RenderTerminalView(frames, 100, 20, 1, nil, nil, nil, 0, "events", true, false, "") + out := RenderTerminalView(frames, 100, 20, 1, nil, nil, nil, 0, "events", false, true, false, "") lines := strings.Split(out, "\n") if got, want := len(lines), 20; got != want { t.Fatalf("expected render to fill viewport height (%d lines), got %d", want, got) @@ -245,7 +245,7 @@ func TestRenderTerminalViewShowsPersistentFilterContext(t *testing.T) { frames := BuildTerminalLayout(snapshot, 80, 6) matchSet := map[int]bool{1: true} - out := RenderTerminalView(frames, 140, 6, 1, nil, matchSet, nil, 0, "events", true, false, "child") + out := RenderTerminalView(frames, 140, 6, 1, nil, matchSet, nil, 0, "events", false, true, false, "child") if !strings.Contains(out, `Filter "child"`) { t.Fatalf("expected filter context in status line, got %q", out) } @@ -279,7 +279,7 @@ func TestRenderTerminalViewFilterKeepsNonMatchingBranchesVisible(t *testing.T) { } matchSet := map[int]bool{needleIdx: true} - out := RenderTerminalView(frames, 180, 8, needleIdx, nil, matchSet, nil, 100, "bytes", true, false, "needle") + out := RenderTerminalView(frames, 180, 8, needleIdx, nil, matchSet, nil, 100, "bytes", false, true, false, "needle") if !strings.Contains(out, `Filter "needle": 60.0% bytes`) { t.Fatalf("expected filter status to report 60.0%% bytes share, got %q", out) } @@ -368,7 +368,7 @@ func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) { }, } frames := BuildTerminalLayout(snapshot, 80, 10) - out := RenderTerminalView(frames, 80, 4, 0, nil, nil, nil, 0, "events", true, false, "") + out := RenderTerminalView(frames, 80, 4, 0, nil, nil, nil, 0, "events", false, true, false, "") if !strings.Contains(out, "showing deepest levels") { t.Fatalf("expected truncation hint in toolbar, got %q", out) } @@ -391,6 +391,64 @@ func TestComputeSubtreeSetIncludesAncestorsAndDescendants(t *testing.T) { } } +func TestComputeRenderParamsHeightMetricExpandsLeafBand(t *testing.T) { + frames := []tuiFrame{ + {Name: "root", Row: 0, Col: 0, Width: 20, Path: "root"}, + {Name: "leaf", Row: 1, Col: 0, Width: 20, Path: "root" + pathSeparator + "leaf", HeightTotal: 100}, + } + + params := computeRenderParams(frames, 8, true) // availableRows=6 + if got, want := params.barHeight, 1; got != want { + t.Fatalf("height metric active: barHeight=%d want=%d", got, want) + } + if got, want := params.leafBarHeight, 5; got != want { + t.Fatalf("height metric active: leafBarHeight=%d want=%d", got, want) + } +} + +func TestLeafFrameHeightsScaledByHeightTotal(t *testing.T) { + frames := []indexedFrame{ + {idx: 10, frame: tuiFrame{Name: "A", HeightTotal: 100}}, + {idx: 11, frame: tuiFrame{Name: "B", HeightTotal: 50}}, + {idx: 12, frame: tuiFrame{Name: "C", HeightTotal: 0}}, + } + + heights := leafFrameHeights(frames, 5) + if got, want := heights[10], 5; got != want { + t.Fatalf("A height=%d want=%d", got, want) + } + if got, want := heights[11], 3; got != want { + t.Fatalf("B height=%d want=%d", got, want) + } + if got, want := heights[12], 1; got != want { + t.Fatalf("C height=%d want=%d", got, want) + } +} + +func TestRenderLeafRowBandFiltersFramesByBand(t *testing.T) { + frames := []indexedFrame{ + {idx: 0, frame: tuiFrame{Name: "A", Col: 0, Width: 5, Path: "root" + pathSeparator + "A", Fill: color.RGBA{R: 150, G: 80, B: 80, A: 255}}}, + {idx: 1, frame: tuiFrame{Name: "B", Col: 5, Width: 5, Path: "root" + pathSeparator + "B", Fill: color.RGBA{R: 80, G: 120, B: 180, A: 255}}}, + } + heights := map[int]int{ + 0: 5, + 1: 2, + } + + topBand := renderLeafRowBand(frames, heights, 3, 10, "root"+pathSeparator+"A", nil, nil, 0, true, false, false, true) + if !strings.Contains(topBand, "A") { + t.Fatalf("expected top band to render taller frame A, got %q", topBand) + } + if strings.Contains(topBand, "B") { + t.Fatalf("expected top band to hide shorter frame B, got %q", topBand) + } + + lowerBand := renderLeafRowBand(frames, heights, 1, 10, "root"+pathSeparator+"A", nil, nil, 0, true, false, false, true) + if !strings.Contains(lowerBand, "A") || !strings.Contains(lowerBand, "B") { + t.Fatalf("expected lower band to render both frames, got %q", lowerBand) + } +} + func mustFindFrame(t *testing.T, frames []tuiFrame, path string) tuiFrame { t.Helper() for _, frame := range frames { |
