From 6033e58d0e69925f7a66abb3b96c265d0bc25099 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sun, 5 Apr 2026 22:58:07 -0700 Subject: [PATCH] feat: implement the r action, tested Began work on replace mode, but not complete. --- internal/action/replace.go | 72 +++ internal/core/mode.go | 3 + .../editor/integration_motion_jump_test.go | 6 +- internal/editor/integration_replace_test.go | 566 ++++++++++++++++++ internal/editor/integration_visual_test.go | 5 +- internal/input/handler.go | 18 +- internal/input/keymap.go | 21 + internal/motion/jump.go | 2 +- internal/style/style.go | 12 + 9 files changed, 696 insertions(+), 9 deletions(-) create mode 100644 internal/action/replace.go create mode 100644 internal/editor/integration_replace_test.go diff --git a/internal/action/replace.go b/internal/action/replace.go new file mode 100644 index 0000000..fd81e43 --- /dev/null +++ b/internal/action/replace.go @@ -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 +} diff --git a/internal/core/mode.go b/internal/core/mode.go index 6131c3e..24f8761 100644 --- a/internal/core/mode.go +++ b/internal/core/mode.go @@ -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 "-----" } diff --git a/internal/editor/integration_motion_jump_test.go b/internal/editor/integration_motion_jump_test.go index 9ef39c0..1ed46d5 100644 --- a/internal/editor/integration_motion_jump_test.go +++ b/internal/editor/integration_motion_jump_test.go @@ -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) } diff --git a/internal/editor/integration_replace_test.go b/internal/editor/integration_replace_test.go new file mode 100644 index 0000000..661d652 --- /dev/null +++ b/internal/editor/integration_replace_test.go @@ -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()) + } + }) +} diff --git a/internal/editor/integration_visual_test.go b/internal/editor/integration_visual_test.go index b07fc61..e9bc037 100644 --- a/internal/editor/integration_visual_test.go +++ b/internal/editor/integration_visual_test.go @@ -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) } }) diff --git a/internal/input/handler.go b/internal/input/handler.go index 24e8161..38e7071 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -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 } diff --git a/internal/input/keymap.go b/internal/input/keymap.go index a2cfc3e..c0d76bc 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -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. diff --git a/internal/motion/jump.go b/internal/motion/jump.go index 98e3937..fc811f9 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -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 } diff --git a/internal/style/style.go b/internal/style/style.go index f1feadd..f69725e 100755 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -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()).