diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:35:56 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:35:56 +0200 |
| commit | 3307447e4ae159b11bbe262ad161d6e3c571ee4c (patch) | |
| tree | 69f508b120715e102f8508ca88e4cbc8adf74a23 /internal/tui/flamegraph/model.go | |
| parent | 6948ab9b8880b318e43590bba8ecab77552348c3 (diff) | |
task 358: add flamegraph keyboard navigation
Diffstat (limited to 'internal/tui/flamegraph/model.go')
| -rw-r--r-- | internal/tui/flamegraph/model.go | 145 |
1 files changed, 143 insertions, 2 deletions
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index f13298e..8c8d434 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -6,7 +6,9 @@ import ( "image/color" coreflamegraph "ior/internal/flamegraph" common "ior/internal/tui/common" + "sort" + "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" ) @@ -24,6 +26,22 @@ type zoomState struct { type frameSpring struct{} +type flameKeyMap struct { + MoveShallower key.Binding + MoveDeeper key.Binding + PrevSibling key.Binding + NextSibling key.Binding +} + +func defaultFlameKeyMap() flameKeyMap { + return flameKeyMap{ + MoveShallower: key.NewBinding(key.WithKeys("j", "down")), + MoveDeeper: key.NewBinding(key.WithKeys("k", "up")), + PrevSibling: key.NewBinding(key.WithKeys("h", "left")), + NextSibling: key.NewBinding(key.WithKeys("l", "right")), + } +} + // Model is the Bubble Tea model for the TUI flamegraph tab. type Model struct { liveTrie *coreflamegraph.LiveTrie @@ -42,6 +60,7 @@ type Model struct { searchActive bool searchQuery string matchIndices map[int]bool + subtreeSet map[int]bool fieldPresets [][]string fieldIndex int @@ -49,6 +68,7 @@ type Model struct { springs []frameSpring paused bool isDark bool + keys flameKeyMap } // tuiFrame stores one terminal flamegraph frame cell. @@ -69,12 +89,14 @@ func NewModel(liveTrie *coreflamegraph.LiveTrie) Model { return Model{ liveTrie: liveTrie, matchIndices: make(map[int]bool), + subtreeSet: make(map[int]bool), fieldPresets: [][]string{ {"comm", "path"}, {"tracepoint", "comm", "path"}, {"pid", "tid", "comm", "path"}, }, isDark: true, + keys: defaultFlameKeyMap(), } } @@ -84,13 +106,30 @@ func (m Model) Init() tea.Cmd { } // Update handles incoming messages. -func (m Model) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + prev := m.selectedIdx + switch { + case key.Matches(msg, m.keys.MoveShallower): + m.moveVertical(-1) + case key.Matches(msg, m.keys.MoveDeeper): + m.moveVertical(1) + case key.Matches(msg, m.keys.PrevSibling): + m.moveSibling(-1) + case key.Matches(msg, m.keys.NextSibling): + m.moveSibling(1) + } + if m.selectedIdx != prev { + m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) + } + } return m, nil } // View renders the flamegraph viewport. func (m Model) View() tea.View { - content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.matchIndices, m.isDark) + content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.isDark) if m.snapshot != nil && len(m.frames) == 0 { content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) } @@ -102,6 +141,10 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { m.liveTrie = liveTrie m.lastVersion = 0 m.snapshot = nil + m.selectedIdx = 0 + m.frames = nil + m.targetFrames = nil + m.subtreeSet = make(map[int]bool) } // RefreshFromLiveTrie loads a new snapshot when the source version changes. @@ -121,6 +164,8 @@ func (m *Model) RefreshFromLiveTrie() bool { } m.targetFrames = BuildTerminalLayout(&snapshot, m.width, m.height) m.frames = append(m.frames[:0], m.targetFrames...) + m.clampSelection() + m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) m.snapshot = &snapshot m.lastVersion = version return true @@ -135,9 +180,105 @@ func (m Model) LastVersion() uint64 { func (m *Model) SetViewport(width, height int) { m.width = width m.height = height + m.targetFrames = BuildTerminalLayout(m.snapshot, width, height) + m.frames = append(m.frames[:0], m.targetFrames...) + m.clampSelection() + m.subtreeSet = computeSubtreeSet(m.frames, m.selectedIdx) } // SetDarkMode sets the active color theme mode. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark } + +func (m *Model) moveVertical(delta int) { + if len(m.frames) == 0 { + return + } + m.clampSelection() + current := m.frames[m.selectedIdx] + targetDepth := current.Depth + delta + targets := framesAtDepth(m.frames, targetDepth) + if len(targets) == 0 { + return + } + best := targets[0] + bestDist := abs(m.frames[best].Col - current.Col) + for _, idx := range targets[1:] { + dist := abs(m.frames[idx].Col - current.Col) + if dist < bestDist { + best = idx + bestDist = dist + } + } + m.selectedIdx = best +} + +func (m *Model) moveSibling(delta int) { + if len(m.frames) == 0 { + return + } + m.clampSelection() + current := m.frames[m.selectedIdx] + siblings := framesAtDepth(m.frames, current.Depth) + if len(siblings) <= 1 { + return + } + pos := indexOf(siblings, m.selectedIdx) + if pos < 0 { + return + } + next := pos + delta + if next < 0 { + next = 0 + } + if next >= len(siblings) { + next = len(siblings) - 1 + } + m.selectedIdx = siblings[next] +} + +func framesAtDepth(frames []tuiFrame, depth int) []int { + if depth < 0 { + return nil + } + indices := make([]int, 0) + for idx, frame := range frames { + if frame.Depth == depth { + indices = append(indices, idx) + } + } + sort.Slice(indices, func(i, j int) bool { + return frames[indices[i]].Col < frames[indices[j]].Col + }) + return indices +} + +func indexOf(values []int, target int) int { + for idx, value := range values { + if value == target { + return idx + } + } + return -1 +} + +func (m *Model) clampSelection() { + if len(m.frames) == 0 { + m.selectedIdx = 0 + return + } + if m.selectedIdx < 0 { + m.selectedIdx = 0 + } + if m.selectedIdx >= len(m.frames) { + m.selectedIdx = len(m.frames) - 1 + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} |
