diff options
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/dashboard/model.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 37 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 39 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 119 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 219 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 4 | ||||
| -rw-r--r-- | internal/tui/tui.go | 106 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 100 |
8 files changed, 605 insertions, 23 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 7ec1362..b1d23bb 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -134,8 +134,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } var animCmd tea.Cmd - if m.liveTrie != nil && !m.flamegraphModel.Paused() && (!m.flamegraphModel.HasSnapshot() || m.liveTrie.Version() != m.flamegraphModel.LastVersion()) { - m.flamegraphModel.RefreshFromLiveTrie() + if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() { animCmd = m.flamegraphModel.AnimationCmd() } if animCmd != nil { @@ -368,6 +367,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { if m.width > 0 && m.height > 0 { m.flamegraphModel.SetViewport(m.width, m.height) } + m.flamegraphModel.RefreshFromLiveTrie() } // SetDarkMode updates dashboard child models for the active theme. diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 1dc1cc1..8904a2f 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -198,20 +198,45 @@ func TestFlameTickRefreshesFlamegraphModel(t *testing.T) { } } -func TestFlameTickLoadsInitialSnapshotWithoutVersionChange(t *testing.T) { +func TestSetLiveTriePreloadsInitialSnapshotWithoutVersionChange(t *testing.T) { liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.SetLiveTrie(liveTrie) m.activeTab = TabFlame - if m.flamegraphModel.HasSnapshot() { - t.Fatalf("expected fresh flame model to start without snapshot") + if !m.flamegraphModel.HasSnapshot() { + t.Fatalf("expected SetLiveTrie to preload a baseline snapshot") } next, _ := m.Update(flameTickMsg{}) model := next.(Model) if !model.flamegraphModel.HasSnapshot() { - t.Fatalf("expected flame tick to load initial snapshot even when trie version is unchanged") + t.Fatalf("expected flame tick to retain initial snapshot even when trie version is unchanged") + } +} + +func TestFlameTickPausedContinuesBootstrapRefresh(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + model := next.(Model) + + next, _ = model.Update(flameTickMsg{}) + model = next.(Model) + initialVersion := model.flamegraphModel.LastVersion() + + liveTrie.Reset() + if liveTrie.Version() == initialVersion { + t.Fatalf("expected reset to advance trie version") + } + + next, _ = model.Update(flameTickMsg{}) + model = next.(Model) + if got, want := model.flamegraphModel.LastVersion(), liveTrie.Version(); got != want { + t.Fatalf("expected paused flame tick bootstrap to refresh version, got %d want %d", got, want) } } @@ -359,10 +384,10 @@ func TestFlameTabReceivesResetAndPauseKeys(t *testing.T) { m.width = 120 m.height = 30 - next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})}) + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) model := next.(Model) if !strings.Contains(model.View().Content, "[PAUSED]") { - t.Fatalf("expected flame pause key to toggle paused state") + t.Fatalf("expected flame space key to toggle paused state") } next, cmd := model.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index 959a5b0..f411a13 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -29,6 +29,7 @@ func (m *Model) resetBaseline() { m.matchIndices = make(map[int]bool) m.filterVisible = make(map[int]bool) m.subtreeSet = make(map[int]bool) + m.hasNavigableSnapshot = false m.statusMessage = "Baseline reset" } @@ -55,6 +56,7 @@ func (m *Model) cycleFieldOrder() { m.matchIndices = make(map[int]bool) m.filterVisible = make(map[int]bool) m.subtreeSet = make(map[int]bool) + m.hasNavigableSnapshot = false m.statusMessage = "Order: " + strings.Join(nextPreset, "/") } @@ -68,13 +70,16 @@ func (m Model) toolbarLine() string { state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]") } order := m.currentFieldPresetLabel() - line := fmt.Sprintf("%s | view:%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | p:pause", state, compactFramePath(m.currentRootPath()), order) + line := fmt.Sprintf("%s | view:%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order) if m.searchQuery != "" { line += " | filter:" + m.searchQuery } if m.statusMessage != "" { line += " | " + m.statusMessage } + if m.lastKeyDebug != "" { + line += " | " + m.lastKeyDebug + } width := m.width if width <= 0 { width = 80 @@ -87,10 +92,40 @@ func (m Model) helpOverlay() string { if width <= 0 { width = 80 } - help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches p pause r reset baseline o order ? help" + help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches space/p pause r reset baseline o order ? help" return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width)) } +func (m Model) selectionStatusLine() string { + width := m.width + if width <= 0 { + width = 80 + } + mode := "LIVE" + if m.paused { + mode = "PAUSED" + } + if len(m.frames) == 0 { + line := fmt.Sprintf("[%s] sel:none | arrows/hjkl navigate | enter zoom | / filter", mode) + return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width)) + } + selIdx := m.selectedIdx + if selIdx < 0 || selIdx >= len(m.frames) { + selIdx = 0 + } + frame := m.frames[selIdx] + systemShare := frame.Percent + if m.globalTotal > 0 { + systemShare = percentOfTotal(frame.Total, m.globalTotal) + } + line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total:%d | %.2f%% system", + mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, frame.Total, systemShare) + if m.searchQuery != "" { + line += " | filter:" + m.searchQuery + } + return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width)) +} + func (m Model) currentFieldPresetLabel() string { if len(m.fieldPresets) == 0 { return "n/a" diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index cca2fe5..b205d33 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -79,6 +79,7 @@ type Model struct { subtreeSet map[int]bool showHelp bool statusMessage string + lastKeyDebug string fieldPresets [][]string fieldIndex int @@ -86,8 +87,11 @@ type Model struct { animation AnimationState animating bool paused bool - isDark bool - keys flameKeyMap + // hasNavigableSnapshot flips once we have at least one selectable non-root + // frame. Paused mode can still bootstrap snapshots until then. + hasNavigableSnapshot bool + isDark bool + keys flameKeyMap } // tuiFrame stores one terminal flamegraph frame cell. @@ -159,56 +163,78 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyPressMsg: if m.searchActive { + handled := false switch msg.String() { case "esc": + handled = true m.clearSearch() + m.recordKeyDebug(msg, handled, false) return m, nil case "enter": + handled = true m.applySearchQuery(m.searchInput.Value()) m.searchActive = false m.searchInput.Blur() + m.recordKeyDebug(msg, handled, false) return m, nil } var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) _ = cmd + m.recordKeyDebug(msg, true, false) return m, nil } prev := m.selectedIdx + handled := false switch { case isSearchOpenKey(msg): + handled = true m.openSearch() case isNextMatchKey(msg): + handled = true m.jumpMatch(1) case isPrevMatchKey(msg): + handled = true m.jumpMatch(-1) case isPauseKey(msg): + handled = true m.togglePause() case isResetBaselineKey(msg): + handled = true m.resetBaseline() case isCycleOrderKey(msg): + handled = true m.cycleFieldOrder() case isHelpToggleKey(msg): + handled = true m.toggleHelp() case isZoomInKey(msg, m.keys): + handled = true m.zoomIn() case isZoomUndoKey(msg, m.keys): + handled = true m.zoomUndo() case isZoomResetKey(msg, m.keys): + handled = true m.zoomReset() case isMoveShallowerKey(msg, m.keys): - m.moveVerticalWithFallback(-1, 1) + handled = true + m.moveVerticalWithFallback(-1, 1, -1) case isMoveDeeperKey(msg, m.keys): - m.moveVerticalWithFallback(1, -1) + handled = true + m.moveVerticalWithFallback(1, -1, 1) case isPrevSiblingKey(msg, m.keys): + handled = true m.moveSibling(-1) case isNextSiblingKey(msg, m.keys): + handled = true m.moveSibling(1) } if m.selectedIdx != prev { m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) } + m.recordKeyDebug(msg, handled, m.selectedIdx != prev) } return m, nil } @@ -251,6 +277,7 @@ func (m Model) View() tea.View { if m.snapshot != nil && len(m.frames) == 0 { content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) } + content += "\n" + m.selectionStatusLine() if m.showHelp { content += "\n" + m.helpOverlay() } @@ -273,6 +300,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { m.filterVisible = make(map[int]bool) m.animation = NewAnimationState(30, 6.0, 1.0) m.animating = false + m.hasNavigableSnapshot = false } // RefreshFromLiveTrie loads a new snapshot when the source version changes. @@ -280,7 +308,8 @@ func (m *Model) RefreshFromLiveTrie() bool { if m.liveTrie == nil { return false } - if m.paused { + // Keep bootstrapping while paused until we have a navigable snapshot. + if m.paused && m.snapshot != nil && m.hasNavigableSnapshot { return false } version := m.liveTrie.Version() @@ -359,6 +388,9 @@ func (m *Model) rebuildFrames(animate bool) { m.animating = false m.frames = append(m.frames[:0], m.targetFrames...) } + if len(m.frames) > 1 { + m.hasNavigableSnapshot = true + } m.clampSelection() m.recomputeFilterState() m.ensureSelectionNavigable() @@ -451,27 +483,33 @@ func (m *Model) moveVertical(delta int) { m.selectedIdx = best } -func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta int) { +func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta, traversalDelta int) { before := m.selectedIdx m.moveVertical(primaryDelta) if m.selectedIdx == before && fallbackDelta != 0 { m.moveVertical(fallbackDelta) } + if m.selectedIdx == before && traversalDelta != 0 { + m.moveTraversal(traversalDelta) + } } func (m *Model) moveSibling(delta int) { if len(m.frames) == 0 { return } + before := m.selectedIdx m.clampSelection() m.ensureSelectionNavigable() current := m.frames[m.selectedIdx] siblings := m.framesAtDepth(current.Depth) if len(siblings) <= 1 { + m.moveTraversal(delta) return } pos := indexOf(siblings, m.selectedIdx) if pos < 0 { + m.moveTraversal(delta) return } next := pos + delta @@ -482,6 +520,9 @@ func (m *Model) moveSibling(delta int) { next = len(siblings) - 1 } m.selectedIdx = siblings[next] + if m.selectedIdx == before { + m.moveTraversal(delta) + } } func framesAtDepth(frames []tuiFrame, depth int) []int { @@ -602,6 +643,67 @@ func (m *Model) ensureSelectionNavigable() { } } +func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) { + keyID := keyString(msg) + if keyID == "" { + keyID = fmt.Sprintf("code:%d", msg.Code) + } + sel := "-" + selIdx := m.selectedIdx + if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) { + sel = compactFramePath(m.frames[m.selectedIdx].Path) + } + m.lastKeyDebug = fmt.Sprintf("dbg frames=%d idx=%d key=%q code=%d handled=%t moved=%t sel=%s", len(m.frames), selIdx, keyID, msg.Code, handled, moved, sel) +} + +func (m *Model) moveTraversal(delta int) { + if len(m.frames) == 0 || delta == 0 { + return + } + order := m.visibleTraversalOrder() + if len(order) == 0 { + return + } + pos := indexOf(order, m.selectedIdx) + if pos < 0 { + pos = 0 + } + next := pos + delta + if next < 0 { + next = 0 + } + if next >= len(order) { + next = len(order) - 1 + } + m.selectedIdx = order[next] +} + +func (m Model) visibleTraversalOrder() []int { + indices := make([]int, 0, len(m.frames)) + include := m.navigableFrameSet() + for idx := range m.frames { + if include != nil && !include[idx] { + continue + } + indices = append(indices, idx) + } + sort.Slice(indices, func(i, j int) bool { + left := m.frames[indices[i]] + right := m.frames[indices[j]] + if left.Depth != right.Depth { + return left.Depth < right.Depth + } + if left.Col != right.Col { + return left.Col < right.Col + } + if left.Row != right.Row { + return left.Row < right.Row + } + return indices[i] < indices[j] + }) + return indices +} + func keyString(msg tea.KeyPressMsg) string { if s := msg.String(); s != "" { return s @@ -612,7 +714,10 @@ func keyString(msg tea.KeyPressMsg) string { func isSearchOpenKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "/" } func isNextMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "n" } func isPrevMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "N" } -func isPauseKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "p" } +func isPauseKey(msg tea.KeyPressMsg) bool { + k := keyString(msg) + return k == "p" || k == " " || k == "space" || msg.Code == tea.KeySpace +} func isResetBaselineKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "r" } diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index f58f890..7387ac6 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -2,6 +2,7 @@ package flamegraph import ( "reflect" + "strings" "testing" coreflamegraph "ior/internal/flamegraph" @@ -53,6 +54,59 @@ func TestRefreshFromLiveTrieTracksVersionAndSnapshot(t *testing.T) { } } +func TestRefreshFromLiveTrieAllowsInitialLoadWhilePaused(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial paused refresh to load first snapshot") + } + if m.snapshot == nil { + t.Fatalf("expected snapshot to be available after initial paused refresh") + } + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected subsequent paused refresh to be skipped once snapshot exists") + } +} + +func TestRefreshFromLiveTriePausedBlocksAfterNavigableSnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + m.snapshot = &snapshotNode{Name: "root", Total: 1} + m.frames = []tuiFrame{ + {Name: "root", Path: "root"}, + {Name: "child", Path: "root" + pathSeparator + "child"}, + } + m.hasNavigableSnapshot = true + m.lastVersion = 1 + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected paused refresh to remain frozen once navigable snapshot exists") + } + if got, want := m.lastVersion, uint64(1); got != want { + t.Fatalf("expected version to remain unchanged while paused, got %d want %d", got, want) + } +} + +func TestRefreshFromLiveTriePausedKeepsBootstrappingWithoutNavigableSnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + m.snapshot = &snapshotNode{Name: "root", Total: 1} + m.frames = []tuiFrame{{Name: "root", Path: "root"}} + m.hasNavigableSnapshot = false + m.lastVersion = 1 + + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected paused refresh to continue bootstrapping before navigation is possible") + } + if got, want := m.lastVersion, trie.Version(); got != want { + t.Fatalf("expected paused bootstrap refresh to track trie version, got %d want %d", got, want) + } +} + func TestKeyboardNavigationDeepNarrowTree(t *testing.T) { m := NewModel(nil) m.frames = []tuiFrame{ @@ -106,6 +160,141 @@ func TestKeyboardNavigationShallowWideSiblings(t *testing.T) { } } +func TestHorizontalTraversalFallbackFromRoot(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + {Name: "B", Depth: 1, Col: 30, Path: "root" + pathSeparator + "B"}, + } + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected right arrow from root to move to first traversable frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 2 { + t.Fatalf("expected vi right key to move to next frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.selectedIdx != 1 { + t.Fatalf("expected left arrow to move back to previous frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"}) + if m.selectedIdx != 0 { + t.Fatalf("expected vi left key to move back to root, got idx %d", m.selectedIdx) + } +} + +func TestPausedStateStillAllowsNavigation(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + } + m.paused = true + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected navigation to work while paused, got idx %d", m.selectedIdx) + } +} + +func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestFlameData(trie) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected seeded fixture refresh to load frames") + } + if len(m.frames) < 2 { + t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames)) + } + + visited := map[int]bool{m.selectedIdx: true} + for i := 0; i < len(m.frames)*4; i++ { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + visited[m.selectedIdx] = true + } + for i := 0; i < len(m.frames)*4; i++ { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + visited[m.selectedIdx] = true + } + + if got, want := len(visited), len(m.frames); got != want { + t.Fatalf("expected arrow traversal to visit all frames: visited=%d frames=%d", got, want) + } + if !strings.Contains(m.View().Content, "sel:") { + t.Fatalf("expected view to expose selected-frame status line") + } +} + +func TestLiveFixtureArrowTraversalWhileStreamingVisitsAllFrames(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestLiveFlameData(trie, 0) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial refresh to load frames") + } + if len(m.frames) < 2 { + t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames)) + } + + selectedPath := func(model Model) string { + if len(model.frames) == 0 || model.selectedIdx < 0 || model.selectedIdx >= len(model.frames) { + return "" + } + return model.frames[model.selectedIdx].Path + } + + visitedPaths := map[string]bool{selectedPath(m): true} + moves := 0 + for i := 0; i < len(m.frames)*4; i++ { + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1)) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after synthetic live ingest at step %d", i) + } + before := selectedPath(m) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + after := selectedPath(m) + if after != before { + moves++ + } + visitedPaths[after] = true + } + for i := 0; i < len(m.frames)*4; i++ { + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1+len(m.frames)*4)) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after synthetic live ingest (reverse) at step %d", i) + } + before := selectedPath(m) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + after := selectedPath(m) + if after != before { + moves++ + } + visitedPaths[after] = true + } + + if moves == 0 { + t.Fatalf("expected live-stream navigation to change selection at least once") + } + if len(visitedPaths) < 8 { + t.Fatalf("expected traversal across live updates to reach multiple frame paths, got %d", len(visitedPaths)) + } +} + func TestKeyboardNavigationSingleNodeClamped(t *testing.T) { m := NewModel(nil) m.frames = []tuiFrame{{Name: "root", Depth: 0, Col: 0, Path: "root"}} @@ -350,9 +539,17 @@ func TestControlPauseToggle(t *testing.T) { if !m.paused { t.Fatalf("expected pause to toggle on") } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if m.paused { + t.Fatalf("expected space key to toggle pause off") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if !m.paused { + t.Fatalf("expected space key to toggle pause on") + } m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) if m.paused { - t.Fatalf("expected pause to toggle off") + t.Fatalf("expected p key to toggle pause off") } } @@ -374,6 +571,26 @@ func TestControlResetBaseline(t *testing.T) { } } +func TestViewIncludesSelectionStatusBar(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 100, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 40, Percent: 40, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 100 + + view := m.View().Content + if !strings.Contains(view, "[LIVE] sel:2/2 child") { + t.Fatalf("expected selection status bar to include selected frame info, got %q", view) + } + if !strings.Contains(view, "40.00% system") { + t.Fatalf("expected selection status bar to include selected share, got %q", view) + } +} + func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) { liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") m := NewModel(liveTrie) diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 517929e..67ad66e 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -435,10 +435,10 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat } if isSelected { - selectedBg := lipgloss.Color("99") + selectedBg := lipgloss.Color("129") selectedFg := lipgloss.Color("15") if !isDark { - selectedBg = lipgloss.Color("93") + selectedBg = lipgloss.Color("129") selectedFg = lipgloss.Color("15") } return base.Background(selectedBg).Foreground(selectedFg).Bold(true).Underline(true) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7918c0f..0381784 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -166,6 +166,16 @@ func RunWithTraceStarter(starter TraceStarter) error { return err } +// RunTestFlamesWithTraceStarter starts the TUI directly on dashboard/flame view +// with a synthetic static flamegraph source. +func RunTestFlamesWithTraceStarter(starter TraceStarter) error { + cfg := flags.Get() + model := newModelWithRuntimeConfig(1, 1, cfg.TUIExportEnable, starter) + program := tea.NewProgram(model) + _, err := program.Run() + return err +} + // Model is the top-level Bubble Tea model that routes between PID picker and dashboard. type Model struct { screen Screen @@ -195,6 +205,15 @@ type Model struct { keyboardEnhancements tea.KeyboardEnhancementsMsg keyboardEnhancementsKnown bool + + lastKeyEventID string + lastKeyEventAt time.Time + lastKeyEventWasPress bool + // Some terminals emit release+press for a single physical key event. + // When we fallback-handle a release as a press, suppress the immediate + // matching press to avoid double-handling. + suppressPressKeyID string + suppressPressUntil time.Time } // NewModel creates the top-level TUI model. @@ -269,6 +288,12 @@ func initialWindowSizeCmd() tea.Cmd { // Update routes messages, transitions screens, and manages tracing startup state. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + normalizedMsg, ok := m.normalizeKeyEvent(msg) + if !ok { + return m, nil + } + msg = normalizedMsg + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -382,6 +407,87 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateActiveModel(msg) } +func (m *Model) normalizeKeyEvent(msg tea.Msg) (tea.Msg, bool) { + switch keyMsg := msg.(type) { + case tea.KeyPressMsg: + keyID := keyEventID(keyMsg) + if m.shouldSuppressPress(keyID) { + return nil, false + } + m.recordKeyEvent(keyMsg, true) + return keyMsg, true + case tea.KeyReleaseMsg: + pressMsg := tea.KeyPressMsg(keyMsg) + keyID := keyEventID(pressMsg) + if m.lastKeyEventWasPress && keyID != "" && keyID == m.lastKeyEventID && time.Since(m.lastKeyEventAt) <= 500*time.Millisecond { + // Some terminals emit both press+release; avoid handling release as a duplicate. + m.lastKeyEventWasPress = false + return nil, false + } + if !releaseHasIdentity(pressMsg) { + // Ignore release messages that don't carry enough identity information. + // Some terminals emit these before a usable press event. + return nil, false + } + // Fallback: treat release as press for terminals that only emit release events. + m.armPressSuppression(keyID) + m.recordKeyEvent(pressMsg, false) + return pressMsg, true + default: + return msg, true + } +} + +func (m *Model) shouldSuppressPress(keyID string) bool { + if m.suppressPressKeyID == "" { + return false + } + if time.Now().After(m.suppressPressUntil) { + m.clearPressSuppression() + return false + } + if keyID == "" || keyID != m.suppressPressKeyID { + return false + } + m.clearPressSuppression() + return true +} + +func (m *Model) armPressSuppression(keyID string) { + if keyID == "" { + return + } + // Keep this short so fast repeated key presses still work naturally. + m.suppressPressKeyID = keyID + m.suppressPressUntil = time.Now().Add(60 * time.Millisecond) +} + +func (m *Model) clearPressSuppression() { + m.suppressPressKeyID = "" + m.suppressPressUntil = time.Time{} +} + +func (m *Model) recordKeyEvent(msg tea.KeyPressMsg, wasPress bool) { + m.lastKeyEventID = keyEventID(msg) + m.lastKeyEventAt = time.Now() + m.lastKeyEventWasPress = wasPress +} + +func keyEventID(msg tea.KeyPressMsg) string { + return fmt.Sprintf("code:%d/mod:%d", msg.Code, msg.Mod) +} + +func releaseHasIdentity(msg tea.KeyPressMsg) bool { + if msg.Code != 0 { + return true + } + if msg.Text != "" { + return true + } + keyStr := msg.String() + return keyStr != "" && keyStr != "\x00" +} + func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.screen { case ScreenPIDPicker: diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7d2a439..6cdc427 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -303,13 +303,107 @@ func TestFlamePauseKeyDoesNotTriggerPIDReselect(t *testing.T) { m.width = 120 m.height = 30 - next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})}) + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) updated := next.(Model) if updated.screen != ScreenDashboard { - t.Fatalf("expected flame pause key to keep dashboard screen, got %v", updated.screen) + t.Fatalf("expected flame space key to keep dashboard screen, got %v", updated.screen) } if !strings.Contains(updated.View().Content, "[PAUSED]") { - t.Fatalf("expected flame pause key to toggle flame paused state") + t.Fatalf("expected flame space key to toggle flame paused state") + } +} + +func TestFlameSpaceKeyReleaseFallbackTogglesPause(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "}) + updated := next.(Model) + if !strings.Contains(updated.View().Content, "[PAUSED]") { + t.Fatalf("expected key release fallback to toggle flame paused state") + } +} + +func TestFlameSpacePressReleaseDoesNotDoubleTogglePause(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + updated := next.(Model) + if !strings.Contains(updated.View().Content, "[PAUSED]") { + t.Fatalf("expected key press to pause flame") + } + + next, _ = updated.Update(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "}) + updated = next.(Model) + if !strings.Contains(updated.View().Content, "[PAUSED]") { + t.Fatalf("expected key release after key press to be ignored as duplicate") + } +} + +func TestFlameSpaceReleasePressDoesNotDoubleTogglePause(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "}) + updated := next.(Model) + if !strings.Contains(updated.View().Content, "[PAUSED]") { + t.Fatalf("expected key release fallback to pause flame") + } + + next, _ = updated.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + updated = next.(Model) + if !strings.Contains(updated.View().Content, "[PAUSED]") { + t.Fatalf("expected immediate matching key press after release fallback to be ignored") + } +} + +func TestNormalizeKeyEventReleaseFallbackSuppressesImmediatePressOnly(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + + normalized, ok := m.normalizeKeyEvent(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "}) + if !ok { + t.Fatalf("expected release fallback to be handled") + } + if _, isPress := normalized.(tea.KeyPressMsg); !isPress { + t.Fatalf("expected release fallback to normalize to KeyPressMsg, got %T", normalized) + } + + if normalized, ok = m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}); ok { + t.Fatalf("expected immediate matching press to be suppressed, got %T", normalized) + } + + time.Sleep(70 * time.Millisecond) + if normalized, ok = m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}); !ok { + t.Fatalf("expected press to be accepted after suppression window") + } + if _, isPress := normalized.(tea.KeyPressMsg); !isPress { + t.Fatalf("expected accepted message to be KeyPressMsg, got %T", normalized) + } +} + +func TestNormalizeKeyEventIgnoresUnidentifiedRelease(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + + if normalized, ok := m.normalizeKeyEvent(tea.KeyReleaseMsg{}); ok { + t.Fatalf("expected unidentified release to be ignored, got %T", normalized) + } + + normalized, ok := m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if !ok { + t.Fatalf("expected subsequent real key press to be handled") + } + if _, isPress := normalized.(tea.KeyPressMsg); !isPress { + t.Fatalf("expected normalized message to be KeyPressMsg, got %T", normalized) } } |
