From 351aa0331bfdab61b69d63b46911de21b1feea90 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Wed, 25 Mar 2026 01:43:37 +0200 Subject: [PATCH] fix: unify all text input behavior, fix cursor navigations fix: text cursor visual fix: text delete word fix: home/end navigation --- internal/ui/actions.go | 7 +- internal/ui/commands.go | 4 +- internal/ui/commands_test.go | 6 +- internal/ui/keys.go | 76 +++------------- internal/ui/keys_test.go | 164 +++++++++++++++++------------------ internal/ui/layout.go | 8 +- internal/ui/layout_test.go | 24 ++--- internal/ui/model.go | 16 ++-- internal/ui/text.go | 164 +++++++++++++++++++++++++++-------- internal/ui/text_test.go | 97 +++++++++++++++------ internal/ui/update.go | 1 - internal/ui/view.go | 23 +++-- internal/ui/view_test.go | 8 +- 13 files changed, 341 insertions(+), 257 deletions(-) diff --git a/internal/ui/actions.go b/internal/ui/actions.go index 00cde09..eecad43 100644 --- a/internal/ui/actions.go +++ b/internal/ui/actions.go @@ -95,7 +95,7 @@ func (m *model) actionGoToLast() (tea.Model, tea.Cmd) { func (m *model) actionEnterFilter() (tea.Model, tea.Cmd) { m.filterMode = true - m.filterCursor = len(m.filter) + m.filterInput.Cursor = len(m.filterInput.Text) return m, nil } @@ -103,7 +103,7 @@ func (m *model) actionToggleRegexFilter() (tea.Model, tea.Cmd) { m.filterMode = true m.filterRegex = !m.filterRegex m.filterRegexErr = nil - m.filterCursor = len(m.filter) + m.filterInput.Cursor = len(m.filterInput.Text) m.updateFiltered() return m, nil } @@ -141,8 +141,7 @@ func (m *model) actionQuit() (tea.Model, tea.Cmd) { func (m *model) actionOpenPalette() (tea.Model, tea.Cmd) { m.cmdPaletteMode = true - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 + m.cmdPaletteInput.clear() m.cmdPaletteSelected = 0 return m, nil } diff --git a/internal/ui/commands.go b/internal/ui/commands.go index bbea01b..7106779 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -41,10 +41,10 @@ func commands() []command { // filteredCommands returns commands matching the current palette filter. func (m *model) filteredCommands() []command { all := commands() - if m.cmdPaletteFilter == "" { + if m.cmdPaletteInput.Text == "" { return all } - filter := strings.ToLower(m.cmdPaletteFilter) + filter := strings.ToLower(m.cmdPaletteInput.Text) var result []command for _, c := range all { if strings.Contains(strings.ToLower(c.name), filter) { diff --git a/internal/ui/commands_test.go b/internal/ui/commands_test.go index 3e56663..c837277 100644 --- a/internal/ui/commands_test.go +++ b/internal/ui/commands_test.go @@ -13,7 +13,7 @@ func TestCommandsCount(t *testing.T) { func TestFilteredCommandsNoFilter(t *testing.T) { m := testModelWithLines() - m.cmdPaletteFilter = "" + m.cmdPaletteInput.Text = "" filtered := m.filteredCommands() all := commands() if len(filtered) != len(all) { @@ -23,7 +23,7 @@ func TestFilteredCommandsNoFilter(t *testing.T) { func TestFilteredCommandsWithFilter(t *testing.T) { m := testModelWithLines() - m.cmdPaletteFilter = "reload" + m.cmdPaletteInput.Text = "reload" filtered := m.filteredCommands() // "Reload command" and "Reload & clear lines" if len(filtered) != 2 { @@ -33,7 +33,7 @@ func TestFilteredCommandsWithFilter(t *testing.T) { func TestFilteredCommandsCaseInsensitive(t *testing.T) { m := testModelWithLines() - m.cmdPaletteFilter = "QUIT" + m.cmdPaletteInput.Text = "QUIT" filtered := m.filteredCommands() if len(filtered) != 1 { t.Errorf("expected 1 command matching 'QUIT', got %d", len(filtered)) diff --git a/internal/ui/keys.go b/internal/ui/keys.go index ff202cb..5701ea8 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -45,8 +45,7 @@ func (m *model) handleCmdPaletteMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.cmdPaletteMode = false - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 + m.cmdPaletteInput.clear() m.cmdPaletteSelected = 0 return m, nil case tea.KeyEnter: @@ -54,8 +53,7 @@ func (m *model) handleCmdPaletteMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(filtered) > 0 && m.cmdPaletteSelected < len(filtered) { m.cmdPaletteMode = false cmd := filtered[m.cmdPaletteSelected] - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 + m.cmdPaletteInput.clear() m.cmdPaletteSelected = 0 return cmd.action(m) } @@ -71,31 +69,8 @@ func (m *model) handleCmdPaletteMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cmdPaletteSelected++ } return m, nil - case tea.KeyLeft: - if msg.Alt { - m.cmdPaletteCursor = wordBoundaryLeft(m.cmdPaletteFilter, m.cmdPaletteCursor) - } else if m.cmdPaletteCursor > 0 { - m.cmdPaletteCursor-- - } - return m, nil - case tea.KeyRight: - if msg.Alt { - m.cmdPaletteCursor = wordBoundaryRight(m.cmdPaletteFilter, m.cmdPaletteCursor) - } else if m.cmdPaletteCursor < len(m.cmdPaletteFilter) { - m.cmdPaletteCursor++ - } - return m, nil - case tea.KeyBackspace: - if msg.Alt { - m.cmdPaletteFilter, m.cmdPaletteCursor = textBackspaceWord(m.cmdPaletteFilter, m.cmdPaletteCursor) - } else { - m.cmdPaletteFilter, m.cmdPaletteCursor = textBackspace(m.cmdPaletteFilter, m.cmdPaletteCursor) - } - m.cmdPaletteSelected = 0 - return m, nil default: - if len(msg.Runes) > 0 { - m.cmdPaletteFilter, m.cmdPaletteCursor = textInsert(m.cmdPaletteFilter, string(msg.Runes), m.cmdPaletteCursor) + if m.cmdPaletteInput.handleKey(msg) { m.cmdPaletteSelected = 0 } return m, nil @@ -106,8 +81,7 @@ func (m *model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.filterMode = false - m.filter = "" - m.filterCursor = 0 + m.filterInput.clear() m.filterRegex = false m.filterRegexErr = nil m.updateFiltered() @@ -115,39 +89,16 @@ func (m *model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: m.filterMode = false return m, nil - case tea.KeyLeft: - if msg.Alt { - m.filterCursor = wordBoundaryLeft(m.filter, m.filterCursor) - } else if m.filterCursor > 0 { - m.filterCursor-- - } - return m, nil - case tea.KeyRight: - if msg.Alt { - m.filterCursor = wordBoundaryRight(m.filter, m.filterCursor) - } else if m.filterCursor < len(m.filter) { - m.filterCursor++ - } - return m, nil - case tea.KeyBackspace: - if msg.Alt { - m.filter, m.filterCursor = textBackspaceWord(m.filter, m.filterCursor) - } else { - m.filter, m.filterCursor = textBackspace(m.filter, m.filterCursor) - } - m.updateFiltered() - return m, nil default: - if len(msg.Runes) > 0 { - s := string(msg.Runes) - if s == "/" && m.filter == "" { - m.filterRegex = !m.filterRegex - m.filterRegexErr = nil - } else { - m.filter, m.filterCursor = textInsert(m.filter, s, m.filterCursor) - } + // Special case: "/" on empty filter toggles regex mode + if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 && string(msg.Runes) == "/" && m.filterInput.Text == "" { + m.filterRegex = !m.filterRegex + m.filterRegexErr = nil m.updateFiltered() + return m, nil } + m.filterInput.handleKey(msg) + m.updateFiltered() return m, nil } } @@ -157,9 +108,8 @@ func (m *model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q", "ctrl+c": return m.actionQuit() case "esc": - if m.filter != "" || m.filterRegex { - m.filter = "" - m.filterCursor = 0 + if m.filterInput.Text != "" || m.filterRegex { + m.filterInput.clear() m.filterRegex = false m.filterRegexErr = nil m.updateFiltered() diff --git a/internal/ui/keys_test.go b/internal/ui/keys_test.go index f63cb3b..6128b20 100644 --- a/internal/ui/keys_test.go +++ b/internal/ui/keys_test.go @@ -12,47 +12,47 @@ func TestFilterCursorMovement(t *testing.T) { cfg := Config{Command: "echo test", Shell: "sh"} m := testModel(cfg) m.filterMode = true - m.filter = "hello" - m.filterCursor = 5 + m.filterInput.Text = "hello" + m.filterInput.Cursor = 5 // Left arrow moves cursor left keyMsg := tea.KeyMsg{Type: tea.KeyLeft} result, _ := m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4 after left, got %d", m.filterCursor) + if m.filterInput.Cursor != 4 { + t.Errorf("expected filterCursor 4 after left, got %d", m.filterInput.Cursor) } // Left again result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 3 { - t.Errorf("expected filterCursor 3 after second left, got %d", m.filterCursor) + if m.filterInput.Cursor != 3 { + t.Errorf("expected filterCursor 3 after second left, got %d", m.filterInput.Cursor) } // Left doesn't go below 0 - m.filterCursor = 0 + m.filterInput.Cursor = 0 result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor) + if m.filterInput.Cursor != 0 { + t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterInput.Cursor) } // Right arrow moves cursor right - m.filterCursor = 2 + m.filterInput.Cursor = 2 keyMsg = tea.KeyMsg{Type: tea.KeyRight} result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 3 { - t.Errorf("expected filterCursor 3 after right, got %d", m.filterCursor) + if m.filterInput.Cursor != 3 { + t.Errorf("expected filterCursor 3 after right, got %d", m.filterInput.Cursor) } // Right doesn't go past end - m.filterCursor = 5 + m.filterInput.Cursor = 5 result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 5 { - t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterCursor) + if m.filterInput.Cursor != 5 { + t.Errorf("expected filterCursor 5 (clamped), got %d", m.filterInput.Cursor) } } @@ -62,94 +62,94 @@ func TestFilterAltLeftRight(t *testing.T) { t.Run("alt+left jumps to previous word boundary", func(t *testing.T) { m := testModel(cfg) m.filterMode = true - m.filter = "foo bar baz" - m.filterCursor = 11 // end + m.filterInput.Text = "foo bar baz" + m.filterInput.Cursor = 11 // end keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true} result, _ := m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 8 { - t.Errorf("expected filterCursor 8, got %d", m.filterCursor) + if m.filterInput.Cursor != 8 { + t.Errorf("expected filterCursor 8, got %d", m.filterInput.Cursor) } result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4, got %d", m.filterCursor) + if m.filterInput.Cursor != 4 { + t.Errorf("expected filterCursor 4, got %d", m.filterInput.Cursor) } result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + if m.filterInput.Cursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor) } // Already at start, stays at 0 result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterCursor) + if m.filterInput.Cursor != 0 { + t.Errorf("expected filterCursor 0 (clamped), got %d", m.filterInput.Cursor) } }) t.Run("alt+right jumps to next word boundary", func(t *testing.T) { m := testModel(cfg) m.filterMode = true - m.filter = "foo bar baz" - m.filterCursor = 0 + m.filterInput.Text = "foo bar baz" + m.filterInput.Cursor = 0 keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true} result, _ := m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4, got %d", m.filterCursor) + if m.filterInput.Cursor != 4 { + t.Errorf("expected filterCursor 4, got %d", m.filterInput.Cursor) } result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 8 { - t.Errorf("expected filterCursor 8, got %d", m.filterCursor) + if m.filterInput.Cursor != 8 { + t.Errorf("expected filterCursor 8, got %d", m.filterInput.Cursor) } result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 11 { - t.Errorf("expected filterCursor 11, got %d", m.filterCursor) + if m.filterInput.Cursor != 11 { + t.Errorf("expected filterCursor 11, got %d", m.filterInput.Cursor) } // Already at end, stays at 11 result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 11 { - t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterCursor) + if m.filterInput.Cursor != 11 { + t.Errorf("expected filterCursor 11 (clamped), got %d", m.filterInput.Cursor) } }) t.Run("alt+left skips trailing spaces", func(t *testing.T) { m := testModel(cfg) m.filterMode = true - m.filter = "foo bar" - m.filterCursor = 6 // middle of spaces, before "bar" + m.filterInput.Text = "foo bar" + m.filterInput.Cursor = 6 // middle of spaces, before "bar" keyMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: true} result, _ := m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + if m.filterInput.Cursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor) } }) t.Run("alt+right skips trailing spaces", func(t *testing.T) { m := testModel(cfg) m.filterMode = true - m.filter = "foo bar" - m.filterCursor = 3 // end of "foo" + m.filterInput.Text = "foo bar" + m.filterInput.Cursor = 3 // end of "foo" keyMsg := tea.KeyMsg{Type: tea.KeyRight, Alt: true} result, _ := m.handleKeyPress(keyMsg) m = result.(*model) - if m.filterCursor != 6 { - t.Errorf("expected filterCursor 6, got %d", m.filterCursor) + if m.filterInput.Cursor != 6 { + t.Errorf("expected filterCursor 6, got %d", m.filterInput.Cursor) } }) } @@ -158,18 +158,18 @@ func TestFilterInsertAtCursor(t *testing.T) { cfg := Config{Command: "echo test", Shell: "sh"} m := testModel(cfg) m.filterMode = true - m.filter = "helo" - m.filterCursor = 3 + m.filterInput.Text = "helo" + m.filterInput.Cursor = 3 // Insert 'l' at position 3 -> "hello" keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}} result, _ := m.handleKeyPress(keyMsg) m = result.(*model) - if m.filter != "hello" { - t.Errorf("expected filter 'hello', got %q", m.filter) + if m.filterInput.Text != "hello" { + t.Errorf("expected filter 'hello', got %q", m.filterInput.Text) } - if m.filterCursor != 4 { - t.Errorf("expected filterCursor 4, got %d", m.filterCursor) + if m.filterInput.Cursor != 4 { + t.Errorf("expected filterCursor 4, got %d", m.filterInput.Cursor) } } @@ -177,29 +177,29 @@ func TestFilterBackspaceAtCursor(t *testing.T) { cfg := Config{Command: "echo test", Shell: "sh"} m := testModel(cfg) m.filterMode = true - m.filter = "hello" - m.filterCursor = 3 + m.filterInput.Text = "hello" + m.filterInput.Cursor = 3 // Backspace at position 3 -> "helo" keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} result, _ := m.handleKeyPress(keyMsg) m = result.(*model) - if m.filter != "helo" { - t.Errorf("expected filter 'helo', got %q", m.filter) + if m.filterInput.Text != "helo" { + t.Errorf("expected filter 'helo', got %q", m.filterInput.Text) } - if m.filterCursor != 2 { - t.Errorf("expected filterCursor 2, got %d", m.filterCursor) + if m.filterInput.Cursor != 2 { + t.Errorf("expected filterCursor 2, got %d", m.filterInput.Cursor) } // Backspace at position 0 does nothing - m.filterCursor = 0 + m.filterInput.Cursor = 0 result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filter != "helo" { - t.Errorf("expected filter 'helo' (unchanged), got %q", m.filter) + if m.filterInput.Text != "helo" { + t.Errorf("expected filter 'helo' (unchanged), got %q", m.filterInput.Text) } - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + if m.filterInput.Cursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor) } } @@ -225,18 +225,18 @@ func TestFilterAltBackspace(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := testModel(cfg) m.filterMode = true - m.filter = tt.filter - m.filterCursor = tt.cursor + m.filterInput.Text = tt.filter + m.filterInput.Cursor = tt.cursor keyMsg := tea.KeyMsg{Type: tea.KeyBackspace, Alt: true} result, _ := m.handleKeyPress(keyMsg) newModel := result.(*model) - if newModel.filter != tt.expectedFilter { - t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filter) + if newModel.filterInput.Text != tt.expectedFilter { + t.Errorf("expected filter %q, got %q", tt.expectedFilter, newModel.filterInput.Text) } - if newModel.filterCursor != tt.expectedCursor { - t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterCursor) + if newModel.filterInput.Cursor != tt.expectedCursor { + t.Errorf("expected filterCursor %d, got %d", tt.expectedCursor, newModel.filterInput.Cursor) } }) } @@ -246,8 +246,8 @@ func TestFilterRegexToggle(t *testing.T) { cfg := Config{Command: "echo test", Shell: "sh"} m := testModel(cfg) m.filterMode = true - m.filter = "" - m.filterCursor = 0 + m.filterInput.Text = "" + m.filterInput.Cursor = 0 // Type '/' on empty filter toggles regex mode on keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} @@ -256,8 +256,8 @@ func TestFilterRegexToggle(t *testing.T) { if !m.filterRegex { t.Error("expected filterRegex to be true after typing /") } - if m.filter != "" { - t.Errorf("expected empty filter, got %q", m.filter) + if m.filterInput.Text != "" { + t.Errorf("expected empty filter, got %q", m.filterInput.Text) } // Type '/' again on empty filter toggles regex mode off @@ -269,12 +269,12 @@ func TestFilterRegexToggle(t *testing.T) { // Type '/' when filter is non-empty adds it to filter m.filterRegex = true - m.filter = "abc" - m.filterCursor = 3 + m.filterInput.Text = "abc" + m.filterInput.Cursor = 3 result, _ = m.handleKeyPress(keyMsg) m = result.(*model) - if m.filter != "abc/" { - t.Errorf("expected filter 'abc/', got %q", m.filter) + if m.filterInput.Text != "abc/" { + t.Errorf("expected filter 'abc/', got %q", m.filterInput.Text) } if !m.filterRegex { t.Error("expected filterRegex to remain true") @@ -285,8 +285,8 @@ func TestFilterEscClearsRegex(t *testing.T) { cfg := Config{Command: "echo test", Shell: "sh"} m := testModel(cfg) m.filterMode = true - m.filter = "test" - m.filterCursor = 4 + m.filterInput.Text = "test" + m.filterInput.Cursor = 4 m.filterRegex = true // Esc in filter mode clears everything @@ -297,11 +297,11 @@ func TestFilterEscClearsRegex(t *testing.T) { if m.filterMode { t.Error("expected filterMode to be false") } - if m.filter != "" { - t.Errorf("expected empty filter, got %q", m.filter) + if m.filterInput.Text != "" { + t.Errorf("expected empty filter, got %q", m.filterInput.Text) } - if m.filterCursor != 0 { - t.Errorf("expected filterCursor 0, got %d", m.filterCursor) + if m.filterInput.Cursor != 0 { + t.Errorf("expected filterCursor 0, got %d", m.filterInput.Cursor) } if m.filterRegex { t.Error("expected filterRegex to be false") @@ -661,8 +661,8 @@ func TestKeyCmdPaletteOpenAndNav(t *testing.T) { keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} result, _ = newModel.handleKeyPress(keyMsg) newModel = result.(*model) - if newModel.cmdPaletteFilter != "r" { - t.Errorf("expected cmdPaletteFilter 'r', got %q", newModel.cmdPaletteFilter) + if newModel.cmdPaletteInput.Text != "r" { + t.Errorf("expected cmdPaletteFilter 'r', got %q", newModel.cmdPaletteInput.Text) } // Esc closes palette diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 4042519..51c2655 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -117,8 +117,8 @@ func (m *model) updateFiltered() { m.filtered = []int{} m.filterRegexErr = nil - if m.filterRegex && m.filter != "" { - re, err := regexp.Compile("(?i)" + m.filter) + if m.filterRegex && m.filterInput.Text != "" { + re, err := regexp.Compile("(?i)" + m.filterInput.Text) if err != nil { m.filterRegexErr = err // Show all lines when regex is invalid @@ -133,9 +133,9 @@ func (m *model) updateFiltered() { } } } else { - filter := strings.ToLower(m.filter) + filter := strings.ToLower(m.filterInput.Text) for i, line := range m.lines { - if m.filter == "" || strings.Contains(strings.ToLower(line.Content), filter) { + if m.filterInput.Text == "" || strings.Contains(strings.ToLower(line.Content), filter) { m.filtered = append(m.filtered, i) } } diff --git a/internal/ui/layout_test.go b/internal/ui/layout_test.go index 3f463f6..69dd56f 100644 --- a/internal/ui/layout_test.go +++ b/internal/ui/layout_test.go @@ -23,7 +23,7 @@ func TestModelUpdateFiltered(t *testing.T) { } // Test with no filter - m.filter = "" + m.filterInput.Text = "" m.updateFiltered() if len(m.filtered) != 4 { @@ -31,7 +31,7 @@ func TestModelUpdateFiltered(t *testing.T) { } // Test with filter - m.filter = "hello" + m.filterInput.Text = "hello" m.updateFiltered() if len(m.filtered) != 2 { @@ -39,7 +39,7 @@ func TestModelUpdateFiltered(t *testing.T) { } // Test case insensitive - m.filter = "HELLO" + m.filterInput.Text = "HELLO" m.updateFiltered() if len(m.filtered) != 2 { @@ -47,7 +47,7 @@ func TestModelUpdateFiltered(t *testing.T) { } // Test no matches - m.filter = "xyz" + m.filterInput.Text = "xyz" m.updateFiltered() if len(m.filtered) != 0 { @@ -155,7 +155,7 @@ func TestUpdateFilteredPreservesOffset(t *testing.T) { } // Set initial state with offset - m.filter = "" + m.filterInput.Text = "" m.updateFiltered() m.offset = 50 m.cursor = 55 @@ -189,13 +189,13 @@ func TestUpdateFilteredClampsOffsetWhenNeeded(t *testing.T) { m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"}) } - m.filter = "" + m.filterInput.Text = "" m.updateFiltered() m.offset = 90 m.cursor = 95 // Now filter to fewer lines - m.filter = "xyz" // No matches + m.filterInput.Text = "xyz" // No matches m.updateFiltered() // Offset should be clamped to valid range @@ -221,7 +221,7 @@ func TestFilterRegexMatching(t *testing.T) { // Regex filter matching m.filterRegex = true - m.filter = "hello.*foo" + m.filterInput.Text = "hello.*foo" m.updateFiltered() if len(m.filtered) != 1 { t.Errorf("expected 1 match for regex 'hello.*foo', got %d", len(m.filtered)) @@ -231,14 +231,14 @@ func TestFilterRegexMatching(t *testing.T) { } // Regex with character class - m.filter = "\\d+" + m.filterInput.Text = "\\d+" m.updateFiltered() if len(m.filtered) != 1 { t.Errorf("expected 1 match for regex '\\d+', got %d", len(m.filtered)) } // Regex is case insensitive - m.filter = "HELLO" + m.filterInput.Text = "HELLO" m.updateFiltered() if len(m.filtered) != 2 { t.Errorf("expected 2 matches for case-insensitive regex 'HELLO', got %d", len(m.filtered)) @@ -254,7 +254,7 @@ func TestFilterRegexInvalid(t *testing.T) { } m.filterRegex = true - m.filter = "[invalid" + m.filterInput.Text = "[invalid" m.updateFiltered() // Should have an error @@ -268,7 +268,7 @@ func TestFilterRegexInvalid(t *testing.T) { } // Valid regex clears the error - m.filter = "hello" + m.filterInput.Text = "hello" m.updateFiltered() if m.filterRegexErr != nil { t.Errorf("expected filterRegexErr to be nil for valid regex, got %v", m.filterRegexErr) diff --git a/internal/ui/model.go b/internal/ui/model.go index fa36f06..8f5799e 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -37,11 +37,10 @@ type Config struct { type model struct { config Config lines []runner.Line - filtered []int // indices into lines that match filter - cursor int // cursor position in filtered list - offset int // scroll offset for visible window - filter string - filterCursor int // cursor position within filter string + filtered []int // indices into lines that match filter + cursor int // cursor position in filtered list + offset int // scroll offset for visible window + filterInput textInput // filter text and cursor filterMode bool filterRegex bool // true when filter is in regex mode filterRegexErr error // non-nil when regex pattern is invalid @@ -65,10 +64,9 @@ type model struct { statusMsg string // temporary status message (e.g., "Yanked!") exitCode int // last command exit code - cmdPaletteMode bool // whether command palette is open - cmdPaletteFilter string // current filter text - cmdPaletteCursor int // cursor position within filter string - cmdPaletteSelected int // selected item index in filtered list + cmdPaletteMode bool // whether command palette is open + cmdPaletteInput textInput // palette filter text and cursor + cmdPaletteSelected int // selected item index in filtered list confirmMode bool // whether a confirmation dialog is visible confirmMessage string // message to display in confirmation dialog diff --git a/internal/ui/text.go b/internal/ui/text.go index 8c1dd70..a4d112c 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -3,6 +3,7 @@ package ui import ( "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -242,48 +243,143 @@ func skipVisualWidth(s string, skipWidth int) string { return result.String() } -// wordBoundaryLeft returns the cursor position after jumping left to the previous word boundary. -func wordBoundaryLeft(s string, pos int) int { - for pos > 0 && s[pos-1] == ' ' { +// textInput is a reusable single-line text editor with cursor, word navigation, +// and block-cursor rendering. +type textInput struct { + Text string + Cursor int +} + +func (ti *textInput) clear() { + ti.Text = "" + ti.Cursor = 0 +} + +// handleKey processes a key message for text editing (left/right, backspace, +// character insert). Returns true if the key was handled. +func (ti *textInput) handleKey(msg tea.KeyMsg) bool { + switch msg.Type { + case tea.KeyLeft: + if msg.Alt { + ti.wordLeft() + } else if ti.Cursor > 0 { + ti.Cursor-- + } + case tea.KeyRight: + if msg.Alt { + ti.wordRight() + } else if ti.Cursor < len(ti.Text) { + ti.Cursor++ + } + case tea.KeyBackspace: + if msg.Alt { + ti.backspaceWord() + } else { + ti.backspace() + } + case tea.KeyCtrlW: + // Ctrl+W deletes word (also sent by some terminals for Alt+Backspace) + ti.backspaceWord() + case tea.KeyDelete: + if msg.Alt { + ti.deleteWord() + } else { + ti.delete() + } + case tea.KeyHome, tea.KeyCtrlA: + ti.Cursor = 0 + case tea.KeyEnd, tea.KeyCtrlE: + ti.Cursor = len(ti.Text) + default: + if len(msg.Runes) > 0 { + ti.insert(string(msg.Runes)) + } else { + return false + } + } + return true +} + +func (ti *textInput) delete() { + if ti.Cursor < len(ti.Text) { + ti.Text = ti.Text[:ti.Cursor] + ti.Text[ti.Cursor+1:] + } +} + +func (ti *textInput) deleteWord() { + if ti.Cursor >= len(ti.Text) { + return + } + pos := ti.Cursor + for pos < len(ti.Text) && ti.Text[pos] == ' ' { + pos++ + } + for pos < len(ti.Text) && ti.Text[pos] != ' ' { + pos++ + } + ti.Text = ti.Text[:ti.Cursor] + ti.Text[pos:] +} + +func (ti *textInput) insert(s string) { + ti.Text = ti.Text[:ti.Cursor] + s + ti.Text[ti.Cursor:] + ti.Cursor += len(s) +} + +func (ti *textInput) backspace() { + if ti.Cursor > 0 { + ti.Text = ti.Text[:ti.Cursor-1] + ti.Text[ti.Cursor:] + ti.Cursor-- + } +} + +func (ti *textInput) backspaceWord() { + if ti.Cursor <= 0 { + return + } + newPos := ti.wordBoundaryLeft() + ti.Text = ti.Text[:newPos] + ti.Text[ti.Cursor:] + ti.Cursor = newPos +} + +func (ti *textInput) wordLeft() { + ti.Cursor = ti.wordBoundaryLeft() +} + +func (ti *textInput) wordRight() { + pos := ti.Cursor + for pos < len(ti.Text) && ti.Text[pos] != ' ' { + pos++ + } + for pos < len(ti.Text) && ti.Text[pos] == ' ' { + pos++ + } + ti.Cursor = pos +} + +func (ti *textInput) wordBoundaryLeft() int { + pos := ti.Cursor + for pos > 0 && ti.Text[pos-1] == ' ' { pos-- } - for pos > 0 && s[pos-1] != ' ' { + for pos > 0 && ti.Text[pos-1] != ' ' { pos-- } return pos } -// wordBoundaryRight returns the cursor position after jumping right to the next word boundary. -func wordBoundaryRight(s string, pos int) int { - for pos < len(s) && s[pos] != ' ' { - pos++ +// render returns (before, cursor, after) where cursor is the character at the +// cursor position styled with an inverted background so it remains visible. +// At end of text, a space with inverted background is used. +func (ti *textInput) render() (before, cursor, after string) { + cursorStyle := lipgloss.NewStyle().Reverse(true) + before = ti.Text[:ti.Cursor] + if ti.Cursor < len(ti.Text) { + cursor = cursorStyle.Render(string(ti.Text[ti.Cursor])) + after = ti.Text[ti.Cursor+1:] + } else { + cursor = cursorStyle.Render(" ") } - for pos < len(s) && s[pos] == ' ' { - pos++ - } - return pos -} - -// textInsert inserts a string at the cursor position, returning new text and cursor. -func textInsert(text, insert string, cursor int) (string, int) { - return text[:cursor] + insert + text[cursor:], cursor + len(insert) -} - -// textBackspace deletes one character before the cursor, returning new text and cursor. -func textBackspace(text string, cursor int) (string, int) { - if cursor <= 0 { - return text, cursor - } - return text[:cursor-1] + text[cursor:], cursor - 1 -} - -// textBackspaceWord deletes the word before the cursor, returning new text and cursor. -func textBackspaceWord(text string, cursor int) (string, int) { - if cursor <= 0 { - return text, cursor - } - newPos := wordBoundaryLeft(text, cursor) - return text[:newPos] + text[cursor:], newPos + return before, cursor, after } func isAnsiTerminator(r rune) bool { diff --git a/internal/ui/text_test.go b/internal/ui/text_test.go index a38789a..da7c0f1 100644 --- a/internal/ui/text_test.go +++ b/internal/ui/text_test.go @@ -103,7 +103,7 @@ func TestIsAnsiTerminator(t *testing.T) { } } -func TestWordBoundaryLeft(t *testing.T) { +func TestTextInputWordLeft(t *testing.T) { tests := []struct { name string s string @@ -118,15 +118,16 @@ func TestWordBoundaryLeft(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := wordBoundaryLeft(tt.s, tt.pos) - if got != tt.want { - t.Errorf("wordBoundaryLeft(%q, %d) = %d, want %d", tt.s, tt.pos, got, tt.want) + ti := textInput{Text: tt.s, Cursor: tt.pos} + ti.wordLeft() + if ti.Cursor != tt.want { + t.Errorf("wordLeft(%q, %d) = %d, want %d", tt.s, tt.pos, ti.Cursor, tt.want) } }) } } -func TestWordBoundaryRight(t *testing.T) { +func TestTextInputWordRight(t *testing.T) { tests := []struct { name string s string @@ -141,44 +142,88 @@ func TestWordBoundaryRight(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := wordBoundaryRight(tt.s, tt.pos) - if got != tt.want { - t.Errorf("wordBoundaryRight(%q, %d) = %d, want %d", tt.s, tt.pos, got, tt.want) + ti := textInput{Text: tt.s, Cursor: tt.pos} + ti.wordRight() + if ti.Cursor != tt.want { + t.Errorf("wordRight(%q, %d) = %d, want %d", tt.s, tt.pos, ti.Cursor, tt.want) } }) } } -func TestTextInsert(t *testing.T) { - text, cursor := textInsert("helo", "l", 3) - if text != "hello" || cursor != 4 { - t.Errorf("got %q cursor %d, want 'hello' cursor 4", text, cursor) +func TestTextInputInsert(t *testing.T) { + ti := textInput{Text: "helo", Cursor: 3} + ti.insert("l") + if ti.Text != "hello" || ti.Cursor != 4 { + t.Errorf("got %q cursor %d, want 'hello' cursor 4", ti.Text, ti.Cursor) } } -func TestTextBackspace(t *testing.T) { - text, cursor := textBackspace("hello", 3) - if text != "helo" || cursor != 2 { - t.Errorf("got %q cursor %d, want 'helo' cursor 2", text, cursor) +func TestTextInputBackspace(t *testing.T) { + ti := textInput{Text: "hello", Cursor: 3} + ti.backspace() + if ti.Text != "helo" || ti.Cursor != 2 { + t.Errorf("got %q cursor %d, want 'helo' cursor 2", ti.Text, ti.Cursor) } // At start, no change - text, cursor = textBackspace("hello", 0) - if text != "hello" || cursor != 0 { - t.Errorf("got %q cursor %d, want 'hello' cursor 0", text, cursor) + ti = textInput{Text: "hello", Cursor: 0} + ti.backspace() + if ti.Text != "hello" || ti.Cursor != 0 { + t.Errorf("got %q cursor %d, want 'hello' cursor 0", ti.Text, ti.Cursor) } } -func TestTextBackspaceWord(t *testing.T) { - text, cursor := textBackspaceWord("hello world", 11) - if text != "hello " || cursor != 6 { - t.Errorf("got %q cursor %d, want 'hello ' cursor 6", text, cursor) +func TestTextInputBackspaceWord(t *testing.T) { + ti := textInput{Text: "hello world", Cursor: 11} + ti.backspaceWord() + if ti.Text != "hello " || ti.Cursor != 6 { + t.Errorf("got %q cursor %d, want 'hello ' cursor 6", ti.Text, ti.Cursor) } // At start, no change - text, cursor = textBackspaceWord("hello", 0) - if text != "hello" || cursor != 0 { - t.Errorf("got %q cursor %d, want 'hello' cursor 0", text, cursor) + ti = textInput{Text: "hello", Cursor: 0} + ti.backspaceWord() + if ti.Text != "hello" || ti.Cursor != 0 { + t.Errorf("got %q cursor %d, want 'hello' cursor 0", ti.Text, ti.Cursor) + } +} + +func TestTextInputRender(t *testing.T) { + // Cursor in middle — character visible with inverted style + ti := textInput{Text: "test", Cursor: 2} + before, cursor, after := ti.render() + if before != "te" { + t.Errorf("before = %q, want 'te'", before) + } + if after != "t" { + t.Errorf("after = %q, want 't'", after) + } + // Cursor should contain the character 's' (with ANSI styling) + if !strings.Contains(cursor, "s") { + t.Errorf("cursor should contain 's', got %q", cursor) + } + + // Cursor at end — styled space + ti = textInput{Text: "test", Cursor: 4} + before, cursor, after = ti.render() + if before != "test" { + t.Errorf("before = %q, want 'test'", before) + } + if after != "" { + t.Errorf("after = %q, want ''", after) + } + // Cursor should contain a space (with ANSI styling) + if !strings.Contains(cursor, " ") { + t.Errorf("cursor should contain space, got %q", cursor) + } +} + +func TestTextInputClear(t *testing.T) { + ti := textInput{Text: "hello", Cursor: 3} + ti.clear() + if ti.Text != "" || ti.Cursor != 0 { + t.Errorf("after clear: text=%q cursor=%d", ti.Text, ti.Cursor) } } diff --git a/internal/ui/update.go b/internal/ui/update.go index 601eb49..1a9b48a 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -24,7 +24,6 @@ func initialModel(cfg Config) model { filtered: []int{}, cursor: 0, offset: 0, - filter: "", filterMode: false, showPreview: false, runner: r, diff --git a/internal/ui/view.go b/internal/ui/view.go index 455d24d..746ad04 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -41,9 +41,8 @@ func (m model) renderCmdPaletteOverlay() (box string, boxWidth, boxHeight int) { var content strings.Builder // Filter input with bottom border - before := m.cmdPaletteFilter[:m.cmdPaletteCursor] - after := m.cmdPaletteFilter[m.cmdPaletteCursor:] - filterLine := filterStyle.Render(":"+before) + "█" + filterStyle.Render(after) + before, block, after := m.cmdPaletteInput.render() + filterLine := filterStyle.Render(":"+before) + block + filterStyle.Render(after) // Pad filter line to full width filterVisual := lipgloss.Width(filterLine) if filterVisual < paletteWidth { @@ -339,21 +338,19 @@ func (m model) renderPromptLine() string { switch { case m.filterMode && m.filterRegex: label := filterRegexStyle.Render("regex/") - before := m.filter[:m.filterCursor] - after := m.filter[m.filterCursor:] - input := filterStyle.Render(before) + "█" + filterStyle.Render(after) + before, block, after := m.filterInput.render() + input := filterStyle.Render(before) + block + filterStyle.Render(after) promptLine = label + input if m.filterRegexErr != nil { promptLine += " " + filterErrStyle.Render("(invalid regex)") } case m.filterMode: - before := m.filter[:m.filterCursor] - after := m.filter[m.filterCursor:] - promptLine = filterStyle.Render("/"+before) + "█" + filterStyle.Render(after) - case m.filter != "" && m.filterRegex: - promptLine = promptStyle.Render(fmt.Sprintf("%s (regex: %s)", m.config.Prompt, m.filter)) - case m.filter != "": - promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filter)) + before, block, after := m.filterInput.render() + promptLine = filterStyle.Render("/"+before) + block + filterStyle.Render(after) + case m.filterInput.Text != "" && m.filterRegex: + promptLine = promptStyle.Render(fmt.Sprintf("%s (regex: %s)", m.config.Prompt, m.filterInput.Text)) + case m.filterInput.Text != "": + promptLine = promptStyle.Render(fmt.Sprintf("%s (filter: %s)", m.config.Prompt, m.filterInput.Text)) default: promptLine = promptStyle.Render(m.config.Prompt) } diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index 398d3d0..93910ea 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -40,8 +40,8 @@ func TestRenderConfirmOverlay(t *testing.T) { func TestRenderCmdPaletteOverlay(t *testing.T) { m := testModelWithLines() m.cmdPaletteMode = true - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 + m.cmdPaletteInput.Text = "" + m.cmdPaletteInput.Cursor = 0 m.cmdPaletteSelected = 0 box, boxWidth, boxHeight := m.renderCmdPaletteOverlay() @@ -94,8 +94,8 @@ func TestViewWithConfirmOverlay(t *testing.T) { func TestViewWithCmdPalette(t *testing.T) { m := testModelWithLines() m.cmdPaletteMode = true - m.cmdPaletteFilter = "" - m.cmdPaletteCursor = 0 + m.cmdPaletteInput.Text = "" + m.cmdPaletteInput.Cursor = 0 view := m.View() if !strings.Contains(view, "Reload command") {