From f660ad10bccb529e7176b293ef4be05aeb612074 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 27 May 2026 08:29:07 +0300 Subject: flamegraph: table-drive metric cycles and semantic colors (9p) --- internal/tui/flamegraph/controls.go | 42 ++++++++++----------- internal/tui/flamegraph/model_test.go | 34 +++++++++++++++++ internal/tui/flamegraph/renderer.go | 63 ++++++++++++++++++++++++-------- internal/tui/flamegraph/renderer_test.go | 34 +++++++++++++++++ 4 files changed, 135 insertions(+), 38 deletions(-) (limited to 'internal') diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index 7eee102..8333abb 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -9,6 +9,22 @@ import ( "charm.land/lipgloss/v2" ) +var countFieldCycle = []string{"count", "bytes", "duration"} + +var heightFieldCycle = []string{"", "duration", "bytes", "count"} + +func nextCycleValue(current string, cycle []string) string { + if len(cycle) == 0 { + return current + } + for idx := range cycle { + if cycle[idx] == current { + return cycle[(idx+1)%len(cycle)] + } + } + return cycle[0] +} + func (m *Model) togglePause() { m.paused = !m.paused } @@ -57,18 +73,10 @@ func (m *Model) cycleFieldOrder() { } func (m *Model) toggleCountField() { - // 3-way cycle: count → bytes → duration → count. + // 3-way cycle: count -> bytes -> duration -> count. // durationToPrev (inter-syscall gap) is reachable via the CLI flag but // kept out of the toolbar cycle for now. - var next string - switch m.countField { - case "count": - next = "bytes" - case "bytes": - next = "duration" - default: - next = "count" - } + next := nextCycleValue(m.countField, countFieldCycle) if m.liveTrie != nil { if err := m.liveTrie.SetCountField(next); err != nil { m.statusMessage = "Metric toggle error: " + err.Error() @@ -81,18 +89,8 @@ func (m *Model) toggleCountField() { } func (m *Model) toggleHeightField() { - // 4-way cycle: off → duration → bytes → count → off. - var next string - switch m.heightField { - case "": - next = "duration" - case "duration": - next = "bytes" - case "bytes": - next = "count" - default: - next = "" - } + // 4-way cycle: off -> duration -> bytes -> count -> off. + next := nextCycleValue(m.heightField, heightFieldCycle) if m.liveTrie != nil { if err := m.liveTrie.SetHeightField(next); err != nil { m.statusMessage = "Height toggle error: " + err.Error() diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index f31a9a1..5445896 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -1079,6 +1079,40 @@ func TestControlHeightToggleReconfiguresLiveTrieHeightField(t *testing.T) { } } +func TestControlMetricToggleUnknownValueFallsBackToCount(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count", "") + m := NewModel(liveTrie) + m.countField = "unexpected" + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"}) + if got, want := m.countField, "count"; got != want { + t.Fatalf("expected model count field fallback %q, got %q", want, got) + } + if got, want := liveTrie.CountField(), "count"; got != want { + t.Fatalf("expected live trie count field fallback %q, got %q", want, got) + } + if got, want := m.statusMessage, "Metric: events (new baseline)"; got != want { + t.Fatalf("expected metric fallback status %q, got %q", want, got) + } +} + +func TestControlHeightToggleUnknownValueFallsBackToOff(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count", "") + m := NewModel(liveTrie) + m.heightField = "unexpected" + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'v'}[0], Text: "v"}) + if got, want := m.heightField, ""; got != want { + t.Fatalf("expected model height field fallback %q, got %q", want, got) + } + if got, want := liveTrie.HeightField(), ""; got != want { + t.Fatalf("expected live trie height field fallback %q, got %q", want, got) + } + if got, want := m.statusMessage, "Height: off (new baseline)"; got != want { + t.Fatalf("expected height fallback status %q, got %q", want, got) + } +} + func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) { liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count", "count") m := NewModel(liveTrie) diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 6d5a494..5edf527 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -199,26 +199,57 @@ func terminalFrameColor(name string) color.Color { } } +type semanticMatchKind int + +const ( + semanticMatchContains semanticMatchKind = iota + semanticMatchPrefix +) + +type semanticRule struct { + kind semanticMatchKind + pattern string + color color.RGBA +} + +var semanticColorRules = []semanticRule{ + {kind: semanticMatchContains, pattern: "read", color: color.RGBA{R: 78, G: 132, B: 201, A: 255}}, + {kind: semanticMatchContains, pattern: "pread", color: color.RGBA{R: 78, G: 132, B: 201, A: 255}}, + {kind: semanticMatchContains, pattern: "write", color: color.RGBA{R: 222, G: 122, B: 58, A: 255}}, + {kind: semanticMatchContains, pattern: "pwrite", color: color.RGBA{R: 222, G: 122, B: 58, A: 255}}, + {kind: semanticMatchContains, pattern: "open", color: color.RGBA{R: 196, G: 168, B: 72, A: 255}}, + {kind: semanticMatchContains, pattern: "close", color: color.RGBA{R: 196, G: 168, B: 72, A: 255}}, + {kind: semanticMatchContains, pattern: "stat", color: color.RGBA{R: 196, G: 168, B: 72, A: 255}}, + {kind: semanticMatchContains, pattern: "rename", color: color.RGBA{R: 196, G: 168, B: 72, A: 255}}, + {kind: semanticMatchContains, pattern: "link", color: color.RGBA{R: 196, G: 168, B: 72, A: 255}}, + {kind: semanticMatchPrefix, pattern: "/", color: color.RGBA{R: 88, G: 156, B: 84, A: 255}}, + {kind: semanticMatchContains, pattern: "path:", color: color.RGBA{R: 88, G: 156, B: 84, A: 255}}, + {kind: semanticMatchContains, pattern: "/", color: color.RGBA{R: 88, G: 156, B: 84, A: 255}}, + {kind: semanticMatchContains, pattern: "pid", color: color.RGBA{R: 67, G: 151, B: 149, A: 255}}, + {kind: semanticMatchContains, pattern: "tid", color: color.RGBA{R: 67, G: 151, B: 149, A: 255}}, + {kind: semanticMatchPrefix, pattern: "sys_", color: color.RGBA{R: 191, G: 99, B: 74, A: 255}}, +} + +func semanticRuleMatches(label string, rule semanticRule) bool { + switch rule.kind { + case semanticMatchPrefix: + return strings.HasPrefix(label, rule.pattern) + default: + return strings.Contains(label, rule.pattern) + } +} + func semanticFrameColor(name string) (color.Color, bool) { label := strings.ToLower(strings.TrimSpace(name)) - switch { - case label == "": - return nil, false - case strings.Contains(label, "read"), strings.Contains(label, "pread"): - return color.RGBA{R: 78, G: 132, B: 201, A: 255}, true // read I/O: blue - case strings.Contains(label, "write"), strings.Contains(label, "pwrite"): - return color.RGBA{R: 222, G: 122, B: 58, A: 255}, true // write I/O: orange - case strings.Contains(label, "open"), strings.Contains(label, "close"), strings.Contains(label, "stat"), strings.Contains(label, "rename"), strings.Contains(label, "link"): - return color.RGBA{R: 196, G: 168, B: 72, A: 255}, true // metadata I/O: amber - case strings.HasPrefix(label, "/"), strings.Contains(label, "path:"), strings.Contains(label, "/"): - return color.RGBA{R: 88, G: 156, B: 84, A: 255}, true // file paths: green - case strings.Contains(label, "pid"), strings.Contains(label, "tid"): - return color.RGBA{R: 67, G: 151, B: 149, A: 255}, true // process/thread dimensions: teal - case strings.HasPrefix(label, "sys_"): - return color.RGBA{R: 191, G: 99, B: 74, A: 255}, true // other syscall buckets: rust - default: + if label == "" { return nil, false } + for _, rule := range semanticColorRules { + if semanticRuleMatches(label, rule) { + return rule.color, true + } + } + return nil, false } // renderViewParams bundles the pre-computed layout parameters used by diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index 9b9ed88..8626c39 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -151,6 +151,40 @@ func TestTerminalFrameColorSemanticPalette(t *testing.T) { } } +func TestSemanticFrameColorRuleTableAndOrdering(t *testing.T) { + tests := []struct { + name string + label string + want color.RGBA + ok bool + }{ + {name: "empty", label: "", ok: false}, + {name: "read before path", label: "read/path", want: color.RGBA{R: 78, G: 132, B: 201, A: 255}, ok: true}, + {name: "sys fallback rust", label: "sys_custom", want: color.RGBA{R: 191, G: 99, B: 74, A: 255}, ok: true}, + {name: "path before pid", label: "pid/1234", want: color.RGBA{R: 88, G: 156, B: 84, A: 255}, ok: true}, + {name: "unmatched", label: "dimension", ok: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := semanticFrameColor(tc.label) + if ok != tc.ok { + t.Fatalf("expected ok=%v for %q, got %v", tc.ok, tc.label, ok) + } + if !tc.ok { + return + } + gotRGBA, castOK := got.(color.RGBA) + if !castOK { + t.Fatalf("expected semantic color type color.RGBA, got %T", got) + } + if gotRGBA != tc.want { + t.Fatalf("unexpected semantic color for %q: got=%v want=%v", tc.label, gotRGBA, tc.want) + } + }) + } +} + func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { out := RenderTerminalView(RenderContext{ Width: 50, -- cgit v1.2.3