diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-26 22:38:24 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-26 22:38:24 +0300 |
| commit | 66332e4012e3cfad79f9309a4fd7937f5ccf0d26 (patch) | |
| tree | 1159da3ce3a82a7c22c4e18626dba5ce2c4ab3de /internal/tui/flamegraph | |
| parent | dbd2d5a9afc496b6e913885fea3922f3fed9c4a0 (diff) | |
flamegraph: add height metric controls/keybinding (so)
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 55 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 29 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 54 |
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: "?"}) |
