diff --git a/internal/action/action.go b/internal/action/action.go index 758eb15..59720c9 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -40,6 +40,7 @@ type Model interface { // Mode Mode() Mode SetMode(mode Mode) + IsVisualMode() bool // Insert recording (for count replay) SetInsertRecording(count int, action Action) @@ -64,7 +65,7 @@ type Motion interface { type Operator interface { Operate(m Model, start, end Position) tea.Cmd // DoublePress handles dd, yy, cc (line-wise) - DoublePress(m Model) tea.Cmd + DoublePress(m Model, count int) tea.Cmd } // Repeatable actions track count diff --git a/internal/editor/integration_operator_delete_test.go b/internal/editor/integration_operator_delete_test.go new file mode 100644 index 0000000..a90e27a --- /dev/null +++ b/internal/editor/integration_operator_delete_test.go @@ -0,0 +1,199 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +func TestDeleteLine(t *testing.T) { + t.Run("test 'dd' deletes first line", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want '1'", m.LineCount()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want '0'", m.CursorY()) + } + if m.Line(0) != "world" { + t.Errorf("Line(0) = %s, want 'world'", m.Line(0)) + } + }) + + t.Run("test 'dd' deletes middle line", func(t *testing.T) { + lines := []string{"hello", "world", "testing"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want '2'", m.LineCount()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want '1'", m.CursorY()) + } + if m.Line(0) != "hello" { + t.Errorf("Line(0) = %s, want 'hello'", m.Line(0)) + } + if m.Line(1) != "testing" { + t.Errorf("Line(1) = %s, want 'testing'", m.Line(1)) + } + }) + + t.Run("test 'dd' deletes last line", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want '1'", m.LineCount()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want '0'", m.CursorY()) + } + if m.Line(0) != "hello" { + t.Errorf("Line(0) = %s, want 'hello'", m.Line(0)) + } + }) + + t.Run("test 'dd' deletes line and preserves column", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want '1'", m.LineCount()) + } + if m.CursorX() != 3 { + t.Errorf("CursorX() = %d, want '3'", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want '0'", m.CursorY()) + } + if m.Line(0) != "world" { + t.Errorf("Line(0) = %s, want 'world'", m.Line(0)) + } + }) + + t.Run("test '3dd' deletes three lines", func(t *testing.T) { + lines := []string{"hello", "world", "testing", "line", "another line"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "3", "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want '2'", m.LineCount()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want '1'", m.CursorY()) + } + if m.Line(0) != "hello" { + t.Errorf("Line(0) = %s, want 'hello'", m.Line(0)) + } + + if m.Line(1) != "another line" { + t.Errorf("Line(1) = %s, want 'another line'", m.Line(1)) + } + }) + + t.Run("test 'dd' deletes only line and preserves content", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want '1'", m.LineCount()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want '0'", m.CursorY()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %s, want ''", m.Line(0)) + } + }) + + t.Run("test 'dd' with no lines preserves content", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want '1'", m.LineCount()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want '0'", m.CursorY()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %s, want ''", m.Line(0)) + } + }) + + t.Run("test 'dd' clamps cursor when next line is shorter", func(t *testing.T) { + lines := []string{"hello world", "hi"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 7, Line: 0}) + sendKeys(tm, "d", "d") + + m := getFinalModel(t, tm) + if m.CursorX() != 2 { + t.Errorf("CursorX() = %d, want 2", m.CursorX()) + } + }) + + t.Run("test '3dd' count exceeds remaining lines", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "3", "d", "d") + + 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 '3dd' starting near end of file", func(t *testing.T) { + lines := []string{"hello", "world", "testing"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "3", "d", "d") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.Line(0) != "hello" { + t.Errorf("Line(0) = %q, want \"hello\"", m.Line(0)) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + +} diff --git a/internal/editor/model.go b/internal/editor/model.go index bf82272..fbb4bb7 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -141,6 +141,12 @@ func (m *Model) SetMode(mode action.Mode) { m.mode = mode } +func (m *Model) IsVisualMode() bool { + return m.mode == action.VisualMode || + m.mode == action.VisualLineMode || + m.mode == action.VisualBlockMode +} + func (m *Model) SetInsertRecording(count int, act action.Action) { m.insertCount = count m.insertKeys = []string{} diff --git a/internal/editor/view.go b/internal/editor/view.go index a4ba3f7..fd4932e 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -56,12 +56,6 @@ func posIsAnchor(m Model, col, line int) bool { return col == ax && line == ay } -func isVisualMode(m action.Mode) bool { - return m == action.VisualMode || - m == action.VisualLineMode || - m == action.VisualBlockMode -} - func (m Model) View() string { var view strings.Builder @@ -100,15 +94,15 @@ func (m Model) View() string { view.WriteString(m.cursorStyle().Render(" ")) } } else if x < len(runes) { - if isVisualMode(m.Mode()) && posIsAnchor(m, x, y) { + if m.IsVisualMode() && posIsAnchor(m, x, y) { view.WriteString(m.visualAnchorStyle().Render(string(runes[x]))) - } else if isVisualMode(m.Mode()) && posInsideSelection(m, x, y) { + } else if m.IsVisualMode() && posInsideSelection(m, x, y) { view.WriteString(m.visualHighlightStyle().Render(string(runes[x]))) } else { view.WriteRune(runes[x]) } // To highlight blank lines when in visual mode - } else if isVisualMode(m.Mode()) && posInsideSelection(m, x, y) { + } else if m.IsVisualMode() && posInsideSelection(m, x, y) { view.WriteString(m.visualHighlightStyle().Render(" ")) } } @@ -141,7 +135,7 @@ func (m Model) View() string { var bar string if m.Mode() == action.CommandMode { bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) :%s ", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.command) - } else if isVisualMode(m.Mode()) { + } else if m.IsVisualMode() { bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) <%d, %d> ", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.AnchorX(), m.AnchorY()) } else { bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) | %s | %+v | %d", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.input.Pending(), m.insertKeys, m.insertCount) diff --git a/internal/input/handler.go b/internal/input/handler.go index d71db6e..2d25992 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -128,7 +128,17 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st return cmd case "operator": - h.operator = binding.(action.Operator) + op := binding.(action.Operator) + // In visual mode, the selection is already defined — operate immediately + if m.IsVisualMode() { + start, end := normalizeVisualSelection(m) + cmd := op.Operate(m, start, end) + m.SetMode(action.NormalMode) + h.Reset() + return cmd + } + // In normal mode, wait for a motion to define the range + h.operator = op h.operatorKey = key h.state = StateOperatorPending return nil @@ -152,7 +162,7 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, // dd, yy, cc - same operator key pressed twice if kind == "operator" && key == h.operatorKey { - cmd := h.operator.DoublePress(m) + cmd := h.operator.DoublePress(m, count) h.Reset() return cmd } @@ -233,3 +243,12 @@ func (h *Handler) Reset() { func (h *Handler) Pending() string { return h.buffer } + +func normalizeVisualSelection(m action.Model) (action.Position, action.Position) { + a := action.Position{Line: m.AnchorY(), Col: m.AnchorX()} + c := action.Position{Line: m.CursorY(), Col: m.CursorX()} + if a.Line < c.Line || (a.Line == c.Line && a.Col <= c.Col) { + return a, c + } + return c, a +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 5835a74..fe048c3 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -3,6 +3,7 @@ package input import ( "git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/motion" + "git.gophernest.net/azpect/TextEditor/internal/operator" ) type Keymap struct { @@ -28,7 +29,7 @@ func NewNormalKeymap() *Keymap { "b": motion.MoveBackwardWord{Count: 1}, }, operators: map[string]action.Operator{ - // "d": DeleteOp{}, + "d": operator.DeleteOperator{}, // "c": ChangeOp{}, // "y": YankOp{}, // "p": PasteOp{}, @@ -69,12 +70,12 @@ func NewVisualKeymap() *Keymap { "b": motion.MoveBackwardWord{Count: 1}, }, operators: map[string]action.Operator{ - // "d": DeleteOp{}, + "d": operator.DeleteOperator{}, + "x": operator.DeleteOperator{}, // "c": ChangeOp{}, // "y": YankOp{}, // "p": PasteOp{}, // "s": SubstitueOp{}, - // "x": DeleteOp{}, // "~": SwapCaseOp{}, }, actions: map[string]action.Action{ diff --git a/internal/operator/delete.go b/internal/operator/delete.go new file mode 100644 index 0000000..1d297ae --- /dev/null +++ b/internal/operator/delete.go @@ -0,0 +1,108 @@ +package operator + +import ( + "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" +) + +// Implements Operator (x) +type DeleteOperator struct{} + +func (o DeleteOperator) Operate(m action.Model, start, end action.Position) tea.Cmd { + switch m.Mode() { + case action.VisualMode: + deleteCharSelection(m, start, end) + case action.VisualLineMode: + deleteLineSelection(m, start, end) + case action.VisualBlockMode: + deleteBlockSelection(m, start, end) + } + return nil +} + +// Double press handles dd - delete the entire line +func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd { + // If we have a higher value than lines remaining, we can only run so many times + opCount := min(count, m.LineCount()-m.CursorY()) + + for range opCount { + y := m.CursorY() + m.DeleteLine(y) + + if m.LineCount() == 0 { + m.InsertLine(0, "") + } + + if y >= m.LineCount() { + y = m.LineCount() - 1 + } + + m.SetCursorY(y) + m.ClampCursorX() + } + + return nil +} + +func deleteCharSelection(m action.Model, start, end action.Position) { + if start.Line == end.Line { + line := m.Line(start.Line) + endCol := min(end.Col+1, len(line)) + m.SetLine(start.Line, line[:start.Col]+line[endCol:]) + } else { + startLine := m.Line(start.Line) + endLine := m.Line(end.Line) + + 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() +} + +func deleteLineSelection(m action.Model, start, end action.Position) { + for i := end.Line; i >= start.Line; i-- { + m.DeleteLine(i) + } + + if m.LineCount() == 0 { + m.InsertLine(0, "") + } + + y := start.Line + if y >= m.LineCount() { + y = m.LineCount() - 1 + } + + m.SetCursorY(y) + m.ClampCursorX() +} + +func deleteBlockSelection(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() +}