feat: huge implementation! 'd' op working well, tested good

Not totally complete WRT tests, but lots of progress. These interfaces
make everything easy.
This commit is contained in:
Hayden Hargreaves 2026-02-12 23:34:07 -07:00
parent db70ca39f1
commit 07589e3897
8 changed files with 593 additions and 20 deletions

View File

@ -1,16 +1,26 @@
package main
import (
"fmt"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/editor"
tea "github.com/charmbracelet/bubbletea"
)
func generateLines(n int) []string {
lines := make([]string, n)
for i := range n {
lines[i] = fmt.Sprintf("line %d", i+1)
}
return lines
}
func main() {
lines := []string{"Hello world testing main.go", "line 2", "line 3", "line 4", "line 5"}
// lines := []string{"Hello world testing main.go", "line 2", "line 3", "line 4", "line 5"}
tea.NewProgram(
editor.NewModel(lines, action.Position{Line: 0, Col: 0}),
editor.NewModel(generateLines(32), action.Position{Line: 0, Col: 0}),
tea.WithAltScreen(),
).Run()
}

View File

@ -66,6 +66,14 @@ type Position struct {
Line, Col int
}
// MotionType indicates how a motion operates
type MotionType int
const (
Charwise MotionType = iota // h, l, w, e, f, t - operates on characters
Linewise // j, k, G, gg, {, } - operates on whole lines
)
// Action is the base interface - anything executable
type Action interface {
Execute(m Model) tea.Cmd
@ -74,11 +82,12 @@ type Action interface {
// Motion moves the cursor and returns the range covered
type Motion interface {
Action
Type() MotionType
}
// Operator acts on a range (delete, yank, change)
type Operator interface {
Operate(m Model, start, end Position) tea.Cmd
Operate(m Model, start, end Position, mtype MotionType) tea.Cmd
// DoublePress handles dd, yy, cc (line-wise)
DoublePress(m Model, count int) tea.Cmd
}

View File

@ -197,3 +197,496 @@ func TestDeleteLine(t *testing.T) {
})
}
func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
t.Run("test 'dl' deletes current character from start", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "l")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want '0'", m.CursorX())
}
if m.Line(0) != "ello" {
t.Errorf("Line(0) = %s, want 'ello'", m.Line(0))
}
})
t.Run("test 'dl' deletes current character from middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "d", "l")
m := getFinalModel(t, tm)
if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want '2'", m.CursorX())
}
if m.Line(0) != "helo" {
t.Errorf("Line(0) = %s, want 'helo'", m.Line(0))
}
})
t.Run("test 'dl' deletes current character from end", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "d", "l")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want '4'", m.CursorX())
}
if m.Line(0) != "hell" {
t.Errorf("Line(0) = %s, want 'hell'", m.Line(0))
}
})
t.Run("test 'dh' does nothing on first char", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "h")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want '0'", m.CursorX())
}
if m.Line(0) != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.Line(0))
}
})
t.Run("test 'dh' deletes character to the left from middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "d", "h")
m := getFinalModel(t, tm)
// dh deletes char to the left (position 1, 'e'), cursor moves to that position
if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX())
}
if m.Line(0) != "hllo" {
t.Errorf("Line(0) = %q, want 'hllo'", m.Line(0))
}
})
t.Run("test 'dh' deletes character to the left from end", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "d", "h")
m := getFinalModel(t, tm)
// dh deletes char to the left (position 3, 'l'), cursor moves to that position
if m.CursorX() != 3 {
t.Errorf("CursorX() = %d, want 3", m.CursorX())
}
if m.Line(0) != "helo" {
t.Errorf("Line(0) = %q, want 'helo'", m.Line(0))
}
})
t.Run("test '2dl' deletes two characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "2", "d", "l")
m := getFinalModel(t, tm)
if m.Line(0) != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.Line(0))
}
})
t.Run("test 'd2l' deletes two characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "2", "l")
m := getFinalModel(t, tm)
if m.Line(0) != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.Line(0))
}
})
t.Run("test '2dh' deletes two characters backwards", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "2", "d", "h")
m := getFinalModel(t, tm)
if m.Line(0) != "heo" {
t.Errorf("Line(0) = %q, want 'heo'", m.Line(0))
}
})
}
func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
t.Run("test 'dj' deletes current and next line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "d", "j")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 4" {
t.Errorf("Line(1) = %q, want 'line 4'", m.Line(1))
}
if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY())
}
})
t.Run("test 'dj' from first line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "j")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.Line(0))
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
// Note: In vim, dj from last line does nothing. Our implementation treats all
// linewise motions consistently - they operate on at least the current line.
t.Run("test 'dj' from last line deletes current line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "d", "j")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.Line(1))
}
})
t.Run("test 'd2j' deletes current and next two lines", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "d", "2", "j")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.Line(1))
}
})
t.Run("test '2dj' deletes current and next two lines", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "2", "d", "j")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.Line(1))
}
})
t.Run("test 'dk' deletes current and previous line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "d", "k")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 4" {
t.Errorf("Line(1) = %q, want 'line 4'", m.Line(1))
}
if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY())
}
})
// Note: In vim, dk from first line does nothing. Our implementation treats all
// linewise motions consistently - they operate on at least the current line.
t.Run("test 'dk' from first line deletes current line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "k")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.Line(0))
}
if m.Line(1) != "line 3" {
t.Errorf("Line(1) = %q, want 'line 3'", m.Line(1))
}
})
t.Run("test 'dk' from second line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "d", "k")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.Line(0))
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'd2k' deletes current and previous two lines", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 3})
sendKeys(tm, "d", "2", "k")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.Line(1))
}
})
t.Run("test '2dk' deletes current and previous two lines", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 3})
sendKeys(tm, "2", "d", "k")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.Line(1))
}
})
t.Run("test 'dj' with count exceeding remaining lines", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "d", "5", "j")
m := getFinalModel(t, tm)
// Should delete from line 1 to end
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
})
t.Run("test 'dk' with count exceeding previous lines", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "d", "5", "k")
m := getFinalModel(t, tm)
// Should delete from line 0 to line 1
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.Line(0))
}
})
}
func TestDeleteOperatorWithJumpMotion(t *testing.T) {
t.Run("test 'dG' deletes from cursor to end of file", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "d", "G")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.Line(1))
}
})
t.Run("test 'dG' from first line deletes everything", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "G")
m := getFinalModel(t, tm)
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("test 'dG' from last line deletes only last line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "d", "G")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
}
if m.Line(1) != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.Line(1))
}
})
t.Run("test 'dG' positions cursor correctly", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 1})
sendKeys(tm, "d", "G")
m := getFinalModel(t, tm)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'dgg' deletes from cursor to start of file", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "d", "g", "g")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 4" {
t.Errorf("Line(0) = %q, want 'line 4'", m.Line(0))
}
if m.Line(1) != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.Line(1))
}
})
t.Run("test 'dgg' from last line deletes everything", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "d", "g", "g")
m := getFinalModel(t, tm)
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("test 'dgg' from first line deletes only first line", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "g", "g")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.Line(0))
}
if m.Line(1) != "line 3" {
t.Errorf("Line(1) = %q, want 'line 3'", m.Line(1))
}
})
t.Run("test 'dgg' positions cursor at start", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3", "line 4"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 2})
sendKeys(tm, "d", "g", "g")
m := getFinalModel(t, tm)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'dG' on single line file deletes the line", func(t *testing.T) {
lines := []string{"only line"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "G")
m := getFinalModel(t, tm)
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("test 'dgg' on single line file deletes the line", func(t *testing.T) {
lines := []string{"only line"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "g", "g")
m := getFinalModel(t, tm)
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("test 'dG' clamps cursor when file shrinks", func(t *testing.T) {
lines := []string{"short", "this is a longer line", "line 3", "line 4"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "d", "G")
m := getFinalModel(t, tm)
// Cursor was at col 10, but "short" only has 5 chars
if m.CursorX() > len("short") {
t.Errorf("CursorX() = %d, should be clamped to line length %d", m.CursorX(), len("short"))
}
})
}

View File

@ -141,7 +141,12 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
// In visual mode, the selection is already defined — operate immediately
if m.IsVisualMode() {
start, end := normalizeVisualSelection(m)
cmd := op.Operate(m, start, end)
// Visual line mode is linewise, others are charwise
mtype := action.Charwise
if m.Mode() == action.VisualLineMode {
mtype = action.Linewise
}
cmd := op.Operate(m, start, end, mtype)
m.SetMode(action.NormalMode)
h.Reset()
return cmd
@ -182,12 +187,12 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
if r, ok := mot.(action.Repeatable); ok {
mot = r.WithCount(count).(action.Motion)
}
// Get range here
// Get range and motion type
pg := m.(PositionGetter)
start := pg.GetCursorPosition()
mot.Execute(m)
end := pg.GetCursorPosition()
cmd := h.operator.Operate(m, start, end)
cmd := h.operator.Operate(m, start, end, mot.Type())
h.Reset()
return cmd
}

View File

@ -5,7 +5,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// MoveDown implements Motion (j)
// MoveDown implements Motion (j) - linewise
type MoveDown struct {
Count int
}
@ -18,11 +18,13 @@ func (a MoveDown) Execute(m action.Model) tea.Cmd {
return nil
}
func (a MoveDown) Type() action.MotionType { return action.Linewise }
func (a MoveDown) WithCount(n int) action.Action {
return MoveDown{Count: n}
}
// MoveUp implements Motion (k)
// MoveUp implements Motion (k) - linewise
type MoveUp struct {
Count int
}
@ -35,11 +37,13 @@ func (a MoveUp) Execute(m action.Model) tea.Cmd {
return nil
}
func (a MoveUp) Type() action.MotionType { return action.Linewise }
func (a MoveUp) WithCount(n int) action.Action {
return MoveUp{Count: n}
}
// MoveLeft implements Motion (h)
// MoveLeft implements Motion (h) - charwise
type MoveLeft struct {
Count int
}
@ -52,11 +56,13 @@ func (a MoveLeft) Execute(m action.Model) tea.Cmd {
return nil
}
func (a MoveLeft) Type() action.MotionType { return action.Charwise }
func (a MoveLeft) WithCount(n int) action.Action {
return MoveLeft{Count: n}
}
// MoveRight implements Motion (l)
// MoveRight implements Motion (l) - charwise
type MoveRight struct {
Count int
}
@ -70,6 +76,8 @@ func (a MoveRight) Execute(m action.Model) tea.Cmd {
return nil
}
func (a MoveRight) Type() action.MotionType { return action.Charwise }
func (a MoveRight) WithCount(n int) action.Action {
return MoveRight{Count: n}
}

View File

@ -5,7 +5,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
// MoveToTop implements Motion (gg)
// MoveToTop implements Motion (gg) - linewise
type MoveToTop struct{}
func (a MoveToTop) Execute(m action.Model) tea.Cmd {
@ -14,7 +14,9 @@ func (a MoveToTop) Execute(m action.Model) tea.Cmd {
return nil
}
// MoveToBottom implements Motion (G)
func (a MoveToTop) Type() action.MotionType { return action.Linewise }
// MoveToBottom implements Motion (G) - linewise
type MoveToBottom struct{}
func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
@ -23,7 +25,9 @@ func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
return nil
}
// MoveToLineStart implements Motion (0)
func (a MoveToBottom) Type() action.MotionType { return action.Linewise }
// MoveToLineStart implements Motion (0) - charwise
type MoveToLineStart struct{}
func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
@ -32,7 +36,9 @@ func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
return nil
}
// MoveToLineEnd implements Motion ($)
func (a MoveToLineStart) Type() action.MotionType { return action.Charwise }
// MoveToLineEnd implements Motion ($) - charwise
type MoveToLineEnd struct{}
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
@ -41,7 +47,9 @@ func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
return nil
}
// MoveToLineContentStart implements Motion (_)
func (a MoveToLineEnd) Type() action.MotionType { return action.Charwise }
// MoveToLineContentStart implements Motion (_) - charwise
type MoveToLineContentStart struct{}
func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
@ -63,3 +71,5 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
m.SetCursorX(x)
return nil
}
func (a MoveToLineContentStart) Type() action.MotionType { return action.Charwise }

