summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-26 22:38:24 +0300
committerPaul Buetow <paul@buetow.org>2026-05-26 22:38:24 +0300
commit66332e4012e3cfad79f9309a4fd7937f5ccf0d26 (patch)
tree1159da3ce3a82a7c22c4e18626dba5ce2c4ab3de /internal
parentdbd2d5a9afc496b6e913885fea3922f3fed9c4a0 (diff)
flamegraph: add height metric controls/keybinding (so)
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/flamegraph/controls.go55
-rw-r--r--internal/tui/flamegraph/model.go29
-rw-r--r--internal/tui/flamegraph/model_test.go54
3 files changed, 129 insertions, 9 deletions
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index 8ec1051..42c3e3e 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -80,6 +80,30 @@ func (m *Model) toggleCountField() {
m.statusMessage = "Metric: " + m.countFieldLabel() + " (new baseline)"
}
+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 = ""
+ }
+ if m.liveTrie != nil {
+ if err := m.liveTrie.SetHeightField(next); err != nil {
+ m.statusMessage = "Height toggle error: " + err.Error()
+ return
+ }
+ }
+ m.heightField = next
+ m.clearSnapshotState(false)
+ m.statusMessage = "Height: " + m.heightFieldLabel() + " (new baseline)"
+}
+
func (m *Model) toggleHelp() {
m.showHelp = !m.showHelp
}
@@ -92,8 +116,8 @@ func (m Model) toolbarLine() string {
order := m.currentFieldPresetLabel()
// Use a Builder to avoid repeated allocations for the optional suffix segments.
var b strings.Builder
- b.WriteString(fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter/click:zoom | click ancestor:undo | u/esc:undo | r:reset | space:pause",
- state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel()))
+ b.WriteString(fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | v:height(%s) | /:search | enter/click:zoom | click ancestor:undo | u/esc:undo | r:reset | space:pause",
+ state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel(), m.heightFieldLabel()))
if m.searchQuery != "" {
b.WriteString(" | filter:")
b.WriteString(m.searchQuery)
@@ -118,7 +142,7 @@ func (m Model) helpOverlay() string {
if width <= 0 {
width = 80
}
- help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter/click zoom click ancestor undo u/backspace/esc undo / search n/N matches space pause r reset baseline o order b metric ? help"
+ help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter/click zoom click ancestor undo u/backspace/esc undo / search n/N matches space pause r reset baseline o order b metric v height ? help"
return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width))
}
@@ -131,8 +155,12 @@ func (m Model) selectionStatusLine() string {
if m.paused {
mode = "PAUSED"
}
+ heightLabel := ""
+ if m.heightField != "" {
+ heightLabel = " | height:" + m.heightFieldLabel()
+ }
if len(m.frames) == 0 {
- line := fmt.Sprintf("[%s] sel:none | arrows/hjkl navigate | enter zoom | / filter", mode)
+ line := fmt.Sprintf("[%s] sel:none | arrows/hjkl navigate | enter zoom | / filter%s", mode, heightLabel)
return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width))
}
selIdx := m.selectedIdx
@@ -156,8 +184,8 @@ func (m Model) selectionStatusLine() string {
}
// Use a Builder to avoid a separate allocation for the optional filter suffix.
var b strings.Builder
- b.WriteString(fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total(%s):%d | %s",
- mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, m.countFieldLabel(), frame.Total, shareLabel))
+ b.WriteString(fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total(%s):%d | %s%s",
+ mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, m.countFieldLabel(), frame.Total, shareLabel, heightLabel))
if m.searchQuery != "" {
b.WriteString(" | filter:")
b.WriteString(m.searchQuery)
@@ -191,3 +219,18 @@ func (m Model) countFieldLabel() string {
return m.countField
}
}
+
+func (m Model) heightFieldLabel() string {
+ switch m.heightField {
+ case "":
+ return "off"
+ case "count":
+ return "count"
+ case "bytes":
+ return "bytes"
+ case "duration":
+ return "duration"
+ default:
+ return m.heightField
+ }
+}
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index 7fb3983..065a78a 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -194,6 +194,7 @@ type Model struct {
fieldPresets [][]string
fieldIndex int
countField string
+ heightField string
paused bool
@@ -244,13 +245,16 @@ func NewModel(liveTrie LiveTrieSource) Model {
{"tracepoint", "comm", "path"},
{"pid", "tracepoint", "path"},
{"comm", "path", "tracepoint"},
+ {"tracepoint", "comm", "pid"},
},
- isDark: true,
- keys: defaultFlameKeyMap(),
- countField: "count",
+ isDark: true,
+ keys: defaultFlameKeyMap(),
+ countField: "count",
+ heightField: "",
}
m.syncFieldPresetToTrie()
m.syncCountFieldToTrie()
+ m.syncHeightFieldToTrie()
return m
}
@@ -357,6 +361,8 @@ func (m *Model) handleModeKey(msg tea.KeyPressMsg) bool {
m.cycleFieldOrder()
case isCycleMetricKey(msg):
m.toggleCountField()
+ case isToggleHeightKey(msg):
+ m.toggleHeightField()
case isHelpToggleKey(msg):
m.toggleHelp()
case isZoomInKey(msg, m.keys):
@@ -455,6 +461,7 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
isResetBaselineKey(msg),
isCycleOrderKey(msg),
isCycleMetricKey(msg),
+ isToggleHeightKey(msg),
isHelpToggleKey(msg):
return true
case isZoomInKey(msg, m.keys),
@@ -551,6 +558,7 @@ func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) {
m.liveTrie = liveTrie
m.syncFieldPresetToTrie()
m.syncCountFieldToTrie()
+ m.syncHeightFieldToTrie()
m.lastVersion = 0
m.snapshot = nil
m.globalTotal = 0
@@ -593,6 +601,20 @@ func (m *Model) syncCountFieldToTrie() {
m.countField = field
}
+func (m *Model) syncHeightFieldToTrie() {
+ if m.liveTrie == nil {
+ m.heightField = ""
+ return
+ }
+ field := strings.TrimSpace(m.liveTrie.HeightField())
+ switch field {
+ case "", "count", "bytes", "duration":
+ m.heightField = field
+ default:
+ m.heightField = ""
+ }
+}
+
// RefreshFromLiveTrie loads a new snapshot synchronously and returns true when
// a new snapshot was applied. Retained as a simple facade for tests; the
// production TUI now uses RefreshFromLiveTrieCmd to do the heavy lifting on a
@@ -921,6 +943,7 @@ func isCycleOrderKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "o" }
func isCycleMetricKey(msg tea.KeyPressMsg) bool {
return keyString(msg) == "b"
}
+func isToggleHeightKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "v" }
func isHelpToggleKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "?" }
func isZoomInKey(msg tea.KeyPressMsg, keys flameKeyMap) bool {
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index e9d16f7..c5da062 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -24,6 +24,9 @@ func TestNewModelDefaults(t *testing.T) {
if got, want := m.fieldPresets[0], []string{"comm", "tracepoint", "path"}; !reflect.DeepEqual(got, want) {
t.Fatalf("default field preset[0] = %v, want %v", got, want)
}
+ if got, want := m.fieldPresets[5], []string{"tracepoint", "comm", "pid"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("default field preset[5] = %v, want %v", got, want)
+ }
if !m.isDark {
t.Fatalf("expected dark mode enabled by default")
}
@@ -1003,6 +1006,49 @@ func TestControlMetricToggleReconfiguresLiveTrieCountField(t *testing.T) {
}
}
+func TestControlHeightToggleReconfiguresLiveTrieHeightField(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count", "")
+ m := NewModel(liveTrie)
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'v'}[0], Text: "v"})
+ if got, want := m.heightField, "duration"; got != want {
+ t.Fatalf("expected model height field %q, got %q", want, got)
+ }
+ if got, want := liveTrie.HeightField(), "duration"; got != want {
+ t.Fatalf("expected live trie height field %q, got %q", want, got)
+ }
+ if got, want := m.statusMessage, "Height: duration (new baseline)"; got != want {
+ t.Fatalf("expected height toggle status %q, got %q", want, got)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'v'}[0], Text: "v"})
+ if got, want := m.heightField, "bytes"; got != want {
+ t.Fatalf("expected model height field %q after second toggle, got %q", want, got)
+ }
+ if got, want := liveTrie.HeightField(), "bytes"; got != want {
+ t.Fatalf("expected live trie height field %q after second toggle, got %q", want, got)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'v'}[0], Text: "v"})
+ if got, want := m.heightField, "count"; got != want {
+ t.Fatalf("expected model height field %q after third toggle, got %q", want, got)
+ }
+ if got, want := liveTrie.HeightField(), "count"; got != want {
+ t.Fatalf("expected live trie height field %q after third toggle, got %q", want, got)
+ }
+
+ 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 %q after fourth toggle, got %q", want, got)
+ }
+ if got, want := liveTrie.HeightField(), ""; got != want {
+ t.Fatalf("expected live trie height field %q after fourth toggle, got %q", want, got)
+ }
+ if got, want := m.statusMessage, "Height: off (new baseline)"; got != want {
+ t.Fatalf("expected height toggle off status %q, got %q", want, got)
+ }
+}
+
func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) {
liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count", "count")
m := NewModel(liveTrie)
@@ -1019,6 +1065,14 @@ func TestNewModelAlignsCountFieldToLiveTrie(t *testing.T) {
}
}
+func TestNewModelAlignsHeightFieldToLiveTrie(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count", "bytes")
+ m := NewModel(liveTrie)
+ if got, want := m.heightField, "bytes"; got != want {
+ t.Fatalf("expected model height field to align with trie field, got %q want %q", got, want)
+ }
+}
+
func TestControlHelpToggle(t *testing.T) {
m := NewModel(nil)
m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"})