diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:31:23 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:31:23 +0200 |
| commit | c432bae0f3afaa05766ca8fcb1a3916f67d747a1 (patch) | |
| tree | 5b61c01773a56dea1290f45845fb52bfdee66422 /internal | |
| parent | 63b9ad7c7692b3bedb4d0051c080946e38e058f9 (diff) | |
task 356: implement flamegraph terminal view renderer
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/flamegraph/model.go | 7 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 138 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 59 |
3 files changed, 200 insertions, 4 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 637ba11..765e784 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -90,11 +90,10 @@ func (m Model) Update(tea.Msg) (tea.Model, tea.Cmd) { // View renders the flamegraph viewport. func (m Model) View() tea.View { - content := "Flame: waiting for data..." - if m.snapshot != nil { - content = fmt.Sprintf("Flame: live snapshot v%d", m.lastVersion) + content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx) + if m.snapshot != nil && len(m.frames) == 0 { + content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) } - content = common.PanelStyle.Render(content) return tea.NewView(content) } diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 6cace44..09d4af2 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -1,13 +1,20 @@ package flamegraph import ( + "fmt" "hash/fnv" "image/color" + common "ior/internal/tui/common" "math" + "sort" "strings" + "unicode/utf8" + + "charm.land/lipgloss/v2" ) const pathSeparator = "\x1f" +const minFlameWidth = 60 // BuildTerminalLayout converts a live trie snapshot into terminal frame cells. func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame { @@ -97,3 +104,134 @@ func terminalFrameColor(name string) color.Color { A: 255, } } + +// RenderTerminalView renders a terminal flamegraph viewport from laid out frames. +func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int) string { + if width < minFlameWidth { + return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") + } + if height < 3 { + return common.PanelStyle.Render("Flame: viewport too short") + } + if len(frames) == 0 { + return common.PanelStyle.Render("Flame: waiting for data...") + } + + availableRows := height - 2 // toolbar + status + maxRow := maxFrameRow(frames) + rowOffset := 0 + truncated := false + if maxRow+1 > availableRows { + rowOffset = maxRow + 1 - availableRows + truncated = true + } + + if selectedIdx < 0 || selectedIdx >= len(frames) { + selectedIdx = 0 + } + selected := frames[selectedIdx] + + toolbar := fmt.Sprintf("Flame | frames:%d | rows:%d", len(frames), availableRows) + if truncated { + toolbar += " | showing deepest levels" + } + toolbar = padOrTrim(toolbar, width) + status := fmt.Sprintf("Selected: %s %.2f%% total=%d depth=%d", selected.Name, selected.Percent, selected.Total, selected.Depth) + status = padOrTrim(status, width) + + rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path) + + var b strings.Builder + b.Grow((width + 1) * (len(rows) + 2)) + b.WriteString(toolbar) + for _, row := range rows { + b.WriteString("\n") + b.WriteString(row) + } + b.WriteString("\n") + b.WriteString(status) + return b.String() +} + +func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string) []string { + rowsByDepth := make(map[int][]tuiFrame) + for _, frame := range frames { + if frame.Row < rowOffset || frame.Row > maxRow { + continue + } + rowsByDepth[frame.Row] = append(rowsByDepth[frame.Row], frame) + } + + rows := make([]string, 0, maxRow-rowOffset+1) + for row := maxRow; row >= rowOffset; row-- { + framesAtRow := rowsByDepth[row] + sort.Slice(framesAtRow, func(i, j int) bool { + return framesAtRow[i].Col < framesAtRow[j].Col + }) + rows = append(rows, renderRow(framesAtRow, width, selectedPath)) + } + return rows +} + +func renderRow(frames []tuiFrame, width int, selectedPath string) string { + if len(frames) == 0 { + return strings.Repeat(" ", width) + } + var b strings.Builder + b.Grow(width + 8) + cursor := 0 + for _, frame := range frames { + if frame.Col >= width { + continue + } + if frame.Col > cursor { + gap := frame.Col - cursor + b.WriteString(strings.Repeat(" ", gap)) + cursor += gap + } + + cellWidth := frame.Width + if frame.Col+cellWidth > width { + cellWidth = width - frame.Col + } + if cellWidth <= 0 { + continue + } + label := padOrTrim(frame.Name, cellWidth) + style := lipgloss.NewStyle().Width(cellWidth).Foreground(common.ColorBackground).Background(frame.Fill) + if frame.Path == selectedPath { + style = style.Bold(true).Underline(true) + } + cell := style.Render(label) + b.WriteString(cell) + cursor = frame.Col + cellWidth + } + if cursor < width { + b.WriteString(strings.Repeat(" ", width-cursor)) + } + return b.String() +} + +func maxFrameRow(frames []tuiFrame) int { + maxRow := 0 + for _, frame := range frames { + if frame.Row > maxRow { + maxRow = frame.Row + } + } + return maxRow +} + +func padOrTrim(s string, width int) string { + if width <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= width { + return s + strings.Repeat(" ", width-utf8.RuneCountInString(s)) + } + if width == 1 { + return "…" + } + r := []rune(s) + return string(r[:width-1]) + "…" +} diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index 33d902f..32f260f 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -108,6 +108,65 @@ func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) { } } +func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { + out := RenderTerminalView(nil, 50, 10, 0) + if !strings.Contains(out, "terminal too narrow") { + t.Fatalf("expected narrow terminal warning, got %q", out) + } +} + +func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 10, + Children: []*snapshotNode{ + {Name: "child", Total: 10}, + }, + } + frames := BuildTerminalLayout(snapshot, 80, 6) + + out := RenderTerminalView(frames, 80, 6, 1) + if !strings.Contains(out, "Flame | frames:2") { + t.Fatalf("expected toolbar to include frame count, got %q", out) + } + if !strings.Contains(out, "Selected: child") { + t.Fatalf("expected status line to show selected frame, got %q", out) + } +} + +func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 4, + Children: []*snapshotNode{ + { + Name: "a", + Total: 4, + Children: []*snapshotNode{ + { + Name: "b", + Total: 4, + Children: []*snapshotNode{ + { + Name: "c", + Total: 4, + Children: []*snapshotNode{ + {Name: "d", Total: 4}, + }, + }, + }, + }, + }, + }, + }, + } + frames := BuildTerminalLayout(snapshot, 80, 10) + out := RenderTerminalView(frames, 80, 4, 0) + if !strings.Contains(out, "showing deepest levels") { + t.Fatalf("expected truncation hint in toolbar, got %q", out) + } +} + func mustFindFrame(t *testing.T, frames []tuiFrame, path string) tuiFrame { t.Helper() for _, frame := range frames { |
