summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 15:06:22 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 15:06:22 +0200
commit1530bf2856bbb32a6e0457596b55c07f3836a0ec (patch)
treed699766a2607042de0f8278652b9b7cde2426b84 /internal
parent4737786fd4a417ff94e22e4f72a1e924d4e033dd (diff)
flamegraph: use full viewport with capped bar height and preserve footer/status
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/dashboard/model.go23
-rw-r--r--internal/tui/flamegraph/model.go11
-rw-r--r--internal/tui/flamegraph/model_test.go26
-rw-r--r--internal/tui/flamegraph/renderer.go68
-rw-r--r--internal/tui/flamegraph/renderer_test.go26
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)