fix: unify all text input behavior, fix cursor navigations

fix: text cursor visual
fix: text delete word
fix: home/end navigation
This commit is contained in:
2026-03-25 01:43:37 +02:00
parent 6b3abbb9d3
commit 351aa0331b
13 changed files with 341 additions and 257 deletions

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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))

View File

@@ -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()

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -24,7 +24,6 @@ func initialModel(cfg Config) model {
filtered: []int{},
cursor: 0,
offset: 0,
filter: "",
filterMode: false,
showPreview: false,
runner: r,

View File

@@ -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)
}

View File

@@ -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") {