From 6033e58d0e69925f7a66abb3b96c265d0bc25099 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sun, 5 Apr 2026 22:58:07 -0700 Subject: [PATCH 1/3] 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()). -- 2.47.2 From 43b3992522e5a9b8521624ab086f910e748569c8 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sun, 5 Apr 2026 23:53:24 -0700 Subject: [PATCH 2/3] feat: implemented replace mode, tested! Looking great, maybe I will actually use this lol --- FEATURES.md | 4 +- README.md | 8 + internal/action/replace.go | 71 ++- internal/core/mode.go | 3 +- internal/editor/helpers_test.go | 10 + internal/editor/integration_replace_test.go | 474 ++++++++++++++++++++ internal/editor/model_builder.go | 71 +-- internal/input/handler.go | 36 +- internal/input/keymap.go | 8 +- internal/style/style.go | 4 +- 10 files changed, 603 insertions(+), 86 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 61e7de5..261c4b2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -133,8 +133,8 @@ - [ ] `U` - Undo all changes on line ### Other Normal Mode -- [ ] `r{char}` - Replace character -- [ ] `R` - Replace mode +- [x] `r{char}` - Replace character +- [x] `R` - Replace mode - [ ] `~` - Swap case of character - [ ] `ctrl+a` - Increment number - [ ] `ctrl+x` - Decrement number diff --git a/README.md b/README.md index a3df185..e2bb01b 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,14 @@ While the undo tree method that vim uses is powerful, I rarely find myself using approach is more natural to "non-vim" users and much simpler to implement. Implementing a feature similar to Vims undo tree would many times longer than a simple stack. +#### Vim-like Replace vs. Custom Replace + +The way that vim's replace mode is implemented is quite complex, keeping track of the previous +line backspace can only delete newly replaced characters. This is a complex feature, one that +I rarely use, and even find a bit un-intuitive. Implementing replace mode in a way where all +actions function the same as insert mode (other than the actual character typing) allows for +a much simpler implementation, as well as a more intuitive user experience. + --- ## TODO List diff --git a/internal/action/replace.go b/internal/action/replace.go index fd81e43..8fdd216 100644 --- a/internal/action/replace.go +++ b/internal/action/replace.go @@ -1,6 +1,8 @@ package action import ( + "strings" + "git.gophernest.net/azpect/TextEditor/internal/core" tea "github.com/charmbracelet/bubbletea" ) @@ -61,12 +63,67 @@ func (a EnterReplace) WithCount(n int) Action { } func (a EnterReplace) Execute(m Model) tea.Cmd { - - m.SetCommandOutput(&core.CommandOutput{ - Lines: []string{"Replace mode (R) not implemented yet"}, - Inline: true, - IsError: true, - }) - + m.SetMode(core.ReplaceMode) + return nil +} + +type ReplaceModeChar struct { + Char string +} + +// ReplaceModeChar.Execute: Inserts a single character at the cursor position, overwriting current +// character. +func (a ReplaceModeChar) Execute(m Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x, y := win.Cursor.Col, win.Cursor.Line + l := buf.Line(y) + if x < len(l) { + buf.SetLine(y, l[:x]+a.Char+l[x+1:]) + } else { + buf.SetLine(y, l+a.Char) + } + win.SetCursorCol(x + len(a.Char)) + return nil +} + +// ReplaceNewline splits the current line at the cursor (enter key) +type ReplaceNewline struct{} + +// ReplaceNewline.Execute: Splits the current line at the cursor (Enter key). +func (a ReplaceNewline) Execute(m Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x, y := win.Cursor.Col, win.Cursor.Line + l := buf.Line(y) + if x == len(l) { + buf.InsertLine(y+1, "") + } else { + buf.SetLine(y, l[:x]) + buf.InsertLine(y+1, l[x+1:]) + } + win.SetCursorPos(y+1, 0) + return nil +} + +// ReplaceTab inserts spaces equal to the tab size +type ReplaceTab struct{} + +// ReplaceTab.Execute: Inserts spaces equal to the tab size (Tab key). +func (a ReplaceTab) Execute(m Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x, y := win.Cursor.Col, win.Cursor.Line + l := buf.Line(y) + tabs := strings.Repeat(" ", m.Settings().TabStop) + if x < len(l) { + buf.SetLine(y, l[:x]+tabs+l[x+1:]) + } else { + buf.SetLine(y, l+tabs) + } + win.SetCursorCol(x + len(tabs)) return nil } diff --git a/internal/core/mode.go b/internal/core/mode.go index 24f8761..e679c28 100644 --- a/internal/core/mode.go +++ b/internal/core/mode.go @@ -12,13 +12,14 @@ const ( VisualLineMode VisualBlockMode ReplaceMode + WaitingMode // Same as NORMAL output, but cursor is the REPLACE cursor ) // Mode.ToString: Returns a human-readable string representation of the mode // for display in the status bar. func (m Mode) ToString() string { switch m { - case NormalMode: + case NormalMode, WaitingMode: return "NORMAL" case InsertMode: return "INSERT" diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index 187632d..873e6fa 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -34,6 +34,16 @@ func sendKeys(tm *teatest.TestModel, keys ...string) { tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR}) case "ctrl+w": tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW}) + case "tab": + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + case "left": + tm.Send(tea.KeyMsg{Type: tea.KeyLeft}) + case "right": + tm.Send(tea.KeyMsg{Type: tea.KeyRight}) + case "up": + tm.Send(tea.KeyMsg{Type: tea.KeyUp}) + case "down": + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) default: tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) } diff --git a/internal/editor/integration_replace_test.go b/internal/editor/integration_replace_test.go index 661d652..1b5d4d2 100644 --- a/internal/editor/integration_replace_test.go +++ b/internal/editor/integration_replace_test.go @@ -6,6 +6,10 @@ import ( "git.gophernest.net/azpect/TextEditor/internal/core" ) +// ================================================== +// Replace Char (r) Tests +// ================================================== + func TestReplaceChar(t *testing.T) { t.Run("test 'rx' replaces character under cursor", func(t *testing.T) { lines := []string{"hello"} @@ -564,3 +568,473 @@ func TestReplaceCharCombinations(t *testing.T) { } }) } + +// ================================================== +// Replace Mode (R) Tests +// ================================================== + +func TestReplaceModeEntry(t *testing.T) { + t.Run("test 'R' enters replace mode", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + + m := getFinalModel(t, tm) + if m.Mode() != core.ReplaceMode { + t.Errorf("Mode() = %v, want ReplaceMode", m.Mode()) + } + }) + + t.Run("test 'esc' exits replace mode to normal", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "x", "esc") + + m := getFinalModel(t, tm) + if m.Mode() != core.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) +} + +func TestReplaceModeBasic(t *testing.T) { + t.Run("test single character overwrites", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "X", "esc") + + 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 multiple characters overwrite", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "abclo" { + t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test typing past end of line appends", func(t *testing.T) { + lines := []string{"hi"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "R") + sendKeyString(tm, "there") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "hithere" { + t.Errorf("lines[0] = %q, want 'hithere'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test replace mode starting at end of line", func(t *testing.T) { + lines := []string{"hi"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "R") + sendKeyString(tm, "!!") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "hi!!" { + t.Errorf("lines[0] = %q, want 'hi!!'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test replace mode from middle of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "R") + sendKeyString(tm, "XX") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "heXXo" { + t.Errorf("lines[0] = %q, want 'heXXo'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test cursor position after exiting replace mode", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "esc") + + 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) + } + }) +} + +func TestReplaceModeBackspace(t *testing.T) { + t.Run("test backspace deletes character", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "backspace", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "ablo" { + t.Errorf("lines[0] = %q, want 'ablo'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test backspace at start does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "backspace", "esc") + + 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 backspaces", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "backspace", "backspace", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "alo" { + t.Errorf("lines[0] = %q, want 'alo'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test backspace then type more", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "backspace") + sendKeyString(tm, "XY") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "abXY" { + t.Errorf("lines[0] = %q, want 'abXY'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestReplaceModeNavigation(t *testing.T) { + t.Run("test right arrow moves cursor", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "right", "right", "X", "esc") + + 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 left arrow moves cursor", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "R", "left", "X", "esc") + + 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 up/down arrows navigate between lines", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "X", "down", "Y", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "Xello" { + t.Errorf("lines[0] = %q, want 'Xello'", m.ActiveBuffer().Lines[0].String()) + } + if m.ActiveBuffer().Lines[1].String() != "wYrld" { + t.Errorf("lines[1] = %q, want 'wYrld'", m.ActiveBuffer().Lines[1].String()) + } + }) + + t.Run("test up arrow from first line stays on first line", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "up", "X", "esc") + + 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 down arrow from last line stays on last line", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "R", "down", "X", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[1].String() != "Xorld" { + t.Errorf("lines[1] = %q, want 'Xorld'", m.ActiveBuffer().Lines[1].String()) + } + }) +} + +func TestReplaceModeSpecialKeys(t *testing.T) { + t.Run("test enter splits line", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) + sendKeys(tm, "R", "enter", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String()) + } + if m.ActiveBuffer().Lines[1].String() != "world" { + t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1].String()) + } + }) + + t.Run("test tab inserts tab character", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "tab", "esc") + + m := getFinalModel(t, tm) + // Tab is expanded to spaces based on tabstop setting (default 2) + if m.ActiveBuffer().Lines[0].String() != " ello" { + t.Errorf("lines[0] = %q, want ' ello'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test ctrl+w deletes previous word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "foo bar") + sendKeys(tm, "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "foo orld" { + t.Errorf("lines[0] = %q, want 'foo orld'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test ctrl+w at beginning does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "ctrl+w", "esc") + + 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 delete key removes character ahead", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "delete", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "ello" { + t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestReplaceModeUndo(t *testing.T) { + t.Run("test replace mode changes can be undone", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "esc", "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 replace mode changes can be redone", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "esc", "u", "ctrl+r") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "abclo" { + t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test multiple undo operations", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "esc") + sendKeys(tm, "l", "R") + sendKeyString(tm, "XY") + sendKeys(tm, "esc", "u") + + m := getFinalModel(t, tm) + // Only the second replace should be undone + if m.ActiveBuffer().Lines[0].String() != "abclo" { + t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestReplaceModeEdgeCases(t *testing.T) { + t.Run("test replace mode on empty line", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "test") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "test" { + t.Errorf("lines[0] = %q, want 'test'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test replace mode on different lines", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "R", "X", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String()) + } + if m.ActiveBuffer().Lines[1].String() != "Xorld" { + t.Errorf("lines[1] = %q, want 'Xorld'", m.ActiveBuffer().Lines[1].String()) + } + }) + + t.Run("test overwriting tab character", func(t *testing.T) { + lines := []string{"a\tb"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) + sendKeys(tm, "R", "X", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "aXb" { + t.Errorf("lines[0] = %q, want 'aXb'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test replace mode with whitespace characters", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "a b") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "a blo" { + t.Errorf("lines[0] = %q, want 'a blo'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test replace mode on single character line", func(t *testing.T) { + lines := []string{"a"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "X", "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "X" { + t.Errorf("lines[0] = %q, want 'X'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test replace mode preserves other lines", func(t *testing.T) { + lines := []string{"first", "second", "third"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "R") + sendKeyString(tm, "XXX") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "first" { + t.Errorf("lines[0] = %q, want 'first'", m.ActiveBuffer().Lines[0].String()) + } + if m.ActiveBuffer().Lines[1].String() != "XXXond" { + t.Errorf("lines[1] = %q, want 'XXXond'", m.ActiveBuffer().Lines[1].String()) + } + if m.ActiveBuffer().Lines[2].String() != "third" { + t.Errorf("lines[2] = %q, want 'third'", m.ActiveBuffer().Lines[2].String()) + } + }) + + t.Run("test replace mode with special characters", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "@#$") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "@#$lo" { + t.Errorf("lines[0] = %q, want '@#$lo'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestReplaceModeRepeat(t *testing.T) { + t.Run("test replace mode can be repeated with '.'", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "XX") + sendKeys(tm, "esc", "j", ".") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "XXllo" { + t.Errorf("lines[0] = %q, want 'XXllo'", m.ActiveBuffer().Lines[0].String()) + } + if m.ActiveBuffer().Lines[1].String() != "wXXld" { + t.Errorf("lines[1] = %q, want 'wXXld'", m.ActiveBuffer().Lines[1].String()) + } + }) + + t.Run("test dot repeats last replace operation", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R") + sendKeyString(tm, "abc") + sendKeys(tm, "esc", "w", ".") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "abclo abcld" { + t.Errorf("lines[0] = %q, want 'abclo abcld'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test dot after navigation", func(t *testing.T) { + lines := []string{"aaa", "bbb", "ccc"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "R", "X", "esc", "j", "j", ".") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0].String() != "Xaa" { + t.Errorf("lines[0] = %q, want 'Xaa'", m.ActiveBuffer().Lines[0].String()) + } + if m.ActiveBuffer().Lines[2].String() != "Xcc" { + t.Errorf("lines[2] = %q, want 'Xcc'", m.ActiveBuffer().Lines[2].String()) + } + }) +} diff --git a/internal/editor/model_builder.go b/internal/editor/model_builder.go index ea7c435..d70c048 100644 --- a/internal/editor/model_builder.go +++ b/internal/editor/model_builder.go @@ -11,76 +11,7 @@ type ModelBuilder struct { model Model } -// RPGLE -// abap -// algol -// algol_nu -// arduino -// ashen -// aura-theme-dark -// aura-theme-dark-soft -// autumn -// average -// base16-snazzy -// borland -// bw -// catppuccin-frappe -// catppuccin-latte -// catppuccin-macchiato -// catppuccin-mocha -// colorful -// doom-one -// doom-one2 -// dracula -// emacs -// evergarden -// friendly -// fruity -// github -// github-dark -// gruvbox -// gruvbox-light -// hr_high_contrast -// hrdark -// igor -// lovelace -// manni -// modus-operandi -// modus-vivendi -// monokai -// monokailight -// murphy -// native -// nord -// nordic -// onedark -// onesenterprise -// paraiso-dark -// paraiso-light -// pastie -// perldoc -// pygments -// rainbow_dash -// rose-pine -// rose-pine-dawn -// rose-pine-moon -// rrt -// solarized-dark -// solarized-dark256 -// solarized-light -// swapoff -// tango -// tokyonight-day -// tokyonight-moon -// tokyonight-night -// tokyonight-storm -// trac -// vim -// vs -// vulcan -// witchhazel -// xcode -// xcode-dark +// NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave). func NewModelBuilder() *ModelBuilder { chromaStyle := styles.Get("kanagawa-wave") diff --git a/internal/input/handler.go b/internal/input/handler.go index 38e7071..5c574e9 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -41,6 +41,7 @@ type Handler struct { normalKeymap *Keymap visualKeymap *Keymap insertKeymap *Keymap + replaceKeymap *Keymap commandKeymap *Keymap currentKeymap *Keymap @@ -53,6 +54,7 @@ func NewHandler() *Handler { normalKeymap: NewNormalKeymap(), visualKeymap: NewVisualKeymap(), insertKeymap: NewInsertKeymap(), + replaceKeymap: NewReplaceKeymap(), commandKeymap: NewCommandKeymap(), currentKeymap: nil, } @@ -77,7 +79,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { h.recordingKeys = []string{} // Clear recording on ESC h.Reset() - if m.Mode() == core.InsertMode { + if m.Mode() == core.InsertMode || m.Mode() == core.ReplaceMode { // Before exiting insert mode, end the block in the undo stack win := m.ActiveWindow() buf := m.ActiveBuffer() @@ -95,6 +97,8 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { switch m.Mode() { case core.InsertMode: return h.handleInsertKey(m, key) + case core.ReplaceMode: + return h.handleReplaceKey(m, key) case core.CommandMode: return h.handleCommandKey(m, key) } @@ -173,7 +177,7 @@ func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) // Handle character motions (f/t/F/T) - transition to waiting state if kind == "char_motion" { if key == "r" { - m.SetMode(core.ReplaceMode) + m.SetMode(core.WaitingMode) } h.charMotionType = key h.state = StateWaitingForChar @@ -570,6 +574,34 @@ func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd { return action.InsertChar{Char: key}.Execute(m) } +func (h *Handler) handleReplaceKey(m action.Model, key string) tea.Cmd { + buf := m.ActiveBuffer() + win := m.ActiveWindow() + + // Start undo block on first insert key + if buf.UndoStack != nil && !buf.UndoStack.Recording() { + buf.UndoStack.BeginBlock(win.Cursor) + } + + // TODO: Handle differently + + // Record the key for count replay (e.g. 5i...) + m.SetInsertKeys(append(m.InsertKeys(), key)) + m.SetLastChangeKeys(append(m.LastChangeKeys(), key)) + + // Check the insert keymap first + kind, binding := h.replaceKeymap.Lookup(key) + switch kind { + case "action": + return binding.(action.Action).Execute(m) + case "motion": + return binding.(action.Motion).Execute(m) + } + + // Fallback: treat as a regular character to "insert" + return action.ReplaceModeChar{Char: key}.Execute(m) +} + // Handler.handleCommandKey: Processes a keypress in command mode, executing // it as an action or inserting it into the command line. This does not record // anything into the undo stack. diff --git a/internal/input/keymap.go b/internal/input/keymap.go index c0d76bc..a0569a7 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -148,6 +148,8 @@ func NewVisualKeymap() *Keymap { "X": operator.DeleteOperator{}, "y": operator.YankOperator{}, "c": operator.ChangeOperator{}, + "s": operator.ChangeOperator{}, // Same as c in visual mode + "R": operator.ChangeOperator{}, // Seems to do the same thing }, actions: map[string]action.Action{ "p": action.VisualPaste{Count: 1, Replace: true}, @@ -206,7 +208,7 @@ func NewInsertKeymap() *Keymap { } } -// NewReplaceKeymap: Creates a keymap for replace mode with editing actions. +// NewReplaceKeymap: Creates a keymap for replace mode with editing actions. All actions func NewReplaceKeymap() *Keymap { return &Keymap{ motions: map[string]action.Motion{ @@ -217,10 +219,10 @@ func NewReplaceKeymap() *Keymap { }, operators: map[string]action.Operator{}, // this will likely be empty actions: map[string]action.Action{ - "enter": action.InsertNewline{}, + "enter": action.ReplaceNewline{}, "backspace": action.InsertBackspace{}, "delete": action.InsertDelete{}, - "tab": action.InsertTab{}, + "tab": action.ReplaceTab{}, // TODO: This needs replacing "ctrl+w": action.InsertDeletePreviousWord{}, }, } diff --git a/internal/style/style.go b/internal/style/style.go index f69725e..e5d026e 100755 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -186,8 +186,10 @@ func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style return lipgloss.NewStyle(). Background(style.GetForeground()). Foreground(style.GetBackground()) - case core.ReplaceMode: + case core.ReplaceMode, core.WaitingMode: return lipgloss.NewStyle(). + Background(style.GetBackground()). + Foreground(style.GetForeground()). Underline(true) default: return lipgloss.NewStyle(). -- 2.47.2 From 881a4d6d78a341c85689c188df03393b456f0b30 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 6 Apr 2026 18:29:23 -0700 Subject: [PATCH 3/3] doc: documented trade off regarding last insert tracking --- README.md | 4 ++++ internal/input/handler.go | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2bb01b..22c2ccf 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ I rarely use, and even find a bit un-intuitive. Implementing replace mode in a w actions function the same as insert mode (other than the actual character typing) allows for a much simpler implementation, as well as a more intuitive user experience. +Replace mode implements and replaces (no pun intended) the last inserted keys of insert mode. Due to +the infrequent use of replace mode, and the '.' action for insert mode, this felt like a natural +trade off. + --- ## TODO List diff --git a/internal/input/handler.go b/internal/input/handler.go index 5c574e9..25e0bd3 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -583,8 +583,6 @@ func (h *Handler) handleReplaceKey(m action.Model, key string) tea.Cmd { buf.UndoStack.BeginBlock(win.Cursor) } - // TODO: Handle differently - // Record the key for count replay (e.g. 5i...) m.SetInsertKeys(append(m.InsertKeys(), key)) m.SetLastChangeKeys(append(m.LastChangeKeys(), key)) -- 2.47.2