summaryrefslogtreecommitdiff
path: root/internal/tui/keys_normalize.go
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 /internal/tui/keys_normalize.go
parent27798b61d6ac4b14ea5129ac28131302a6c5cb30 (diff)
tui: split key normalization and help rendering (task 424)
Diffstat (limited to 'internal/tui/keys_normalize.go')
-rw-r--r--internal/tui/keys_normalize.go97
1 files changed, 97 insertions, 0 deletions
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 == " "
+}