diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:29:11 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:29:11 +0200 |
| commit | 63b9ad7c7692b3bedb4d0051c080946e38e058f9 (patch) | |
| tree | 3285479bdee924913ff622476ac74125232aae56 /internal/tui | |
| parent | 270c4b422cfc5e7588b7045276588e9f043f85e3 (diff) | |
task 355: add terminal flamegraph layout renderer
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/flamegraph/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 99 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 129 |
3 files changed, 230 insertions, 0 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index ac9b5af..637ba11 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -120,6 +120,8 @@ func (m *Model) RefreshFromLiveTrie() bool { if err := json.Unmarshal(payload, &snapshot); err != nil { return false } + m.targetFrames = BuildTerminalLayout(&snapshot, m.width, m.height) + m.frames = append(m.frames[:0], m.targetFrames...) m.snapshot = &snapshot m.lastVersion = version return true diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go new file mode 100644 index 0000000..6cace44 --- /dev/null +++ b/internal/tui/flamegraph/renderer.go @@ -0,0 +1,99 @@ +package flamegraph + +import ( + "hash/fnv" + "image/color" + "math" + "strings" +) + +const pathSeparator = "\x1f" + +// BuildTerminalLayout converts a live trie snapshot into terminal frame cells. +func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame { + if snapshot == nil || width <= 0 || height <= 0 { + return nil + } + rootTotal := snapshotTotal(snapshot) + if rootTotal == 0 { + return nil + } + + rootName := frameName(snapshot.Name, 0) + frames := make([]tuiFrame, 0, len(snapshot.Children)+1) + collectTerminalLayout(&frames, snapshot, rootTotal, width, height, 0, 0, rootName) + return frames +} + +func collectTerminalLayout(out *[]tuiFrame, node *snapshotNode, rootTotal uint64, width, height, depth, col int, path string) { + if node == nil || depth >= height { + return + } + total := snapshotTotal(node) + frameWidth := int(math.Floor(float64(width) * (float64(total) / float64(rootTotal)))) + if frameWidth < 1 { + return + } + + name := frameName(node.Name, depth) + *out = append(*out, tuiFrame{ + Name: name, + Col: col, + Row: depth, + Width: frameWidth, + Total: total, + Percent: 100 * float64(total) / float64(rootTotal), + Fill: terminalFrameColor(name), + Depth: depth, + Path: path, + }) + + cursor := col + for _, child := range node.Children { + childTotal := snapshotTotal(child) + childWidth := int(math.Floor(float64(width) * (float64(childTotal) / float64(rootTotal)))) + if childWidth < 1 { + continue + } + childName := frameName(child.Name, depth+1) + childPath := strings.Join([]string{path, childName}, pathSeparator) + collectTerminalLayout(out, child, rootTotal, width, height, depth+1, cursor, childPath) + cursor += childWidth + } +} + +func snapshotTotal(node *snapshotNode) uint64 { + if node == nil { + return 0 + } + total := node.Value + for _, child := range node.Children { + total += snapshotTotal(child) + } + if node.Total > total { + return node.Total + } + return total +} + +func frameName(name string, depth int) string { + if name != "" { + return name + } + if depth == 0 { + return "root" + } + return "(unknown)" +} + +func terminalFrameColor(name string) color.Color { + hasher := fnv.New32a() + _, _ = hasher.Write([]byte(name)) + h := hasher.Sum32() + return color.RGBA{ + R: uint8(200 + int(h%35)), + G: uint8(80 + int((h>>8)%120)), + B: uint8(40 + int((h>>16)%90)), + A: 255, + } +} diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go new file mode 100644 index 0000000..33d902f --- /dev/null +++ b/internal/tui/flamegraph/renderer_test.go @@ -0,0 +1,129 @@ +package flamegraph + +import ( + "strings" + "testing" +) + +func TestBuildTerminalLayoutWidthScaling(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "A", + Total: 60, + Children: []*snapshotNode{ + {Name: "A1", Total: 30}, + {Name: "A2", Total: 30}, + }, + }, + {Name: "B", Total: 40}, + }, + } + + tests := []struct { + width int + wantA int + wantB int + wantA1 int + wantA2 int + wantAll int + }{ + {width: 80, wantA: 48, wantB: 32, wantA1: 24, wantA2: 24, wantAll: 5}, + {width: 120, wantA: 72, wantB: 48, wantA1: 36, wantA2: 36, wantAll: 5}, + {width: 200, wantA: 120, wantB: 80, wantA1: 60, wantA2: 60, wantAll: 5}, + } + + for _, tc := range tests { + frames := BuildTerminalLayout(snapshot, tc.width, 10) + if len(frames) != tc.wantAll { + t.Fatalf("width %d: expected %d frames, got %d", tc.width, tc.wantAll, len(frames)) + } + root := mustFindFrame(t, frames, "root") + if root.Width != tc.width || root.Row != 0 || root.Col != 0 { + t.Fatalf("width %d: unexpected root frame %+v", tc.width, root) + } + a := mustFindFrame(t, frames, "root"+pathSeparator+"A") + b := mustFindFrame(t, frames, "root"+pathSeparator+"B") + a1 := mustFindFrame(t, frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + a2 := mustFindFrame(t, frames, "root"+pathSeparator+"A"+pathSeparator+"A2") + + if a.Width != tc.wantA || b.Width != tc.wantB { + t.Fatalf("width %d: unexpected child widths A=%d B=%d", tc.width, a.Width, b.Width) + } + if a1.Width != tc.wantA1 || a2.Width != tc.wantA2 { + t.Fatalf("width %d: unexpected grandchild widths A1=%d A2=%d", tc.width, a1.Width, a2.Width) + } + if b.Col != a.Col+a.Width { + t.Fatalf("width %d: expected B col %d, got %d", tc.width, a.Col+a.Width, b.Col) + } + } +} + +func TestBuildTerminalLayoutCullsSubCellFramesAndRespectsHeight(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "big", + Total: 99, + Children: []*snapshotNode{ + {Name: "deep", Total: 99}, + }, + }, + {Name: "tiny", Total: 1}, + }, + } + + frames := BuildTerminalLayout(snapshot, 80, 2) + if hasFrame(frames, "root"+pathSeparator+"tiny") { + t.Fatalf("expected tiny frame to be culled (<1 terminal cell)") + } + if hasFrame(frames, "root"+pathSeparator+"big"+pathSeparator+"deep") { + t.Fatalf("expected deep frame to be omitted due height limit") + } + if !hasFrame(frames, "root"+pathSeparator+"big") { + t.Fatalf("expected big frame to be present") + } +} + +func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 10, + Children: []*snapshotNode{ + {Name: "child", Total: 10}, + }, + } + + frames := BuildTerminalLayout(snapshot, 80, 4) + child := mustFindFrame(t, frames, "root"+pathSeparator+"child") + if !strings.Contains(child.Path, pathSeparator) { + t.Fatalf("expected path %q to contain separator %q", child.Path, pathSeparator) + } + if child.Fill == nil { + t.Fatalf("expected frame color to be set") + } +} + +func mustFindFrame(t *testing.T, frames []tuiFrame, path string) tuiFrame { + t.Helper() + for _, frame := range frames { + if frame.Path == path { + return frame + } + } + t.Fatalf("frame with path %q not found", path) + return tuiFrame{} +} + +func hasFrame(frames []tuiFrame, path string) bool { + for _, frame := range frames { + if frame.Path == path { + return true + } + } + return false +} |
