From 5f246f7af40ff9875274624b7f392eff22313e77 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 10 Mar 2026 23:04:59 +0200 Subject: tui: split key normalization and help rendering (task 424) --- internal/tui/help.go | 145 +++++++++++++++++++++++++++ internal/tui/keys_normalize.go | 97 ++++++++++++++++++ internal/tui/tui.go | 223 ----------------------------------------- 3 files changed, 242 insertions(+), 223 deletions(-) create mode 100644 internal/tui/help.go create mode 100644 internal/tui/keys_normalize.go diff --git a/internal/tui/help.go b/internal/tui/help.go new file mode 100644 index 0000000..db8d9b2 --- /dev/null +++ b/internal/tui/help.go @@ -0,0 +1,145 @@ +package tui + +import ( + "fmt" + "strings" + + common "ior/internal/tui/common" + + "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" +) + +func renderHelpOverlay(width, height int, groups [][]key.Binding) string { + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + + lines := []string{"Help"} + for _, group := range groups { + parts := make([]string, 0, len(group)) + for _, binding := range group { + h := binding.Help() + parts = append(parts, fmt.Sprintf("%s %s", h.Key, h.Desc)) + } + lines = append(lines, strings.Join(parts, " • ")) + } + lines = append(lines, "", "Esc/?/q close") + + boxWidth := width - 6 + if boxWidth < 72 { + boxWidth = 72 + } + + box := common.PanelStyle.Copy(). + Width(boxWidth). + Render(strings.Join(lines, "\n")) + + 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", + "f filter p pid picker t tid picker o probes", + } + if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" { + globalLines[1] += " e stream export" + } + + return []helpSection{ + { + title: "Global", + lines: globalLines, + }, + { + title: "Dashboard Tabs", + lines: []string{ + "tab/shift+tab tabs 1..7 jump tab r reset baseline", + "sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom", + "sys/files/proc tables: s sort S reverse sort", + "sys/proc: v bubbles b metric events/bytes", + "files: d dirs toggle v bubbles (dirs only) b metric", + "flame: arrows/hjkl nav enter/click zoom click ancestor undo u/bs/esc undo o order", + "flame: / filter n/N match next/prev space pause b metric", + "stream: space pause enter push filter esc/F undo /? n/N search", + "stream: x/X export E open", + }, + }, + { + title: "PID/TID Picker", + lines: []string{ + "enter select r refresh esc/q 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/q 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 := common.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 +} diff --git a/internal/tui/keys_normalize.go b/internal/tui/keys_normalize.go new file mode 100644 index 0000000..173847e --- /dev/null +++ b/internal/tui/keys_normalize.go @@ -0,0 +1,97 @@ +package tui + +import ( + "fmt" + "time" + + tea "charm.land/bubbletea/v2" +) + +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. + if shouldSuppressMatchingPressAfterRelease(pressMsg) { + 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/key:%q/text:%q", msg.Code, msg.Mod, msg.String(), msg.Text) +} + +func releaseHasIdentity(msg tea.KeyPressMsg) bool { + if msg.Text != "" { + return true + } + keyStr := msg.String() + if keyStr != "" && keyStr != "\x00" { + return true + } + // Some terminals emit release-only space events without text identity. + return msg.Code == tea.KeySpace +} + +func shouldSuppressMatchingPressAfterRelease(msg tea.KeyPressMsg) bool { + keyStr := msg.String() + return msg.Code == tea.KeySpace || keyStr == " " || keyStr == "space" || msg.Text == " " +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 71b5cf9..434e813 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -607,95 +607,6 @@ func (m Model) handleModalDispatch(msg tea.Msg) (tea.Model, tea.Cmd, bool) { return m, nil, false } -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. - if shouldSuppressMatchingPressAfterRelease(pressMsg) { - 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/key:%q/text:%q", msg.Code, msg.Mod, msg.String(), msg.Text) -} - -func releaseHasIdentity(msg tea.KeyPressMsg) bool { - if msg.Text != "" { - return true - } - keyStr := msg.String() - if keyStr != "" && keyStr != "\x00" { - return true - } - // Some terminals emit release-only space events without text identity. - return msg.Code == tea.KeySpace -} - -func shouldSuppressMatchingPressAfterRelease(msg tea.KeyPressMsg) bool { - keyStr := msg.String() - return msg.Code == tea.KeySpace || keyStr == " " || keyStr == "space" || msg.Text == " " -} - func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.screen { case ScreenPIDPicker: @@ -1149,140 +1060,6 @@ func (s lateBoundDashboardSource) Reset() { } } -func renderHelpOverlay(width, height int, groups [][]key.Binding) string { - if width <= 0 { - width = 80 - } - if height <= 0 { - height = 24 - } - - lines := []string{"Help"} - for _, group := range groups { - parts := make([]string, 0, len(group)) - for _, binding := range group { - h := binding.Help() - parts = append(parts, fmt.Sprintf("%s %s", h.Key, h.Desc)) - } - lines = append(lines, strings.Join(parts, " • ")) - } - lines = append(lines, "", "Esc/?/q close") - - boxWidth := width - 6 - if boxWidth < 72 { - boxWidth = 72 - } - - box := common.PanelStyle.Copy(). - Width(boxWidth). - Render(strings.Join(lines, "\n")) - - 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", - "f filter p pid picker t tid picker o probes", - } - if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" { - globalLines[1] += " e stream export" - } - - return []helpSection{ - { - title: "Global", - lines: globalLines, - }, - { - title: "Dashboard Tabs", - lines: []string{ - "tab/shift+tab tabs 1..7 jump tab r reset baseline", - "sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom", - "sys/files/proc tables: s sort S reverse sort", - "sys/proc: v bubbles b metric events/bytes", - "files: d dirs toggle v bubbles (dirs only) b metric", - "flame: arrows/hjkl nav enter/click zoom click ancestor undo u/bs/esc undo o order", - "flame: / filter n/N match next/prev space pause b metric", - "stream: space pause enter push filter esc/F undo /? n/N search", - "stream: x/X export E open", - }, - }, - { - title: "PID/TID Picker", - lines: []string{ - "enter select r refresh esc/q 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/q 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 := common.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 -- cgit v1.2.3