feat: implement the r action, tested
All checks were successful
Run Test Suite / test (push) Successful in 17s

Began work on replace mode, but not complete.
This commit is contained in:
Hayden Hargreaves 2026-04-05 22:58:07 -07:00
parent 58082afdd2
commit 6033e58d0e
9 changed files with 696 additions and 9 deletions

View File

@ -0,0 +1,72 @@
package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
type ReplaceChar struct {
Char string
Count int
}
func (m ReplaceChar) WithChar(char string) Motion {
m.Char = char
return m
}
func (m ReplaceChar) Type() core.MotionType {
return core.CharwiseInclusive
}
// WithCount sets the count (required by Repeatable interface)
func (m ReplaceChar) WithCount(n int) Action {
m.Count = n
return m
}
func (a ReplaceChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack != nil && !buf.UndoStack.Recording() {
buf.UndoStack.BeginBlock(win.Cursor)
}
pos := win.Cursor.Col
line := buf.Line(win.Cursor.Line)
for i := 0; i < a.Count && pos < len(line); i++ {
line = line[:pos] + a.Char + line[pos+1:]
buf.SetLine(win.Cursor.Line, line)
pos++
}
win.SetCursorCol(pos - 1)
m.SetMode(core.NormalMode)
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
return nil
}
type EnterReplace struct {
Count int
}
func (a EnterReplace) WithCount(n int) Action {
a.Count = n
return a
}
func (a EnterReplace) Execute(m Model) tea.Cmd {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{"Replace mode (R) not implemented yet"},
Inline: true,
IsError: true,
})
return nil
}

View File

@ -11,6 +11,7 @@ const (
VisualMode VisualMode
VisualLineMode VisualLineMode
VisualBlockMode VisualBlockMode
ReplaceMode
) )
// Mode.ToString: Returns a human-readable string representation of the mode // Mode.ToString: Returns a human-readable string representation of the mode
@ -29,6 +30,8 @@ func (m Mode) ToString() string {
return "V-LINE" return "V-LINE"
case VisualBlockMode: case VisualBlockMode:
return "V-BLOCK" return "V-BLOCK"
case ReplaceMode:
return "REPLACE"
default: default:
return "-----" return "-----"
} }

View File

@ -176,7 +176,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0]) - 1
if m.ActiveWindow().Cursor.Col != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
} }
@ -188,7 +188,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0]) - 1
if m.ActiveWindow().Cursor.Col != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
} }
@ -200,7 +200,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0]) - 1
if m.ActiveWindow().Cursor.Col != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
} }

View File

