summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tui/flamegraph/model.go2
-rw-r--r--internal/tui/flamegraph/renderer.go70
-rw-r--r--internal/tui/flamegraph/renderer_test.go70
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 {