summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 22:31:23 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 22:31:23 +0200
commitc432bae0f3afaa05766ca8fcb1a3916f67d747a1 (patch)
tree5b61c01773a56dea1290f45845fb52bfdee66422 /internal
parent63b9ad7c7692b3bedb4d0051c080946e38e058f9 (diff)
task 356: implement flamegraph terminal view renderer
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/flamegraph/model.go7
-rw-r--r--internal/tui/flamegraph/renderer.go138
-rw-r--r--internal/tui/flamegraph/renderer_test.go59
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 {