feat: implemented 'dd' and other 'd' for visual mode.
Tested 'dd' but not visual mode.
This commit is contained in:
parent
f0f3f95e7b
commit
77374ba447
@ -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
|
||||
|
||||
199
internal/editor/integration_operator_delete_test.go
Normal file
199
internal/editor/integration_operator_delete_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
108
internal/operator/delete.go
Normal file
108
internal/operator/delete.go
Normal 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()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user