mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-17 17:28:06 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ func initialModel(cfg Config) model {
|
||||
filtered: []int{},
|
||||
cursor: 0,
|
||||
offset: 0,
|
||||
filter: "",
|
||||
filterMode: false,
|
||||
showPreview: false,
|
||||
runner: r,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user