diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 15:06:22 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 15:06:22 +0200 |
| commit | 1530bf2856bbb32a6e0457596b55c07f3836a0ec (patch) | |
| tree | d699766a2607042de0f8278652b9b7cde2426b84 | |
| parent | 4737786fd4a417ff94e22e4f72a1e924d4e033dd (diff) | |
flamegraph: use full viewport with capped bar height and preserve footer/status
| -rw-r--r-- | internal/tui/dashboard/model.go | 23 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 11 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 26 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 68 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 26 |
5 files changed, 140 insertions, 14 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index b1d23bb..10d8b49 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -19,6 +19,9 @@ const defaultRefreshMs = 1000 const streamRefreshMs = 200 const flameRefreshMs = 200 const streamChromeRows = 4 +const dashboardHelpHintRows = 1 +const dashboardExpandedHelpRows = 2 +const dashboardTabBarRows = 1 // SnapshotSource is the dashboard data source. type SnapshotSource interface { @@ -106,7 +109,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height streamWidth, streamHeight := streamViewport(msg.Width, msg.Height) m.streamModel.SetViewport(streamWidth, streamHeight) - m.flamegraphModel.SetViewport(msg.Width, msg.Height) + flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil case refreshTickMsg: if !m.focused { @@ -171,6 +175,8 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { keyStr := msg.String() if keyStr == "H" { m.showHelp = !m.showHelp + flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil } if m.activeTab == TabFlame && m.flamegraphModel.ConsumesKey(msg) { @@ -396,7 +402,7 @@ func (m *Model) SetPidFilter(pid int) { // View renders the tab bar, active tab scaffold, and help bar. func (m Model) View() tea.View { width, height := common.EffectiveViewport(m.width, m.height) - activeHeight := height + _, activeHeight := flameViewport(width, height, m.showHelp) streamModel := m.streamModel streamModel.SetFooterVisible(m.showHelp) if m.activeTab == TabStream { @@ -487,3 +493,16 @@ func streamViewport(width, height int) (int, int) { } return width, height } + +func flameViewport(width, height int, showHelp bool) (int, int) { + width, height = common.EffectiveViewport(width, height) + chromeRows := dashboardTabBarRows + dashboardHelpHintRows + if showHelp { + chromeRows = dashboardTabBarRows + dashboardExpandedHelpRows + } + height -= chromeRows + if height < 1 { + height = 1 + } + return width, height +} diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 2f40a30..2b974fe 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -284,7 +284,16 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool { // View renders the flamegraph viewport. func (m Model) View() tea.View { - content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, m.isDark, m.searchActive, m.searchQuery) + extraLines := 1 // selection status line + if m.showHelp { + extraLines++ + } + renderHeight := m.height - extraLines + if renderHeight < 3 { + renderHeight = 3 + } + + content := RenderTerminalView(m.frames, m.width, renderHeight, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, 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/model_test.go b/internal/tui/flamegraph/model_test.go index 093bf34..355facc 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -666,6 +666,32 @@ func TestViewIncludesSelectionStatusBar(t *testing.T) { } } +func TestViewFitsViewportHeightAndKeepsSearchFooterVisible(t *testing.T) { + m := NewModel(nil) + m.width = 100 + m.height = 12 + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 100, Total: 100, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 80, Total: 80, Percent: 80, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 100 + m.searchActive = true + m.searchInput.SetValue("child") + + view := m.View().Content + lines := strings.Split(view, "\n") + if got, max := len(lines), m.height; got > max { + t.Fatalf("expected flame view to fit viewport height <=%d, got %d lines", max, got) + } + if !strings.Contains(view, "matches") { + t.Fatalf("expected search footer to remain visible in viewport, got %q", view) + } + if !strings.Contains(view, "[LIVE] sel:2/2 child") { + t.Fatalf("expected selection status line to remain visible, got %q", view) + } +} + func TestViewFilterSelectionStatusUsesFilteredTotalAndKeepsContextVisible(t *testing.T) { snapshot := &snapshotNode{ Name: "root", diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index f2ab08e..3ae9a11 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -17,6 +17,7 @@ import ( const pathSeparator = "\x1f" const pathSeparatorByte = '\x1f' const minFlameWidth = 60 +const maxBarVisualHeight = 3 // BuildTerminalLayout converts a live trie snapshot into terminal frame cells. func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame { @@ -223,10 +224,16 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr availableRows := height - 2 // toolbar + status maxRow := maxFrameRowForSet(frames, nil) + totalDepthRows := maxRow + 1 + barHeight := computeBarHeight(availableRows, totalDepthRows, maxBarVisualHeight) + visibleDepthRows := availableRows / barHeight + if visibleDepthRows < 1 { + visibleDepthRows = 1 + } rowOffset := 0 truncated := false - if maxRow+1 > availableRows { - rowOffset = maxRow + 1 - availableRows + if maxRow+1 > visibleDepthRows { + rowOffset = maxRow + 1 - visibleDepthRows truncated = true } @@ -263,16 +270,16 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr status := fmt.Sprintf("Filter %q: %.1f%% system (%d/%d matches, %.1f%% frames shown) | Selected: %s total=%d depth=%d %.2f%% filter", searchQuery, filterSystemShare, pos, len(matches), frameCoverage, selected.Name, selected.Total, selected.Depth, selectedFilterShare) - return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) + return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) } else { status := fmt.Sprintf("Selected: %s [%s] total=%d depth=%d col=%d width=%d share=%.2f%%", selected.Name, compactFramePath(selected.Path), selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare) - return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) + return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) } } -func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { - return buildRenderRows(frames, width, rowOffset, maxRow, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive) +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 renderViewRows(toolbar, status string, rows []string, width int) string { @@ -294,7 +301,7 @@ type indexedFrame struct { frame tuiFrame } -func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { +func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { rowsByDepth := make(map[int][]indexedFrame) for idx, frame := range frames { if frame.Row < rowOffset || frame.Row > maxRow { @@ -303,18 +310,40 @@ func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPa rowsByDepth[frame.Row] = append(rowsByDepth[frame.Row], indexedFrame{idx: idx, frame: frame}) } - rows := make([]string, 0, maxRow-rowOffset+1) + if barHeight < 1 { + barHeight = 1 + } + + rows := make([]string, 0, (maxRow-rowOffset+1)*barHeight) for row := maxRow; row >= rowOffset; row-- { framesAtRow := rowsByDepth[row] sort.Slice(framesAtRow, func(i, j int) bool { return framesAtRow[i].frame.Col < framesAtRow[j].frame.Col }) - rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive)) + for repeat := 0; repeat < barHeight; repeat++ { + showLabels := repeat == barHeight/2 + rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive, showLabels)) + } + } + + if availableRows > 0 { + if len(rows) > availableRows { + rows = rows[:availableRows] + } + if len(rows) < availableRows { + blank := strings.Repeat(" ", width) + pad := make([]string, 0, availableRows) + for i := 0; i < availableRows-len(rows); i++ { + pad = append(pad, blank) + } + pad = append(pad, rows...) + rows = pad + } } return rows } -func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) string { +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) } @@ -339,7 +368,10 @@ func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet if cellWidth <= 0 { continue } - label := frameLabel(frame.Name, cellWidth, item.idx == selectedIdx, matchSet != nil && matchSet[item.idx]) + label := strings.Repeat(" ", cellWidth) + 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) cell := style.Render(label) b.WriteString(cell) @@ -569,6 +601,20 @@ func filterSampleCoverage(frames []tuiFrame, matchSet map[int]bool, totalBase ui return percentOfTotal(coveredTotal, rootTotal) } +func computeBarHeight(availableRows, depthRows, maxHeight int) int { + if availableRows <= 0 || depthRows <= 0 { + return 1 + } + height := availableRows / depthRows + if height < 1 { + height = 1 + } + if maxHeight > 0 && height > maxHeight { + height = maxHeight + } + return height +} + func filterCoverageTotals(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) (coveredTotal uint64, rootTotal uint64) { if len(frames) == 0 || len(matchSet) == 0 { return 0, 0 diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index b85bceb..091aeec 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -158,6 +158,15 @@ func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { } } +func TestComputeBarHeightCappedAtThree(t *testing.T) { + if got := computeBarHeight(30, 4, 3); got != 3 { + t.Fatalf("expected bar height cap at 3, got %d", got) + } + if got := computeBarHeight(5, 10, 3); got != 1 { + t.Fatalf("expected bar height minimum 1 when depth exceeds rows, got %d", got) + } +} + func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { snapshot := &snapshotNode{ Name: "root", @@ -177,6 +186,23 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { } } +func TestRenderTerminalViewFillsAvailableHeightForShallowTree(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 10, + Children: []*snapshotNode{ + {Name: "child", Total: 10}, + }, + } + frames := BuildTerminalLayout(snapshot, 100, 20) + + out := RenderTerminalView(frames, 100, 20, 1, nil, nil, nil, 0, 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) + } +} + func TestFrameLabelAddsSelectionAndMatchMarkers(t *testing.T) { if got := frameLabel("child", 7, true, false); got != ">child<" { t.Fatalf("expected selected marker label, got %q", got) |