@ -0,0 +1,566 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
func TestReplaceChar(t *testing.T) {
t.Run("test 'rx' replaces character under cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xello" {
t.Errorf("lines[0] = %q, want 'xello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' in middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hexlo" {
t.Errorf("lines[0] = %q, want 'hexlo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' at end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hellx" {
t.Errorf("lines[0] = %q, want 'hellx'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' cursor position remains at replaced char", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'r' stays in normal mode", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
})
t.Run("test 'r' with space replaces with space", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", " ")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " ello" {
t.Errorf("lines[0] = %q, want ' ello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' with digit replaces with digit", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "5")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "5ello" {
t.Errorf("lines[0] = %q, want '5ello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' with special char replaces correctly", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "@")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "@ello" {
t.Errorf("lines[0] = %q, want '@ello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple 'r' operations in sequence", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x", "l", "r", "y")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xyllo" {
t.Errorf("lines[0] = %q, want 'xyllo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceCharWithCount(t *testing.T) {
t.Run("test '3rx' replaces three characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "3", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xxxlo" {
t.Errorf("lines[0] = %q, want 'xxxlo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '3rx' cursor position after replace", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "3", "r", "x")
m := getFinalModel(t, tm)
// Cursor should be at last replaced character
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test '5rx' replaces all five characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "5", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xxxxx" {
t.Errorf("lines[0] = %q, want 'xxxxx'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '10rx' with overflow stops at line end", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "1", "0", "r", "x")
m := getFinalModel(t, tm)
// Should only replace 5 chars (all available)
if m.ActiveBuffer().Lines[0].String() != "xxxxx" {
t.Errorf("lines[0] = %q, want 'xxxxx'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '2rx' from middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "2", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hxxlo" {
t.Errorf("lines[0] = %q, want 'hxxlo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '2rx' cursor position from middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "2", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test '4rx' from near end stops at line end", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "4", "r", "x")
m := getFinalModel(t, tm)
// Should only replace 2 chars (3 and 4)
if m.ActiveBuffer().Lines[0].String() != "helxx" {
t.Errorf("lines[0] = %q, want 'helxx'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test large count '100rx' doesn't crash", func(t *testing.T) {
lines := []string{"short"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "1", "0", "0", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xxxxx" {
t.Errorf("lines[0] = %q, want 'xxxxx'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceCharEdgeCases(t *testing.T) {
t.Run("test 'r' on empty line does nothing", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'r' on single character line", func(t *testing.T) {
lines := []string{"a"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "x" {
t.Errorf("Line(0) = %q, want 'x'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' at last char replaces it", func(t *testing.T) {
lines := []string{"ab"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ax" {
t.Errorf("Line(0) = %q, want 'ax'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' with whitespace", func(t *testing.T) {
lines := []string{"a b c"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "axb c" {
t.Errorf("Line(0) = %q, want 'axb c'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' preserves other lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test 'r' on line with tabs", func(t *testing.T) {
lines := []string{"a\tb"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "axb" {
t.Errorf("Line(0) = %q, want 'axb'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '5rx' with only 3 chars available", func(t *testing.T) {
lines := []string{"abc"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "5", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xxx" {
t.Errorf("Line(0) = %q, want 'xxx'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' in middle preserves surrounding chars", func(t *testing.T) {
lines := []string{"abcde"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abxde" {
t.Errorf("Line(0) = %q, want 'abxde'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' on whitespace-only line", func(t *testing.T) {
lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " x " {
t.Errorf("Line(0) = %q, want ' x '", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'r' on different lines independently", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x", "j", "r", "y")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xello" {
t.Errorf("Line(0) = %q, want 'xello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "yorld" {
t.Errorf("Line(1) = %q, want 'yorld'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test 'r' does not affect line count", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
})
t.Run("test 'r' with newline character is treated as single char", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
// Note: In vim, 'r' with enter doesn't work the same way
// This test documents the current behavior
sendKeys(tm, "r", "\n")
m := getFinalModel(t, tm)
// Replacing with "\n" string (not actual newline)
if m.ActiveBuffer().Lines[0].String() != "\nello" {
t.Errorf("Line(0) = %q, want '\\nello'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceCharWithMotion(t *testing.T) {
t.Run("test 'lrx' moves then replaces", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "l", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hxllo" {
t.Errorf("lines[0] = %q, want 'hxllo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'wrx' moves to next word start then replaces", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "w", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello xorld" {
t.Errorf("lines[0] = %q, want 'hello xorld'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '$rx' moves to end then replaces", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "$", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hellx" {
t.Errorf("lines[0] = %q, want 'hellx'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '2lrx' moves 2 right then replaces", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "2", "l", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hexlo" {
t.Errorf("lines[0] = %q, want 'hexlo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceCharUndo(t *testing.T) {
t.Run("test 'rx' can be undone with 'u'", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x", "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '3rx' can be undone with 'u'", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "3", "r", "x", "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple 'r' operations can be undone separately", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x", "l", "r", "y", "u")
m := getFinalModel(t, tm)
// Only the last 'ry' should be undone
if m.ActiveBuffer().Lines[0].String() != "xello" {
t.Errorf("lines[0] = %q, want 'xello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test undo then redo 'rx'", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x", "u", "ctrl+r")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xello" {
t.Errorf("lines[0] = %q, want 'xello'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceCharRepeat(t *testing.T) {
t.Run("test 'rx' can be repeated with '.'", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "x", "l", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xxllo" {
t.Errorf("lines[0] = %q, want 'xxllo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '3rx' can be repeated with '.'", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "3", "r", "x", "w", ".")
m := getFinalModel(t, tm)
// First 3rx at position 0, second at position 6
if m.ActiveBuffer().Lines[0].String() != "xxxlo xxxld" {
t.Errorf("lines[0] = %q, want 'xxxlo xxxld'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '.' repeats last replace char", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "r", "a", "l", ".", "l", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "aaalo" {
t.Errorf("lines[0] = %q, want 'aaalo'", m.ActiveBuffer().Lines[0].String())
}
})
// NOTE: This is the same as Vim's handling, but that is okay, I like this more, feels more honest
t.Run("test '.' with count multiplies", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "2", "r", "x", "w", "3", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "xxllo xxxxx" {
t.Errorf("lines[0] = %q, want 'xxllo xxxxx'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceCharMultiLine(t *testing.T) {
t.Run("test 'r' on second line", func(t *testing.T) {
lines := []string{"first", "second"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "first" {
t.Errorf("Line(0) = %q, want 'first'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "xecond" {
t.Errorf("Line(1) = %q, want 'xecond'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test 'r' does not cross line boundaries", func(t *testing.T) {
lines := []string{"ab", "cd"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "5", "r", "x")
m := getFinalModel(t, tm)
// Should only replace 'b', not cross to next line
if m.ActiveBuffer().Lines[0].String() != "ax" {
t.Errorf("Line(0) = %q, want 'ax'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "cd" {
t.Errorf("Line(1) = %q, want 'cd'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test 'r' on last line", func(t *testing.T) {
lines := []string{"first", "second", "third"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[2].String() != "xhird" {
t.Errorf("Line(2) = %q, want 'xhird'", m.ActiveBuffer().Lines[2].String())
}
})
}
func TestReplaceCharCombinations(t *testing.T) {
t.Run("test 'frx' finds then replaces", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "f", "w", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello xorld" {
t.Errorf("lines[0] = %q, want 'hello xorld'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '2frx' finds second occurrence then replaces", func(t *testing.T) {
lines := []string{"hello hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "2", "f", "l", "r", "x")
m := getFinalModel(t, tm)
// Find second 'l', which is at position 3
if m.ActiveBuffer().Lines[0].String() != "helxo hello" {
t.Errorf("lines[0] = %q, want 'helxo hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '^rx' moves to first non-blank then replaces", func(t *testing.T) {
lines := []string{" hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "^", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " xello" {
t.Errorf("lines[0] = %q, want ' xello'", m.ActiveBuffer().Lines[0].String())
}
})
}

View File

@ -398,9 +398,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
if m.ActiveWindow().Anchor.Col != 0 { if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
} }
// $ moves past end of line if m.ActiveWindow().Cursor.Col != 10 {
if m.ActiveWindow().Cursor.Col != 11 { t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
} }
}) })

View File

@ -172,6 +172,9 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd { func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd {
// Handle character motions (f/t/F/T) - transition to waiting state // Handle character motions (f/t/F/T) - transition to waiting state
if kind == "char_motion" { if kind == "char_motion" {
if key == "r" {
m.SetMode(core.ReplaceMode)
}
h.charMotionType = key h.charMotionType = key
h.state = StateWaitingForChar h.state = StateWaitingForChar
return nil return nil
@ -362,7 +365,11 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
// Apply count if supported // Apply count if supported
if r, ok := mot.(action.Repeatable); ok { if r, ok := mot.(action.Repeatable); ok {
mot = r.WithCount(count).(action.Motion) result := r.WithCount(count)
// WithCount returns Action, but char motions still implement Motion
if m, ok := result.(action.Motion); ok {
mot = m
}
} }
// If operator pending (e.g., "df{char}"), get range and operate // If operator pending (e.g., "df{char}"), get range and operate
@ -378,7 +385,14 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
// Otherwise just execute the motion // Otherwise just execute the motion
cmd := h.executeMotion(m, mot) cmd := h.executeMotion(m, mot)
// ReplaceChar modifies the buffer, so it should be repeatable with '.'
// (unlike f/t/F/T which are pure motions)
if _, isReplace := mot.(action.ReplaceChar); isReplace {
h.RecordAndReset(m)
} else {
h.Reset() h.Reset()
}
return cmd return cmd
} }

View File

@ -77,12 +77,14 @@ func NewNormalKeymap() *Keymap {
"u": action.Undo{}, "u": action.Undo{},
"ctrl+r": action.Redo{}, "ctrl+r": action.Redo{},
".": action.Repeat{Count: 1}, ".": action.Repeat{Count: 1},
"R": action.EnterReplace{},
}, },
charMotions: map[string]action.Motion{ charMotions: map[string]action.Motion{
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false}, "f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
"F": action.FindChar{Forward: false, Inclusive: true, Repeated: false}, "F": action.FindChar{Forward: false, Inclusive: true, Repeated: false},
"t": action.FindChar{Forward: true, Inclusive: false, Repeated: false}, "t": action.FindChar{Forward: true, Inclusive: false, Repeated: false},
"T": action.FindChar{Forward: false, Inclusive: false, Repeated: false}, "T": action.FindChar{Forward: false, Inclusive: false, Repeated: false},
"r": action.ReplaceChar{Count: 1},
}, },
modifiers: map[string]any{ modifiers: map[string]any{
"i": nil, "i": nil,
@ -202,7 +204,26 @@ func NewInsertKeymap() *Keymap {
"ctrl+w": action.InsertDeletePreviousWord{}, "ctrl+w": action.InsertDeletePreviousWord{},
}, },
} }
}
// NewReplaceKeymap: Creates a keymap for replace mode with editing actions.
func NewReplaceKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"down": motion.MoveDown{Count: 1},
"up": motion.MoveUp{Count: 1},
"left": motion.MoveLeft{Count: 1},
"right": motion.MoveRight{Count: 1},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
"enter": action.InsertNewline{},
"backspace": action.InsertBackspace{},
"delete": action.InsertDelete{},
"tab": action.InsertTab{},
"ctrl+w": action.InsertDeletePreviousWord{},
},
}
} }
// NewCommandKeymap: Creates a keymap for command mode with command line editing. // NewCommandKeymap: Creates a keymap for command mode with command line editing.

View File

@ -50,7 +50,7 @@ type MoveToLineEnd struct{}
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd { func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() win := m.ActiveWindow()
buf := m.ActiveBuffer() buf := m.ActiveBuffer()
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len()) win.SetCursorCol(buf.Lines[win.Cursor.Line].Len() - 1)
return nil return nil
} }

View File

@ -15,6 +15,7 @@ type Styles struct {
CursorNormal lipgloss.Style CursorNormal lipgloss.Style
CursorInsert lipgloss.Style CursorInsert lipgloss.Style
CursorCommand lipgloss.Style CursorCommand lipgloss.Style
CursorReplace lipgloss.Style
// Gutter (line numbers) // Gutter (line numbers)
Gutter lipgloss.Style Gutter lipgloss.Style
@ -47,6 +48,7 @@ func DefaultStyles() Styles {
CursorNormal: lipgloss.NewStyle().Reverse(true), CursorNormal: lipgloss.NewStyle().Reverse(true),
CursorInsert: lipgloss.NewStyle().Underline(true), CursorInsert: lipgloss.NewStyle().Underline(true),
CursorCommand: lipgloss.NewStyle().Reverse(true), CursorCommand: lipgloss.NewStyle().Reverse(true),
CursorReplace: lipgloss.NewStyle().Underline(true),
Gutter: lipgloss.NewStyle(). Gutter: lipgloss.NewStyle().
Background(lipgloss.Color("236")). Background(lipgloss.Color("236")).
@ -95,12 +97,17 @@ func ChromaStyles(chromaStyle *chroma.Style) Styles {
CursorInsert: lipgloss.NewStyle(). CursorInsert: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)). Background(lipgloss.Color(bgString)).
Bold(true).
Underline(true), Underline(true),
CursorCommand: lipgloss.NewStyle(). CursorCommand: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)). Background(lipgloss.Color(bgString)).
Reverse(true), Reverse(true),
CursorReplace: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Underline(true),
Gutter: lipgloss.NewStyle(). Gutter: lipgloss.NewStyle().
Background(lipgloss.Color( Background(lipgloss.Color(
darkenColor(lineNumbers.Background, 0.9).String()), darkenColor(lineNumbers.Background, 0.9).String()),
@ -163,6 +170,8 @@ func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
return s.CursorInsert return s.CursorInsert
case core.CommandMode: case core.CommandMode:
return s.CursorCommand return s.CursorCommand
case core.ReplaceMode:
return s.CursorReplace
default: default:
return s.CursorNormal return s.CursorNormal
} }
@ -177,6 +186,9 @@ func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Background(style.GetForeground()). Background(style.GetForeground()).
Foreground(style.GetBackground()) Foreground(style.GetBackground())
case core.ReplaceMode:
return lipgloss.NewStyle().
Underline(true)
default: default:
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Background(s.BackgroundStyle.GetBackground()). Background(s.BackgroundStyle.GetBackground()).