diff --git a/internal/action/change.go b/internal/action/change.go new file mode 100644 index 0000000..0a3a25e --- /dev/null +++ b/internal/action/change.go @@ -0,0 +1,109 @@ +package action + +import tea "github.com/charmbracelet/bubbletea" + +// ChangeToEndOfLine implements Action (C) - changes from cursor to end of line +type ChangeToEndOfLine struct { + Count int +} + +func (a ChangeToEndOfLine) Execute(m Model) tea.Cmd { + pos := m.CursorX() + line := m.Line(m.CursorY()) + + // Save deleted text to register + if pos < len(line) { + m.UpdateDefaultRegister(CharwiseRegister, []string{line[pos:]}) + } + + // Delete to end of line + m.SetLine(m.CursorY(), line[:pos]) + + // Enter insert mode + m.SetMode(InsertMode) + + return nil +} + +// Ensure ChangeToEndOfLine implements Repeatable +var _ Repeatable = ChangeToEndOfLine{} + +func (a ChangeToEndOfLine) WithCount(n int) Action { + return ChangeToEndOfLine{Count: n} +} + +// SubstituteChar implements Action (s) - deletes character and enters insert mode +type SubstituteChar struct { + Count int +} + +func (a SubstituteChar) Execute(m Model) tea.Cmd { + pos := m.CursorX() + line := m.Line(m.CursorY()) + + // Calculate how many chars to delete (limited by line length) + count := min(a.Count, len(line)-pos) + + if count > 0 { + // Save deleted text to register + m.UpdateDefaultRegister(CharwiseRegister, []string{line[pos : pos+count]}) + + // Delete the characters + m.SetLine(m.CursorY(), line[:pos]+line[pos+count:]) + } + + // Enter insert mode + m.SetMode(InsertMode) + + return nil +} + +// Ensure SubstituteChar implements Repeatable +var _ Repeatable = SubstituteChar{} + +func (a SubstituteChar) WithCount(n int) Action { + return SubstituteChar{Count: n} +} + +// SubstituteLine implements Action (S) - clears line and enters insert mode +type SubstituteLine struct { + Count int +} + +func (a SubstituteLine) Execute(m Model) tea.Cmd { + y := m.CursorY() + + // Calculate how many lines to substitute + count := min(a.Count, m.LineCount()-y) + + var lines []string + + // Collect and delete lines + for range count { + lines = append(lines, m.Line(y)) + m.DeleteLine(y) + } + + // Save deleted lines to register + m.UpdateDefaultRegister(LinewiseRegister, lines) + + // Insert empty line at original position + insertY := min(y, m.LineCount()) + m.InsertLine(insertY, "") + + // Position cursor + m.SetCursorY(insertY) + m.SetCursorX(0) + + // Enter insert mode + m.SetMode(InsertMode) + + return nil +} + +// Ensure SubstituteLine implements Repeatable +var _ Repeatable = SubstituteLine{} + +func (a SubstituteLine) WithCount(n int) Action { + return SubstituteLine{Count: n} +} diff --git a/internal/editor/integration_operator_change_test.go b/internal/editor/integration_operator_change_test.go new file mode 100644 index 0000000..5ca86a0 --- /dev/null +++ b/internal/editor/integration_operator_change_test.go @@ -0,0 +1,1014 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// ============================================================================= +// cc (Change Line) Tests +// ============================================================================= + +func TestChangeLine(t *testing.T) { + t.Run("cc changes first line and enters insert mode", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello", "world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + // First line should be empty (ready for insert) + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + if m.Line(1) != "world" { + t.Errorf("Line(1) = %q, want 'world'", m.Line(1)) + } + }) + + t.Run("cc changes middle line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "line one" { + t.Errorf("Line(0) = %q, want 'line one'", m.Line(0)) + } + if m.Line(1) != "" { + t.Errorf("Line(1) = %q, want ''", m.Line(1)) + } + if m.Line(2) != "line three" { + t.Errorf("Line(2) = %q, want 'line three'", m.Line(2)) + } + }) + + t.Run("cc changes last line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello", "world"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "hello" { + t.Errorf("Line(0) = %q, want 'hello'", m.Line(0)) + } + if m.Line(1) != "" { + t.Errorf("Line(1) = %q, want ''", m.Line(1)) + } + }) + + t.Run("cc puts deleted line in register", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world", "second line"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "c") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 1 || reg.Content[0] != "hello world" { + t.Errorf("register content = %q, want 'hello world'", reg.Content) + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + }) + + t.Run("cc on single line file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"only line"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("cc cursor at column 0", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello", "world"}), + WithCursorPos(action.Position{Line: 0, Col: 3}), + ) + sendKeys(tm, "c", "c") + + m := getFinalModel(t, tm) + // Cursor should be at column 0 on the empty line + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) +} + +func TestChangeLineWithCount(t *testing.T) { + t.Run("2cc changes two lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three", "line four"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "2", "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have 3 lines: empty + line three + line four + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + if m.Line(1) != "line three" { + t.Errorf("Line(1) = %q, want 'line three'", m.Line(1)) + } + }) + + t.Run("3cc changes three lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"one", "two", "three", "four", "five"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "3", "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have 3 lines: one + empty + five + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "one" { + t.Errorf("Line(0) = %q, want 'one'", m.Line(0)) + } + if m.Line(1) != "" { + t.Errorf("Line(1) = %q, want ''", m.Line(1)) + } + if m.Line(2) != "five" { + t.Errorf("Line(2) = %q, want 'five'", m.Line(2)) + } + }) + + t.Run("5cc with only 3 lines remaining changes all remaining", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"one", "two", "three"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "5", "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("2cc puts both lines in register", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"first", "second", "third"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "2", "c", "c") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 2 { + t.Errorf("register content length = %d, want 2", len(reg.Content)) + } + }) +} + +// ============================================================================= +// c with Horizontal Motions Tests +// ============================================================================= + +func TestChangeWithHorizontalMotion(t *testing.T) { + t.Run("cl changes single character", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "l") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "ello world" { + t.Errorf("Line(0) = %q, want 'ello world'", m.Line(0)) + } + }) + + t.Run("c2l changes two characters", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "2", "l") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "llo world" { + t.Errorf("Line(0) = %q, want 'llo world'", m.Line(0)) + } + }) + + t.Run("ch changes character to the left", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 5}), + ) + sendKeys(tm, "c", "h") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "hell world" { + t.Errorf("Line(0) = %q, want 'hell world'", m.Line(0)) + } + }) + + t.Run("c$ changes to end of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), + ) + sendKeys(tm, "c", "$") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "hello " { + t.Errorf("Line(0) = %q, want 'hello '", m.Line(0)) + } + }) + + t.Run("c0 changes to start of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), + ) + sendKeys(tm, "c", "0") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "world" { + t.Errorf("Line(0) = %q, want 'world'", m.Line(0)) + } + }) + + t.Run("c^ changes to first non-blank", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{" hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 8}), + ) + sendKeys(tm, "c", "^") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // ^ is exclusive motion, so position 8 (space) is not included + // Delete positions 3-7 ("hello"), leaving " " + " world" = " world" + if m.Line(0) != " world" { + t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) + } + }) +} + +// ============================================================================= +// c with Word Motions Tests +// ============================================================================= + +func TestChangeWithWordMotion(t *testing.T) { + t.Run("cw changes word", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "w") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "world" { + t.Errorf("Line(0) = %q, want 'world'", m.Line(0)) + } + }) + + t.Run("cw from middle of word changes to next word", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 2}), + ) + sendKeys(tm, "c", "w") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "heworld" { + t.Errorf("Line(0) = %q, want 'heworld'", m.Line(0)) + } + }) + + t.Run("ce changes to end of word", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "e") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != " world" { + t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) + } + }) + + t.Run("cb changes backward word", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), + ) + sendKeys(tm, "c", "b") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "world" { + t.Errorf("Line(0) = %q, want 'world'", m.Line(0)) + } + }) + + t.Run("c2w changes two words", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"one two three four"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "2", "w") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "three four" { + t.Errorf("Line(0) = %q, want 'three four'", m.Line(0)) + } + }) + + t.Run("cW changes WORD", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello.world next"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "W") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "next" { + t.Errorf("Line(0) = %q, want 'next'", m.Line(0)) + } + }) + + t.Run("cE changes to end of WORD", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello.world next"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "E") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != " next" { + t.Errorf("Line(0) = %q, want ' next'", m.Line(0)) + } + }) +} + +// ============================================================================= +// c with Vertical Motions Tests +// ============================================================================= + +func TestChangeWithVerticalMotion(t *testing.T) { + t.Run("cj changes current and next line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "j") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have empty line + line three + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + if m.Line(1) != "line three" { + t.Errorf("Line(1) = %q, want 'line three'", m.Line(1)) + } + }) + + t.Run("ck changes current and previous line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "c", "k") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have empty line + line three + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + if m.Line(1) != "line three" { + t.Errorf("Line(1) = %q, want 'line three'", m.Line(1)) + } + }) + + t.Run("c2j changes three lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"one", "two", "three", "four", "five"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "2", "j") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have empty + four + five + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + if m.Line(1) != "four" { + t.Errorf("Line(1) = %q, want 'four'", m.Line(1)) + } + }) +} + +// ============================================================================= +// c with Jump Motions Tests +// ============================================================================= + +func TestChangeWithJumpMotion(t *testing.T) { + t.Run("cG changes from cursor to end of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "G") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // All lines should be replaced with one empty line + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("cgg changes from cursor to start of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 2, Col: 0}), + ) + sendKeys(tm, "c", "g", "g") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // All lines should be replaced with one empty line + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("cG from middle changes to end", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three", "line four"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "c", "G") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have line one + empty + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + if m.Line(0) != "line one" { + t.Errorf("Line(0) = %q, want 'line one'", m.Line(0)) + } + if m.Line(1) != "" { + t.Errorf("Line(1) = %q, want ''", m.Line(1)) + } + }) +} + +// ============================================================================= +// C (Change to end of line) Tests +// ============================================================================= + +func TestChangeToEndOfLine(t *testing.T) { + t.Run("C changes from cursor to end of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), + ) + sendKeys(tm, "C") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "hello " { + t.Errorf("Line(0) = %q, want 'hello '", m.Line(0)) + } + }) + + t.Run("C at start of line clears entire line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "C") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("C at end of line enters insert mode", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello"}), + WithCursorPos(action.Position{Line: 0, Col: 4}), + ) + sendKeys(tm, "C") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should delete last char + if m.Line(0) != "hell" { + t.Errorf("Line(0) = %q, want 'hell'", m.Line(0)) + } + }) + + t.Run("C puts deleted text in register", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), + ) + sendKeys(tm, "C") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 1 || reg.Content[0] != "world" { + t.Errorf("register content = %q, want 'world'", reg.Content) + } + }) +} + +// ============================================================================= +// s (Substitute character) Tests +// ============================================================================= + +func TestSubstituteCharacter(t *testing.T) { + t.Run("s deletes character and enters insert mode", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "s") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "ello" { + t.Errorf("Line(0) = %q, want 'ello'", m.Line(0)) + } + }) + + t.Run("2s deletes two characters", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "2", "s") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "llo" { + t.Errorf("Line(0) = %q, want 'llo'", m.Line(0)) + } + }) + + t.Run("s at end of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello"}), + WithCursorPos(action.Position{Line: 0, Col: 4}), + ) + sendKeys(tm, "s") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "hell" { + t.Errorf("Line(0) = %q, want 'hell'", m.Line(0)) + } + }) +} + +// ============================================================================= +// S (Substitute line) Tests +// ============================================================================= + +func TestSubstituteLine(t *testing.T) { + t.Run("S clears line and enters insert mode", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 5}), + ) + sendKeys(tm, "S") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("S preserves other lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "S") + + m := getFinalModel(t, tm) + if m.Line(0) != "line one" { + t.Errorf("Line(0) = %q, want 'line one'", m.Line(0)) + } + if m.Line(1) != "" { + t.Errorf("Line(1) = %q, want ''", m.Line(1)) + } + if m.Line(2) != "line three" { + t.Errorf("Line(2) = %q, want 'line three'", m.Line(2)) + } + }) + + t.Run("2S substitutes two lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"one", "two", "three", "four"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "2", "S") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have empty + three + four + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + if m.Line(1) != "three" { + t.Errorf("Line(1) = %q, want 'three'", m.Line(1)) + } + }) +} + +// ============================================================================= +// Visual Mode Change Tests +// ============================================================================= + +func TestVisualModeChange(t *testing.T) { + t.Run("vc changes single character", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "v", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "ello world" { + t.Errorf("Line(0) = %q, want 'ello world'", m.Line(0)) + } + }) + + t.Run("v with motion then c changes selection", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "v", "e", "c") // select "hello" + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != " world" { + t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) + } + }) + + t.Run("v$c changes to end of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), + ) + sendKeys(tm, "v", "$", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "hello " { + t.Errorf("Line(0) = %q, want 'hello '", m.Line(0)) + } + }) + + t.Run("visual selection spanning lines then c", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 0, Col: 5}), + ) + sendKeys(tm, "v", "j", "l", "l", "l", "l", "c") // select across lines + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should merge lines with selection removed + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + }) + + t.Run("visual change puts deleted text in register", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "v", "l", "l", "l", "l", "c") // select "hello" + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 1 || reg.Content[0] != "hello" { + t.Errorf("register content = %q, want 'hello'", reg.Content) + } + }) +} + +func TestVisualLineModeChange(t *testing.T) { + t.Run("Vc changes line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "V", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(1) != "" { + t.Errorf("Line(1) = %q, want ''", m.Line(1)) + } + }) + + t.Run("Vjc changes multiple lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three", "line four"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "V", "j", "c") // select lines two and three + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // Should have: line one, empty, line four + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "line one" { + t.Errorf("Line(0) = %q, want 'line one'", m.Line(0)) + } + if m.Line(1) != "" { + t.Errorf("Line(1) = %q, want ''", m.Line(1)) + } + if m.Line(2) != "line four" { + t.Errorf("Line(2) = %q, want 'line four'", m.Line(2)) + } + }) + + t.Run("visual line change puts lines in register", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "V", "j", "c") // select first two lines + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 2 { + t.Errorf("register content length = %d, want 2", len(reg.Content)) + } + }) +} + +// ============================================================================= +// Edge Cases +// ============================================================================= + +func TestChangeEdgeCases(t *testing.T) { + t.Run("cc on empty line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"", "world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "c") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("cw on last word of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), + ) + sendKeys(tm, "c", "w") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + // cw on last word should change to end of line + if m.Line(0) != "hello " { + t.Errorf("Line(0) = %q, want 'hello '", m.Line(0)) + } + }) + + t.Run("c$ on empty line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{""}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "$") + + m := getFinalModel(t, tm) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %q, want ''", m.Line(0)) + } + }) + + t.Run("cj at last line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello", "world"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "c", "j") + + m := getFinalModel(t, tm) + // cj at last line should just change current line (can't go down) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + }) + + t.Run("ck at first line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello", "world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "c", "k") + + m := getFinalModel(t, tm) + // ck at first line should just change current line (can't go up) + if m.Mode() != action.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + }) +} diff --git a/internal/input/handler.go b/internal/input/handler.go index 749911b..caa8c52 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -152,7 +152,10 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st mtype = action.Linewise } cmd := op.Operate(m, start, end, mtype) - m.SetMode(action.NormalMode) + // Only reset to normal mode if operator didn't enter insert mode + if m.Mode() != action.InsertMode { + m.SetMode(action.NormalMode) + } h.Reset() return cmd } diff --git a/internal/input/keymap.go b/internal/input/keymap.go index f4c2b89..cb9f598 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -55,6 +55,9 @@ func NewNormalKeymap() *Keymap { "V": action.EnterVisualLineMode{}, "ctrl+v": action.EnterVisualBlockMode{}, "D": action.DeleteToEndOfLine{Count: 1}, + "C": action.ChangeToEndOfLine{Count: 1}, + "s": action.SubstituteChar{Count: 1}, + "S": action.SubstituteLine{Count: 1}, "p": action.Paste{Count: 1}, "P": action.PasteBefore{Count: 1}, }, @@ -85,11 +88,7 @@ func NewVisualKeymap() *Keymap { "d": operator.DeleteOperator{}, "x": operator.DeleteOperator{}, "y": operator.YankOperator{}, - // "c": ChangeOp{}, - // "y": YankOp{}, - // "p": PasteOp{}, - // "s": SubstitueOp{}, - // "~": SwapCaseOp{}, + "c": operator.ChangeOperator{}, }, actions: map[string]action.Action{ "p": action.VisualPaste{Count: 1}, diff --git a/internal/operator/change.go b/internal/operator/change.go new file mode 100644 index 0000000..8a8c69f --- /dev/null +++ b/internal/operator/change.go @@ -0,0 +1,173 @@ +package operator + +import ( + "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" +) + +// Implements Operator (c) +type ChangeOperator struct{} + +func (o ChangeOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd { + switch m.Mode() { + case action.VisualMode: + changeCharSelection(m, start, end) + case action.VisualLineMode: + changeLineSelection(m, start, end) + case action.VisualBlockMode: + changeBlockSelection(m, start, end) + case action.NormalMode: + changeNormalMode(m, start, end, mtype) + } + return nil +} + +func changeNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) { + // Normalize so start is always before or equal to end + if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { + start, end = end, start + } + + // Linewise motions (j, k, G, gg) always operate on whole lines + if mtype == action.Linewise { + changeLineSelection(m, start, end) + return + } + + // Charwise motions on same line + if start.Line == end.Line { + // No movement = nothing to change + if start.Col == end.Col && mtype == action.CharwiseExclusive { + m.SetMode(action.InsertMode) + return + } + // Exclusive motion: end position not included, so back up one + if mtype == action.CharwiseExclusive { + end.Col-- + } + if end.Col >= start.Col { + changeCharSelection(m, start, end) + } else { + m.SetMode(action.InsertMode) + } + return + } + + // Charwise motion spanning multiple lines + changeCharSelection(m, start, end) +} + +func changeCharSelection(m action.Model, start, end action.Position) { + var deletedText string + + if start.Line == end.Line { + line := m.Line(start.Line) + endCol := min(end.Col+1, len(line)) + deletedText = line[start.Col:endCol] + m.SetLine(start.Line, line[:start.Col]+line[endCol:]) + } else { + startLine := m.Line(start.Line) + endLine := m.Line(end.Line) + + // Extract deleted text + deletedText = startLine[start.Col:] + "\n" + for y := start.Line + 1; y < end.Line; y++ { + deletedText += m.Line(y) + "\n" + } + endCol := min(end.Col+1, len(endLine)) + deletedText += endLine[:endCol] + + prefix := startLine[:start.Col] + suffix := "" + if end.Col+1 < len(endLine) { + suffix = endLine[end.Col+1:] + } + + // Delete from end back to start to preserve indices + for i := end.Line; i >= start.Line; i-- { + m.DeleteLine(i) + } + m.InsertLine(start.Line, prefix+suffix) + } + + m.SetCursorY(start.Line) + m.SetCursorX(start.Col) + m.ClampCursorX() + m.SetMode(action.InsertMode) + + // Update register with deleted text + m.UpdateDefaultRegister(action.CharwiseRegister, []string{deletedText}) +} + +func changeLineSelection(m action.Model, start, end action.Position) { + var lines []string + + for i := end.Line; i >= start.Line; i-- { + lines = append([]string{m.Line(i)}, lines...) + m.DeleteLine(i) + } + + // Insert an empty line for editing + insertY := min(start.Line, m.LineCount()) + m.InsertLine(insertY, "") + + m.SetCursorY(insertY) + m.SetCursorX(0) + m.SetMode(action.InsertMode) + + // Update registers + m.UpdateDefaultRegister(action.LinewiseRegister, lines) +} + +func changeBlockSelection(m action.Model, start, end action.Position) { + startCol := min(start.Col, end.Col) + endCol := max(start.Col, end.Col) + + for y := start.Line; y <= end.Line; y++ { + line := m.Line(y) + if startCol >= len(line) { + continue + } + ec := min(endCol+1, len(line)) + m.SetLine(y, line[:startCol]+line[ec:]) + } + + m.SetCursorY(start.Line) + m.SetCursorX(startCol) + m.ClampCursorX() + m.SetMode(action.InsertMode) +} + +// Verify ChangeOperator implements DoublePresser +var _ action.DoublePresser = ChangeOperator{} + +// Double press handles cc - change the entire line +func (o ChangeOperator) DoublePress(m action.Model, count int) tea.Cmd { + startY := m.CursorY() + + // If we have a higher value than lines remaining, we can only run so many times + opCount := min(count, m.LineCount()-startY) + + var lines []string + + // Collect lines to delete (always delete at startY since lines shift up) + for range opCount { + lines = append(lines, m.Line(startY)) + m.DeleteLine(startY) + } + + // Put deleted lines in register + m.UpdateDefaultRegister(action.LinewiseRegister, lines) + + // Insert empty line at the original position for editing + // If we deleted everything, startY might be past end, so clamp it + insertY := min(startY, m.LineCount()) + m.InsertLine(insertY, "") + + // Position cursor on the new empty line + m.SetCursorY(insertY) + m.SetCursorX(0) + m.SetMode(action.InsertMode) + + return nil +} diff --git a/internal/operator/delete.go b/internal/operator/delete.go index c7ae676..1b120e1 100644 --- a/internal/operator/delete.go +++ b/internal/operator/delete.go @@ -52,7 +52,6 @@ func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd { // Put her in the register! m.UpdateDefaultRegister(action.LinewiseRegister, lines) - // m.SetRegister('"', action.LinewiseRegister, lines) return nil }