View File

@ -171,11 +171,11 @@ func prevWordStart(m action.Model, x, y int) (int, int) {
return x, y
}
// MoveForwardWord implements Motion (w) - charwise
type MoveForwardWord struct {
Count int
}
// Execute implements [action.Action].
func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
x := m.CursorX()
y := m.CursorY()
@ -187,15 +187,17 @@ func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
return nil
}
func (a MoveForwardWord) Type() action.MotionType { return action.Charwise }
func (a MoveForwardWord) WithCount(n int) action.Action {
return MoveForwardWord{Count: n}
}
// MoveForwardWordEnd implements Motion (e) - charwise
type MoveForwardWordEnd struct {
Count int
}
// Execute implements [action.Action].
func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
x := m.CursorX()
y := m.CursorY()
@ -207,15 +209,17 @@ func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
return nil
}
func (a MoveForwardWordEnd) Type() action.MotionType { return action.Charwise }
func (a MoveForwardWordEnd) WithCount(n int) action.Action {
return MoveForwardWordEnd{Count: n}
}
// MoveBackwardWord implements Motion (b) - charwise
type MoveBackwardWord struct {
Count int
}
// Execute implements [action.Action].
func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
x := m.CursorX()
y := m.CursorY()
@ -227,6 +231,8 @@ func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
return nil
}
func (a MoveBackwardWord) Type() action.MotionType { return action.Charwise }
func (a MoveBackwardWord) WithCount(n int) action.Action {
return MoveBackwardWord{Count: n}
}

