summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 22:29:11 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 22:29:11 +0200
commit63b9ad7c7692b3bedb4d0051c080946e38e058f9 (patch)
tree3285479bdee924913ff622476ac74125232aae56 /internal
parent270c4b422cfc5e7588b7045276588e9f043f85e3 (diff)
task 355: add terminal flamegraph layout renderer
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/flamegraph/model.go2
-rw-r--r--internal/tui/flamegraph/renderer.go99
-rw-r--r--internal/tui/flamegraph/renderer_test.go129
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
+}