diff --git a/FEATURES.md b/FEATURES.md index 9c74fd0..b9ec918 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -104,7 +104,7 @@ ### Delete Actions - [x] `x` - Delete character under cursor - [x] `D` - Delete to end of line -- [ ] `X` - Delete character before cursor +- [x] `X` - Delete character before cursor - [ ] `J` - Join lines - [ ] `gJ` - Join lines without space diff --git a/internal/action/delete.go b/internal/action/delete.go index b637aea..8fff5a7 100644 --- a/internal/action/delete.go +++ b/internal/action/delete.go @@ -27,6 +27,35 @@ func (a DeleteChar) WithCount(n int) Action { return DeleteChar{Count: n} } +// DeletePrevChar implements Action (x) +type DeletePrevChar struct { + Count int +} + +// DeletePrevChar.Execute: Deletes Count characters before the cursor position (x key). +func (a DeletePrevChar) Execute(m Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + pos := win.Cursor.Col + line := buf.Lines[win.Cursor.Line] + for i := 0; i < a.Count && pos <= len(line); i++ { + if pos > 0 { + line = line[:pos-1] + line[pos:] + buf.SetLine(win.Cursor.Line, line) + pos-- + win.SetCursorCol(pos) + } + } + + return nil +} + +// DeletePrevChar.WithCount: Returns a new DeletePrevChar with the given count. +func (a DeletePrevChar) WithCount(n int) Action { + return DeletePrevChar{Count: n} +} + // DeleteToEndOfLine implements Action (D) - deletes from cursor to end of line // and optionally Count-1 additional lines below. type DeleteToEndOfLine struct { diff --git a/internal/editor/integration_delete_test.go b/internal/editor/integration_delete_test.go index 77bedd0..fa4835d 100644 --- a/internal/editor/integration_delete_test.go +++ b/internal/editor/integration_delete_test.go @@ -194,6 +194,274 @@ func TestDeleteCharEdgeCases(t *testing.T) { }) } +func TestDeleteCharBackward(t *testing.T) { + t.Run("test 'X' deletes character before cursor", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "ello" { + t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' in middle of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "helo" { + t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' at end of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "helo" { + t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'XX' deletes two characters backward", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "X", "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "llo" { + t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestDeleteCharBackwardWithCount(t *testing.T) { + t.Run("test '3X' deletes three characters backward", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "3", "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "lo" { + t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test '10X' with overflow", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) + sendKeys(tm, "1", "0", "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "o" { + t.Errorf("lines[0] = %q, want 'o'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test '2X' from middle", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "2", "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hlo" { + t.Errorf("lines[0] = %q, want 'hlo'", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestDeleteCharBackwardEdgeCases(t *testing.T) { + t.Run("test 'X' at start of line does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'X' on empty line does nothing", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) + } + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'X' on single character line from end", func(t *testing.T) { + lines := []string{"a"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "a" { + t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' at second char deletes first", func(t *testing.T) { + lines := []string{"ab"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "b" { + t.Errorf("Line(0) = %q, want 'b'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' with whitespace", func(t *testing.T) { + lines := []string{"a b c"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "ab c" { + t.Errorf("Line(0) = %q, want 'ab c'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' preserves other lines", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount()) + } + if m.ActiveBuffer().Lines[1] != "world" { + t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1]) + } + }) + + t.Run("test 'X' multiple times deletes multiple chars backward", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "X", "X", "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "lo" { + t.Errorf("Line(0) = %q, want 'lo'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' on line with tabs", func(t *testing.T) { + lines := []string{"a\tb"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "ab" { + t.Errorf("Line(0) = %q, want 'ab'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test '5X' with only 3 chars available before cursor", func(t *testing.T) { + lines := []string{"abc"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "5", "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "c" { + t.Errorf("Line(0) = %q, want 'c'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' in middle preserves surrounding chars", func(t *testing.T) { + lines := []string{"abcde"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "abde" { + t.Errorf("Line(0) = %q, want 'abde'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' cursor position after delete", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + // Cursor should move back one position after deleting char before it + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test '3X' cursor position after delete", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) + sendKeys(tm, "3", "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "ho" { + t.Errorf("Line(0) = %q, want 'ho'", m.ActiveBuffer().Lines[0]) + } + // Cursor should be at position 1 after deleting 3 chars backward from position 4 + if m.ActiveWindow().Cursor.Col != 1 { + t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'X' at start of second line does nothing", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello" { + t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0]) + } + if m.ActiveBuffer().Lines[1] != "world" { + t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1]) + } + }) + + t.Run("test 'X' on whitespace-only line", func(t *testing.T) { + lines := []string{" "} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != " " { + t.Errorf("Line(0) = %q, want ' '", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'X' from end of line deletes last char", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) + sendKeys(tm, "X") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "helo" { + t.Errorf("Line(0) = %q, want 'helo'", m.ActiveBuffer().Lines[0]) + } + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) +} + func TestDeleteToEndOfLine(t *testing.T) { t.Run("test 'D' deletes to end of line", func(t *testing.T) { lines := []string{"hello world"} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 28510b3..ca00f41 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -55,6 +55,7 @@ func NewNormalKeymap() *Keymap { "o": action.OpenLineBelow{}, "O": action.OpenLineAbove{}, "x": action.DeleteChar{Count: 1}, + "X": action.DeletePrevChar{Count: 1}, ":": action.EnterComandMode{}, "v": action.EnterVisualMode{}, "V": action.EnterVisualLineMode{},