View File

@ -5,10 +5,10 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
// Implements Operator (x)
// Implements Operator (d)
type DeleteOperator struct{}
func (o DeleteOperator) Operate(m action.Model, start, end action.Position) tea.Cmd {
func (o DeleteOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd {
switch m.Mode() {
case action.VisualMode:
deleteCharSelection(m, start, end)
@ -16,6 +16,8 @@ func (o DeleteOperator) Operate(m action.Model, start, end action.Position) tea.
deleteLineSelection(m, start, end)
case action.VisualBlockMode:
deleteBlockSelection(m, start, end)
case action.NormalMode:
deleteNormalMode(m, start, end, mtype)
}
return nil
}
@ -44,6 +46,36 @@ func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd {
return nil
}
func deleteNormalMode(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 {
deleteLineSelection(m, start, end)
return
}
// Charwise motions on same line
if start.Line == end.Line {
// No movement = nothing to delete
if start.Col == end.Col {
return
}
// Exclusive motion: delete [start.Col, end.Col)
end.Col--
if end.Col >= start.Col {
deleteCharSelection(m, start, end)
}
return
}
// Charwise motion spanning multiple lines (e.g., d/search)
deleteCharSelection(m, start, end)
}
func deleteCharSelection(m action.Model, start, end action.Position) {
if start.Line == end.Line {
line := m.Line(start.Line)