feat: implemented 'X', tested

X and x are the same in visual mode I think
This commit is contained in:
Hayden Hargreaves 2026-03-19 17:43:12 -07:00
parent a01369f407
commit 5c629496c6
4 changed files with 299 additions and 1 deletions

View File

@ -104,7 +104,7 @@
### Delete Actions ### Delete Actions
- [x] `x` - Delete character under cursor - [x] `x` - Delete character under cursor
- [x] `D` - Delete to end of line - [x] `D` - Delete to end of line
- [ ] `X` - Delete character before cursor - [x] `X` - Delete character before cursor
- [ ] `J` - Join lines - [ ] `J` - Join lines
- [ ] `gJ` - Join lines without space - [ ] `gJ` - Join lines without space

View File

@ -27,6 +27,35 @@ func (a DeleteChar) WithCount(n int) Action {
return DeleteChar{Count: n} 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 // DeleteToEndOfLine implements Action (D) - deletes from cursor to end of line
// and optionally Count-1 additional lines below. // and optionally Count-1 additional lines below.
type DeleteToEndOfLine struct { type DeleteToEndOfLine struct {

View File

@ -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) { func TestDeleteToEndOfLine(t *testing.T) {
t.Run("test 'D' deletes to end of line", func(t *testing.T) { t.Run("test 'D' deletes to end of line", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}

View File

@ -55,6 +55,7 @@ func NewNormalKeymap() *Keymap {
"o": action.OpenLineBelow{}, "o": action.OpenLineBelow{},
"O": action.OpenLineAbove{}, "O": action.OpenLineAbove{},
"x": action.DeleteChar{Count: 1}, "x": action.DeleteChar{Count: 1},
"X": action.DeletePrevChar{Count: 1},
":": action.EnterComandMode{}, ":": action.EnterComandMode{},
"v": action.EnterVisualMode{}, "v": action.EnterVisualMode{},
"V": action.EnterVisualLineMode{}, "V": action.EnterVisualLineMode{},