feat: implemented 'dd' and other 'd' for visual mode.

Tested 'dd' but not visual mode.
This commit is contained in:
Hayden Hargreaves 2026-02-11 17:56:06 -07:00
parent f0f3f95e7b
commit 77374ba447
7 changed files with 344 additions and 16 deletions

View File

@ -40,6 +40,7 @@ type Model interface {
// Mode // Mode
Mode() Mode Mode() Mode
SetMode(mode Mode) SetMode(mode Mode)
IsVisualMode() bool
// Insert recording (for count replay) // Insert recording (for count replay)
SetInsertRecording(count int, action Action) SetInsertRecording(count int, action Action)
@ -64,7 +65,7 @@ type Motion interface {
type Operator interface { type Operator interface {
Operate(m Model, start, end Position) tea.Cmd Operate(m Model, start, end Position) tea.Cmd
// DoublePress handles dd, yy, cc (line-wise) // DoublePress handles dd, yy, cc (line-wise)
DoublePress(m Model) tea.Cmd DoublePress(m Model, count int) tea.Cmd
} }
// Repeatable actions track count // Repeatable actions track count

View File

@ -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())
}
})
}

View File

@ -141,6 +141,12 @@ func (m *Model) SetMode(mode action.Mode) {
m.mode = 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) { func (m *Model) SetInsertRecording(count int, act action.Action) {
m.insertCount = count m.insertCount = count
m.insertKeys = []string{} m.insertKeys = []string{}

View File

@ -56,12 +56,6 @@ func posIsAnchor(m Model, col, line int) bool {
return col == ax && line == ay 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 { func (m Model) View() string {
var view strings.Builder var view strings.Builder
@ -100,15 +94,15 @@ func (m Model) View() string {
view.WriteString(m.cursorStyle().Render(" ")) view.WriteString(m.cursorStyle().Render(" "))
} }
} else if x < len(runes) { } 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]))) 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]))) view.WriteString(m.visualHighlightStyle().Render(string(runes[x])))
} else { } else {
view.WriteRune(runes[x]) view.WriteRune(runes[x])
} }
// To highlight blank lines when in visual mode // 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(" ")) view.WriteString(m.visualHighlightStyle().Render(" "))
} }
} }
@ -141,7 +135,7 @@ func (m Model) View() string {
var bar string var bar string
if m.Mode() == action.CommandMode { 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) 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()) 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 { } 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) 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)

View File

@ -128,7 +128,17 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
return cmd return cmd
case "operator": 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.operatorKey = key
h.state = StateOperatorPending h.state = StateOperatorPending
return nil 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 // dd, yy, cc - same operator key pressed twice
if kind == "operator" && key == h.operatorKey { if kind == "operator" && key == h.operatorKey {
cmd := h.operator.DoublePress(m) cmd := h.operator.DoublePress(m, count)
h.Reset() h.Reset()
return cmd return cmd
} }
@ -233,3 +243,12 @@ func (h *Handler) Reset() {
func (h *Handler) Pending() string { func (h *Handler) Pending() string {
return h.buffer 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
}

View File

@ -3,6 +3,7 @@ package input
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/motion" "git.gophernest.net/azpect/TextEditor/internal/motion"
"git.gophernest.net/azpect/TextEditor/internal/operator"
) )
type Keymap struct { type Keymap struct {
@ -28,7 +29,7 @@ func NewNormalKeymap() *Keymap {
"b": motion.MoveBackwardWord{Count: 1}, "b": motion.MoveBackwardWord{Count: 1},
}, },
operators: map[string]action.Operator{ operators: map[string]action.Operator{
// "d": DeleteOp{}, "d": operator.DeleteOperator{},
// "c": ChangeOp{}, // "c": ChangeOp{},
// "y": YankOp{}, // "y": YankOp{},
// "p": PasteOp{}, // "p": PasteOp{},
@ -69,12 +70,12 @@ func NewVisualKeymap() *Keymap {
"b": motion.MoveBackwardWord{Count: 1}, "b": motion.MoveBackwardWord{Count: 1},
}, },
operators: map[string]action.Operator{ operators: map[string]action.Operator{
// "d": DeleteOp{}, "d": operator.DeleteOperator{},
"x": operator.DeleteOperator{},
// "c": ChangeOp{}, // "c": ChangeOp{},
// "y": YankOp{}, // "y": YankOp{},
// "p": PasteOp{}, // "p": PasteOp{},
// "s": SubstitueOp{}, // "s": SubstitueOp{},
// "x": DeleteOp{},
// "~": SwapCaseOp{}, // "~": SwapCaseOp{},
}, },
actions: map[string]action.Action{ actions: map[string]action.Action{

108
internal/operator/delete.go Normal file
View File

@ -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()
}