summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-10 23:04:59 +0200
committerPaul Buetow <paul@buetow.org>2026-03-10 23:04:59 +0200
commit5f246f7af40ff9875274624b7f392eff22313e77 (patch)
tree2e1be54a6d7f6fb616b1c24533dc6db170c80c63
parent27798b61d6ac4b14ea5129ac28131302a6c5cb30 (diff)
tui: split key normalization and help rendering (task 424)
-rw-r--r--internal/tui/help.go145
-rw-r--r--internal/tui/keys_normalize.go97
-rw-r--r--internal/tui/tui.go223
3 files changed, 242 insertions, 223 deletions
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