diff --git a/cmd/gim/main.go b/cmd/gim/main.go index 8b427cb..770f00e 100644 --- a/cmd/gim/main.go +++ b/cmd/gim/main.go @@ -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() } diff --git a/internal/action/action.go b/internal/action/action.go index f9d4cc7..5a58665 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -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 } diff --git a/internal/editor/integration_operator_delete_test.go b/internal/editor/integration_operator_delete_test.go index a90e27a..58f02ae 100644 --- a/internal/editor/integration_operator_delete_test.go +++ b/internal/editor/integration_operator_delete_test.go @@ -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")) + } + }) +} diff --git a/internal/input/handler.go b/internal/input/handler.go index 669d9f1..d223e93 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -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 } diff --git a/internal/motion/basic.go b/internal/motion/basic.go index d613736..00b1297 100644 --- a/internal/motion/basic.go +++ b/internal/motion/basic.go @@ -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} } diff --git a/internal/motion/jump.go b/internal/motion/jump.go index da40276..b0721a1 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -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 } diff --git a/internal/motion/word.go b/internal/motion/word.go index 1be4021..762e97f 100644 --- a/internal/motion/word.go +++ b/internal/motion/word.go @@ -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} } diff --git a/internal/operator/delete.go b/internal/operator/delete.go index 1d297ae..dd3b4fd 100644 --- a/internal/operator/delete.go +++ b/internal/operator/delete.go @@ -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)