summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/flamegraph')
-rw-r--r--internal/tui/flamegraph/controls.go42
-rw-r--r--internal/tui/flamegraph/model_test.go34
-rw-r--r--internal/tui/flamegraph/renderer.go63
-rw-r--r--internal/tui/flamegraph/renderer_test.go34
4 files changed, 135 insertions, 38 deletions
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,