diff options
| -rw-r--r-- | internal/tui/flamegraph/model.go | 15 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 103 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 95 |
3 files changed, 171 insertions, 42 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 81fcb7b..b9a8734 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -510,7 +510,20 @@ 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(), strings.TrimSpace(m.heightField) != "", m.isDark, m.searchActive, m.searchQuery) + content := RenderTerminalView(RenderContext{ + Frames: m.frames, + Width: m.width, + Height: renderHeight, + SelectedIdx: m.selectedIdx, + SubtreeSet: m.subtreeSet, + MatchSet: m.matchIndices, + FilterSet: m.filterVisible, + GlobalTotal: m.globalTotal, + MetricLabel: m.countFieldLabel(), + HeightMetricActive: strings.TrimSpace(m.heightField) != "", + IsDark: m.isDark, + SearchQuery: 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 e5bbae2..a38792e 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -233,6 +233,40 @@ type renderViewParams struct { truncated bool } +// RenderContext bundles flamegraph render inputs to avoid long positional +// parameter lists at call sites. +type RenderContext struct { + Frames []tuiFrame + Width int + Height int + SelectedIdx int + SubtreeSet map[int]bool + MatchSet map[int]bool + FilterSet map[int]bool + GlobalTotal uint64 + MetricLabel string + HeightMetricActive bool + IsDark bool + SearchQuery string +} + +type renderRowsContext struct { + frames []tuiFrame + width int + rowOffset int + maxRow int + barHeight int + leafBarHeight int + availableRows int + selectedPath string + subtreeSet map[int]bool + matchSet map[int]bool + selectedIdx int + heightMetricActive bool + isDark bool + filterActive bool +} + // computeRenderParams derives the row-layout parameters for a given frame set // and viewport height. func computeRenderParams(frames []tuiFrame, height int, heightMetricActive bool) renderViewParams { @@ -327,7 +361,20 @@ 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, heightMetricActive, isDark, searchActive bool, searchQuery string) string { +func RenderTerminalView(ctx RenderContext) string { + frames := ctx.Frames + width := ctx.Width + height := ctx.Height + selectedIdx := ctx.SelectedIdx + subtreeSet := ctx.SubtreeSet + matchSet := ctx.MatchSet + filterSet := ctx.FilterSet + globalTotal := ctx.GlobalTotal + metricLabel := ctx.MetricLabel + heightMetricActive := ctx.HeightMetricActive + isDark := ctx.IsDark + searchQuery := ctx.SearchQuery + if width < minFlameWidth { return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") } @@ -364,11 +411,23 @@ 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.leafBarHeight, params.availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, heightMetricActive, isDark, searchActive, filterIsActive), width) -} - -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) + rows := buildRenderRows(renderRowsContext{ + frames: frames, + width: width, + rowOffset: params.rowOffset, + maxRow: params.maxRow, + barHeight: params.barHeight, + leafBarHeight: params.leafBarHeight, + availableRows: params.availableRows, + selectedPath: selected.Path, + subtreeSet: subtreeSet, + matchSet: matchSet, + selectedIdx: selectedIdx, + heightMetricActive: heightMetricActive, + isDark: isDark, + filterActive: filterIsActive, + }) + return renderViewRows(toolbar, status, rows, width) } func renderViewRows(toolbar, status string, rows []string, width int) string { @@ -390,7 +449,22 @@ type indexedFrame struct { frame tuiFrame } -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 { +func buildRenderRows(ctx renderRowsContext) []string { + frames := ctx.frames + width := ctx.width + rowOffset := ctx.rowOffset + maxRow := ctx.maxRow + barHeight := ctx.barHeight + leafBarHeight := ctx.leafBarHeight + availableRows := ctx.availableRows + selectedPath := ctx.selectedPath + subtreeSet := ctx.subtreeSet + matchSet := ctx.matchSet + selectedIdx := ctx.selectedIdx + heightMetricActive := ctx.heightMetricActive + isDark := ctx.isDark + filterActive := ctx.filterActive + rowsByDepth := make(map[int][]indexedFrame) for idx, frame := range frames { if frame.Row < rowOffset || frame.Row > maxRow { @@ -413,13 +487,13 @@ func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, lea 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)) + rows = append(rows, renderLeafRowBand(framesAtRow, frameHeights, h, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, 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)) + rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, filterActive, showLabels)) } } @@ -464,17 +538,17 @@ func leafFrameHeights(frames []indexedFrame, leafBarHeight int) map[int]int { 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 { +func renderLeafRowBand(frames []indexedFrame, frameHeights map[int]int, band, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, 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) + return renderRow(visible, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, filterActive, showLabels) } -func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive, showLabels bool) string { +func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, filterActive, showLabels bool) string { if len(frames) == 0 { return strings.Repeat(" ", width) } @@ -503,7 +577,7 @@ func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet if showLabels { 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) + style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, filterActive) cell := style.Render(label) b.WriteString(cell) cursor = frame.Col + cellWidth @@ -582,8 +656,7 @@ func computeFilterVisibleSetInto(frames []tuiFrame, matchSet, visible map[int]bo return visible } -func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) lipgloss.Style { - _ = searchActive +func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, filterActive bool) lipgloss.Style { base := lipgloss.NewStyle(). Foreground(common.ColorBackground). Background(frame.Fill) diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index 9361686..9b9ed88 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -152,7 +152,12 @@ func TestTerminalFrameColorSemanticPalette(t *testing.T) { } func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { - out := RenderTerminalView(nil, 50, 10, 0, nil, nil, nil, 0, "events", false, true, false, "") + out := RenderTerminalView(RenderContext{ + Width: 50, + Height: 10, + MetricLabel: "events", + IsDark: true, + }) if !strings.Contains(out, "terminal too narrow") { t.Fatalf("expected narrow terminal warning, got %q", out) } @@ -177,7 +182,14 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 80, 6) - out := RenderTerminalView(frames, 80, 6, 1, nil, nil, nil, 0, "events", false, true, false, "") + out := RenderTerminalView(RenderContext{ + Frames: frames, + Width: 80, + Height: 6, + SelectedIdx: 1, + MetricLabel: "events", + IsDark: true, + }) if !strings.Contains(out, "Flame | view:root | frames:2") { t.Fatalf("expected toolbar to include frame count, got %q", out) } @@ -196,7 +208,14 @@ func TestRenderTerminalViewFillsAvailableHeightForShallowTree(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 100, 20) - out := RenderTerminalView(frames, 100, 20, 1, nil, nil, nil, 0, "events", false, true, false, "") + out := RenderTerminalView(RenderContext{ + Frames: frames, + Width: 100, + Height: 20, + SelectedIdx: 1, + MetricLabel: "events", + IsDark: true, + }) 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) @@ -226,7 +245,6 @@ func TestSelectedFrameStyleDoesNotUnderline(t *testing.T) { 1, true, false, - false, ) rendered := style.Render(" child ") if strings.Contains(rendered, "\x1b[4m") || strings.Contains(rendered, "[4;") || strings.Contains(rendered, ";4m") { @@ -245,7 +263,16 @@ 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", false, true, false, "child") + out := RenderTerminalView(RenderContext{ + Frames: frames, + Width: 140, + Height: 6, + SelectedIdx: 1, + MatchSet: matchSet, + MetricLabel: "events", + IsDark: true, + SearchQuery: "child", + }) if !strings.Contains(out, `Filter "child"`) { t.Fatalf("expected filter context in status line, got %q", out) } @@ -279,7 +306,17 @@ func TestRenderTerminalViewFilterKeepsNonMatchingBranchesVisible(t *testing.T) { } matchSet := map[int]bool{needleIdx: true} - out := RenderTerminalView(frames, 180, 8, needleIdx, nil, matchSet, nil, 100, "bytes", false, true, false, "needle") + out := RenderTerminalView(RenderContext{ + Frames: frames, + Width: 180, + Height: 8, + SelectedIdx: needleIdx, + MatchSet: matchSet, + GlobalTotal: 100, + MetricLabel: "bytes", + IsDark: true, + SearchQuery: "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 +405,14 @@ func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) { }, } frames := BuildTerminalLayout(snapshot, 80, 10) - out := RenderTerminalView(frames, 80, 4, 0, nil, nil, nil, 0, "events", false, true, false, "") + out := RenderTerminalView(RenderContext{ + Frames: frames, + Width: 80, + Height: 4, + SelectedIdx: 0, + MetricLabel: "events", + IsDark: true, + }) if !strings.Contains(out, "showing deepest levels") { t.Fatalf("expected truncation hint in toolbar, got %q", out) } @@ -435,7 +479,7 @@ func TestRenderLeafRowBandFiltersFramesByBand(t *testing.T) { 1: 2, } - topBand := renderLeafRowBand(frames, heights, 3, 10, "root"+pathSeparator+"A", nil, nil, 0, true, false, false, true) + topBand := renderLeafRowBand(frames, heights, 3, 10, "root"+pathSeparator+"A", nil, nil, 0, true, false, true) if !strings.Contains(topBand, "A") { t.Fatalf("expected top band to render taller frame A, got %q", topBand) } @@ -443,7 +487,7 @@ func TestRenderLeafRowBandFiltersFramesByBand(t *testing.T) { 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) + lowerBand := renderLeafRowBand(frames, heights, 1, 10, "root"+pathSeparator+"A", nil, nil, 0, true, false, true) if !strings.Contains(lowerBand, "A") || !strings.Contains(lowerBand, "B") { t.Fatalf("expected lower band to render both frames, got %q", lowerBand) } @@ -456,23 +500,22 @@ func TestBuildRenderRowsHeightMetricUsesLeafBandsAndViewportRows(t *testing.T) { {Name: "B", Row: 1, Col: 6, Width: 6, Path: "root" + pathSeparator + "B", HeightTotal: 50, Fill: color.RGBA{R: 80, G: 120, B: 180, A: 255}}, } - rows := buildRenderRows( - frames, - 12, // width - 0, // rowOffset - 1, // maxRow - 1, // barHeight - 4, // leafBarHeight - 5, // availableRows - "root", - map[int]bool{0: true, 1: true, 2: true}, - nil, - 0, - true, // heightMetricActive - true, // isDark - false, // searchActive - false, // filterActive - ) + rows := buildRenderRows(renderRowsContext{ + frames: frames, + width: 12, // width + rowOffset: 0, // rowOffset + maxRow: 1, // maxRow + barHeight: 1, // barHeight + leafBarHeight: 4, // leafBarHeight + availableRows: 5, // availableRows + selectedPath: "root", + subtreeSet: map[int]bool{0: true, 1: true, 2: true}, + matchSet: nil, + selectedIdx: 0, + heightMetricActive: true, // heightMetricActive + isDark: true, // isDark + filterActive: false, // filterActive + }) if got, want := len(rows), 5; got != want { t.Fatalf("row count = %d, want %d", got, want) |
