Implemented the replace (r and R) actions and replace mode #5
72
internal/action/replace.go
Normal file
72
internal/action/replace.go
Normal 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
|
||||
}
|
||||
@ -11,6 +11,7 @@ const (
|
||||
VisualMode
|
||||
VisualLineMode
|
||||
VisualBlockMode
|
||||
ReplaceMode
|
||||
)
|
||||
|
||||
// Mode.ToString: Returns a human-readable string representation of the mode
|
||||
@ -29,6 +30,8 @@ func (m Mode) ToString() string {
|
||||
return "V-LINE"
|
||||
case VisualBlockMode:
|
||||
return "V-BLOCK"
|
||||
case ReplaceMode:
|
||||
return "REPLACE"
|
||||
default:
|
||||
return "-----"
|
||||
}
|
||||
|
||||
@ -176,7 +176,7 @@ func TestMoveToLineEnd(t *testing.T) {
|
||||
sendKeys(tm, "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
want := len(lines[0]) - 1
|
||||
if 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, "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
want := len(lines[0]) - 1
|
||||
if 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, "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
want := len(lines[0]) - 1
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
|
||||
566
internal/editor/integration_replace_test.go
Normal file
566
internal/editor/integration_replace_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -398,9 +398,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
|
||||
if m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
// $ moves past end of line
|
||||
if m.ActiveWindow().Cursor.Col != 11 {
|
||||
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
|
||||
if m.ActiveWindow().Cursor.Col != 10 {
|
||||
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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 {
|
||||
// Handle character motions (f/t/F/T) - transition to waiting state
|
||||
if kind == "char_motion" {
|
||||
if key == "r" {
|
||||
m.SetMode(core.ReplaceMode)
|
||||
}
|
||||
h.charMotionType = key
|
||||
h.state = StateWaitingForChar
|
||||
return nil
|
||||
@ -362,7 +365,11 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
|
||||
|
||||
// Apply count if supported
|
||||
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
|
||||
@ -378,7 +385,14 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
|
||||
|
||||
// Otherwise just execute the motion
|
||||
cmd := h.executeMotion(m, mot)
|
||||
h.Reset()
|
||||
|
||||
// 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()
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@ -77,12 +77,14 @@ func NewNormalKeymap() *Keymap {
|
||||
"u": action.Undo{},
|
||||
"ctrl+r": action.Redo{},
|
||||
".": action.Repeat{Count: 1},
|
||||
"R": action.EnterReplace{},
|
||||
},
|
||||
charMotions: map[string]action.Motion{
|
||||
"f": action.FindChar{Forward: true, 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: false, Inclusive: false, Repeated: false},
|
||||
"r": action.ReplaceChar{Count: 1},
|
||||
},
|
||||
modifiers: map[string]any{
|
||||
"i": nil,
|
||||
@ -202,7 +204,26 @@ func NewInsertKeymap() *Keymap {
|
||||
"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.
|
||||
|
||||
@ -50,7 +50,7 @@ type MoveToLineEnd struct{}
|
||||
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len())
|
||||
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len() - 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ type Styles struct {
|
||||
CursorNormal lipgloss.Style
|
||||
CursorInsert lipgloss.Style
|
||||
CursorCommand lipgloss.Style
|
||||
CursorReplace lipgloss.Style
|
||||
|
||||
// Gutter (line numbers)
|
||||
Gutter lipgloss.Style
|
||||
@ -47,6 +48,7 @@ func DefaultStyles() Styles {
|
||||
CursorNormal: lipgloss.NewStyle().Reverse(true),
|
||||
CursorInsert: lipgloss.NewStyle().Underline(true),
|
||||
CursorCommand: lipgloss.NewStyle().Reverse(true),
|
||||
CursorReplace: lipgloss.NewStyle().Underline(true),
|
||||
|
||||
Gutter: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
@ -95,12 +97,17 @@ func ChromaStyles(chromaStyle *chroma.Style) Styles {
|
||||
|
||||
CursorInsert: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(bgString)).
|
||||
Bold(true).
|
||||
Underline(true),
|
||||
|
||||
CursorCommand: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(bgString)).
|
||||
Reverse(true),
|
||||
|
||||
CursorReplace: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(bgString)).
|
||||
Underline(true),
|
||||
|
||||
Gutter: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(
|
||||
darkenColor(lineNumbers.Background, 0.9).String()),
|
||||
@ -163,6 +170,8 @@ func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
|
||||
return s.CursorInsert
|
||||
case core.CommandMode:
|
||||
return s.CursorCommand
|
||||
case core.ReplaceMode:
|
||||
return s.CursorReplace
|
||||
default:
|
||||
return s.CursorNormal
|
||||
}
|
||||
@ -177,6 +186,9 @@ func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style
|
||||
return lipgloss.NewStyle().
|
||||
Background(style.GetForeground()).
|
||||
Foreground(style.GetBackground())
|
||||
case core.ReplaceMode:
|
||||
return lipgloss.NewStyle().
|
||||
Underline(true)
|
||||
default:
|
||||
return lipgloss.NewStyle().
|
||||
Background(s.BackgroundStyle.GetBackground()).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user