diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 14:51:27 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 14:51:27 +0200 |
| commit | 4737786fd4a417ff94e22e4f72a1e924d4e033dd (patch) | |
| tree | 70a17e892e5367cb53737776b00551b06684e7da | |
| parent | 479f399aae8d3b28d9714214ea624d4a8cc0e886 (diff) | |
tui: add full-screen help overlay with H and esc close
| -rw-r--r-- | internal/tui/tui.go | 133 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 66 |
2 files changed, 199 insertions, 0 deletions
diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 19e164f..4006d84 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -187,6 +187,8 @@ type Model struct { keys KeyMap + helpOverlayVisible bool + width int height int quitting bool @@ -326,6 +328,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stopTrace() return m, tea.Quit } + if m.helpOverlayVisible { + if isHelpOverlayCloseKey(msg) || isHelpOverlayOpenKey(msg) { + m.helpOverlayVisible = false + } + return m, nil + } + if isHelpOverlayOpenKey(msg) && !m.attaching && m.lastErr == nil { + m.helpOverlayVisible = true + return m, nil + } if m.exportEnabled && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) { m.exporter = m.exporter.Open() return m, nil @@ -662,6 +674,10 @@ func (m Model) View() tea.View { if m.lastErr != nil { return altScreenView(placeToViewport(width, height, ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error()))), title) } + if m.helpOverlayVisible { + helpView := renderGlobalHelpOverlay(width, height, m.helpSections()) + return altScreenView(helpView, title) + } switch m.screen { case ScreenPIDPicker: @@ -684,6 +700,14 @@ func (m Model) View() tea.View { } } +func isHelpOverlayOpenKey(msg tea.KeyPressMsg) bool { + return msg.String() == "H" +} + +func isHelpOverlayCloseKey(msg tea.KeyPressMsg) bool { + return msg.Code == tea.KeyEsc || msg.String() == "esc" || msg.String() == "?" +} + func runExportCmd(exportEnabled bool, option tuiexport.Option, snap *statsengine.Snapshot) tea.Cmd { return func() tea.Msg { if !exportEnabled { @@ -840,6 +864,115 @@ func renderHelpOverlay(width, height int, groups [][]key.Binding) string { return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) } +type helpSection struct { + title string + lines []string +} + +func (m Model) helpSections() []helpSection { + globalLines := []string{ + "H help esc close help q quit", + "tab/shift+tab cycle tabs 1..7 jump tab", + "p pid picker t tid picker o probes r refresh", + } + if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" { + globalLines = append(globalLines, "e snapshot export") + } + + return []helpSection{ + { + title: "Global", + lines: globalLines, + }, + { + title: "Flame Tab", + lines: []string{ + "arrows/hjkl navigate pgup top pgdn root", + "enter zoom u/backspace/esc undo", + "/ filter n/N match next/prev", + "space/p pause o order r reset baseline", + }, + }, + { + title: "Stream Tab", + lines: []string{ + "space pause/live f add filter esc undo filter", + "enter apply filter / or ? search n/N next/prev", + "j/k/up/down scroll pgup/pgdn page g/G top/tail", + "left/right or h/l switch columns", + "c clear x export X export-as E open last", + }, + }, + { + title: "PID/TID Picker", + lines: []string{ + "enter select r refresh esc back", + }, + }, + } +} + +func renderGlobalHelpOverlay(width, height int, sections []helpSection) string { + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + + boxWidth := width - 4 + if boxWidth > 100 { + boxWidth = 100 + } + if boxWidth < 74 { + boxWidth = 74 + } + contentWidth := boxWidth - 4 + if contentWidth < 20 { + contentWidth = boxWidth + } + + lines := make([]string, 0, 24) + lines = append(lines, "Help") + for _, section := range sections { + lines = append(lines, "") + lines = append(lines, section.title) + for _, line := range section.lines { + lines = append(lines, " "+truncateHelpLine(line, contentWidth-2)) + } + } + lines = append(lines, "", "Esc close") + + maxLines := height - 4 + if maxLines < 6 { + maxLines = 6 + } + if len(lines) > maxLines { + lines = lines[:maxLines-1] + lines = append(lines, truncateHelpLine("... (resize for full help)", contentWidth)) + } + + box := PanelStyle.Copy().Width(boxWidth).Render(strings.Join(lines, "\n")) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +func truncateHelpLine(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + if width == 1 { + return "…" + } + r := []rune(s) + if len(r) >= width { + return string(r[:width-1]) + "…" + } + return s +} + func placeToViewport(width, height int, content string) string { if width <= 0 || height <= 0 { return content diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 946d0e3..87ad340 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -730,6 +730,49 @@ func TestViewShowsDashboardWithoutHelpOverlay(t *testing.T) { } } +func TestHelpOverlayOpensWithUppercaseHAndClosesWithEsc(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 100 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})}) + m = next.(Model) + if !m.helpOverlayVisible { + t.Fatalf("expected help overlay to become visible after H") + } + view := m.View().Content + if !strings.Contains(view, "Help") || !strings.Contains(view, "Global") || !strings.Contains(view, "Esc close") { + t.Fatalf("expected global help overlay content, got %q", view) + } + + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = next.(Model) + if m.helpOverlayVisible { + t.Fatalf("expected esc to close help overlay") + } + if !strings.Contains(m.View().Content, "press H for help") { + t.Fatalf("expected dashboard help hint after closing overlay") + } +} + +func TestHelpOverlayCanOpenFromPIDPicker(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenPIDPicker + m.width = 100 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})}) + m = next.(Model) + if !m.helpOverlayVisible { + t.Fatalf("expected help overlay to open on pid picker screen") + } + if !strings.Contains(m.View().Content, "PID/TID Picker") { + t.Fatalf("expected picker shortcuts in help overlay") + } +} + func TestQuestionMarkDoesNotBlockUnderlyingActions(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard @@ -931,3 +974,26 @@ func TestRenderHelpOverlayUsesWideViewport(t *testing.T) { t.Fatalf("expected wide help overlay to exceed previous 110-col cap, got %d", maxWidth) } } + +func TestGlobalHelpOverlayFitsStandardTerminal(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + out := renderGlobalHelpOverlay(80, 24, m.helpSections()) + + lines := strings.Split(out, "\n") + if len(lines) > 24 { + t.Fatalf("expected help overlay to fit within 24 lines, got %d", len(lines)) + } + + maxWidth := 0 + for _, line := range lines { + if w := lipgloss.Width(line); w > maxWidth { + maxWidth = w + } + } + if maxWidth > 80 { + t.Fatalf("expected help overlay width <= 80, got %d", maxWidth) + } + if !strings.Contains(out, "Flame Tab") || !strings.Contains(out, "Stream Tab") { + t.Fatalf("expected overlay to include tab-specific help sections") + } +} |
