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:
parent
db70ca39f1
commit
07589e3897
@ -1,16 +1,26 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/editor"
|
"git.gophernest.net/azpect/TextEditor/internal/editor"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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() {
|
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(
|
tea.NewProgram(
|
||||||
editor.NewModel(lines, action.Position{Line: 0, Col: 0}),
|
editor.NewModel(generateLines(32), action.Position{Line: 0, Col: 0}),
|
||||||
tea.WithAltScreen(),
|
tea.WithAltScreen(),
|
||||||
).Run()
|
).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,6 +66,14 @@ type Position struct {
|
|||||||
Line, Col int
|
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
|
// Action is the base interface - anything executable
|
||||||
type Action interface {
|
type Action interface {
|
||||||
Execute(m Model) tea.Cmd
|
Execute(m Model) tea.Cmd
|
||||||
@ -74,11 +82,12 @@ type Action interface {
|
|||||||
// Motion moves the cursor and returns the range covered
|
// Motion moves the cursor and returns the range covered
|
||||||
type Motion interface {
|
type Motion interface {
|
||||||
Action
|
Action
|
||||||
|
Type() MotionType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operator acts on a range (delete, yank, change)
|
// Operator acts on a range (delete, yank, change)
|
||||||
type Operator interface {
|
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 handles dd, yy, cc (line-wise)
|
||||||
DoublePress(m Model, count int) tea.Cmd
|
DoublePress(m Model, count int) tea.Cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
// In visual mode, the selection is already defined — operate immediately
|
||||||
if m.IsVisualMode() {
|
if m.IsVisualMode() {
|
||||||
start, end := normalizeVisualSelection(m)
|
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)
|
m.SetMode(action.NormalMode)
|
||||||
h.Reset()
|
h.Reset()
|
||||||
return cmd
|
return cmd
|
||||||
@ -182,12 +187,12 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
|
|||||||
if r, ok := mot.(action.Repeatable); ok {
|
if r, ok := mot.(action.Repeatable); ok {
|
||||||
mot = r.WithCount(count).(action.Motion)
|
mot = r.WithCount(count).(action.Motion)
|
||||||
}
|
}
|
||||||
// Get range here
|
// Get range and motion type
|
||||||
pg := m.(PositionGetter)
|
pg := m.(PositionGetter)
|
||||||
start := pg.GetCursorPosition()
|
start := pg.GetCursorPosition()
|
||||||
mot.Execute(m)
|
mot.Execute(m)
|
||||||
end := pg.GetCursorPosition()
|
end := pg.GetCursorPosition()
|
||||||
cmd := h.operator.Operate(m, start, end)
|
cmd := h.operator.Operate(m, start, end, mot.Type())
|
||||||
h.Reset()
|
h.Reset()
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MoveDown implements Motion (j)
|
// MoveDown implements Motion (j) - linewise
|
||||||
type MoveDown struct {
|
type MoveDown struct {
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
@ -18,11 +18,13 @@ func (a MoveDown) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveDown) Type() action.MotionType { return action.Linewise }
|
||||||
|
|
||||||
func (a MoveDown) WithCount(n int) action.Action {
|
func (a MoveDown) WithCount(n int) action.Action {
|
||||||
return MoveDown{Count: n}
|
return MoveDown{Count: n}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveUp implements Motion (k)
|
// MoveUp implements Motion (k) - linewise
|
||||||
type MoveUp struct {
|
type MoveUp struct {
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
@ -35,11 +37,13 @@ func (a MoveUp) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveUp) Type() action.MotionType { return action.Linewise }
|
||||||
|
|
||||||
func (a MoveUp) WithCount(n int) action.Action {
|
func (a MoveUp) WithCount(n int) action.Action {
|
||||||
return MoveUp{Count: n}
|
return MoveUp{Count: n}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveLeft implements Motion (h)
|
// MoveLeft implements Motion (h) - charwise
|
||||||
type MoveLeft struct {
|
type MoveLeft struct {
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
@ -52,11 +56,13 @@ func (a MoveLeft) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveLeft) Type() action.MotionType { return action.Charwise }
|
||||||
|
|
||||||
func (a MoveLeft) WithCount(n int) action.Action {
|
func (a MoveLeft) WithCount(n int) action.Action {
|
||||||
return MoveLeft{Count: n}
|
return MoveLeft{Count: n}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveRight implements Motion (l)
|
// MoveRight implements Motion (l) - charwise
|
||||||
type MoveRight struct {
|
type MoveRight struct {
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
@ -70,6 +76,8 @@ func (a MoveRight) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveRight) Type() action.MotionType { return action.Charwise }
|
||||||
|
|
||||||
func (a MoveRight) WithCount(n int) action.Action {
|
func (a MoveRight) WithCount(n int) action.Action {
|
||||||
return MoveRight{Count: n}
|
return MoveRight{Count: n}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MoveToTop implements Motion (gg)
|
// MoveToTop implements Motion (gg) - linewise
|
||||||
type MoveToTop struct{}
|
type MoveToTop struct{}
|
||||||
|
|
||||||
func (a MoveToTop) Execute(m action.Model) tea.Cmd {
|
func (a MoveToTop) Execute(m action.Model) tea.Cmd {
|
||||||
@ -14,7 +14,9 @@ func (a MoveToTop) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveToBottom implements Motion (G)
|
func (a MoveToTop) Type() action.MotionType { return action.Linewise }
|
||||||
|
|
||||||
|
// MoveToBottom implements Motion (G) - linewise
|
||||||
type MoveToBottom struct{}
|
type MoveToBottom struct{}
|
||||||
|
|
||||||
func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
|
func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
|
||||||
@ -23,7 +25,9 @@ func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveToLineStart implements Motion (0)
|
func (a MoveToBottom) Type() action.MotionType { return action.Linewise }
|
||||||
|
|
||||||
|
// MoveToLineStart implements Motion (0) - charwise
|
||||||
type MoveToLineStart struct{}
|
type MoveToLineStart struct{}
|
||||||
|
|
||||||
func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
|
func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
|
||||||
@ -32,7 +36,9 @@ func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveToLineEnd implements Motion ($)
|
func (a MoveToLineStart) Type() action.MotionType { return action.Charwise }
|
||||||
|
|
||||||
|
// MoveToLineEnd implements Motion ($) - charwise
|
||||||
type MoveToLineEnd struct{}
|
type MoveToLineEnd struct{}
|
||||||
|
|
||||||
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
|
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
|
||||||
@ -41,7 +47,9 @@ func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveToLineContentStart implements Motion (_)
|
func (a MoveToLineEnd) Type() action.MotionType { return action.Charwise }
|
||||||
|
|
||||||
|
// MoveToLineContentStart implements Motion (_) - charwise
|
||||||
type MoveToLineContentStart struct{}
|
type MoveToLineContentStart struct{}
|
||||||
|
|
||||||
func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
|
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)
|
m.SetCursorX(x)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveToLineContentStart) Type() action.MotionType { return action.Charwise }
|
||||||
|
|||||||
@ -171,11 +171,11 @@ func prevWordStart(m action.Model, x, y int) (int, int) {
|
|||||||
return x, y
|
return x, y
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveForwardWord implements Motion (w) - charwise
|
||||||
type MoveForwardWord struct {
|
type MoveForwardWord struct {
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute implements [action.Action].
|
|
||||||
func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
|
func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
|
||||||
x := m.CursorX()
|
x := m.CursorX()
|
||||||
y := m.CursorY()
|
y := m.CursorY()
|
||||||
@ -187,15 +187,17 @@ func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveForwardWord) Type() action.MotionType { return action.Charwise }
|
||||||
|
|
||||||
func (a MoveForwardWord) WithCount(n int) action.Action {
|
func (a MoveForwardWord) WithCount(n int) action.Action {
|
||||||
return MoveForwardWord{Count: n}
|
return MoveForwardWord{Count: n}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveForwardWordEnd implements Motion (e) - charwise
|
||||||
type MoveForwardWordEnd struct {
|
type MoveForwardWordEnd struct {
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute implements [action.Action].
|
|
||||||
func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
|
func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
|
||||||
x := m.CursorX()
|
x := m.CursorX()
|
||||||
y := m.CursorY()
|
y := m.CursorY()
|
||||||
@ -207,15 +209,17 @@ func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveForwardWordEnd) Type() action.MotionType { return action.Charwise }
|
||||||
|
|
||||||
func (a MoveForwardWordEnd) WithCount(n int) action.Action {
|
func (a MoveForwardWordEnd) WithCount(n int) action.Action {
|
||||||
return MoveForwardWordEnd{Count: n}
|
return MoveForwardWordEnd{Count: n}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveBackwardWord implements Motion (b) - charwise
|
||||||
type MoveBackwardWord struct {
|
type MoveBackwardWord struct {
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute implements [action.Action].
|
|
||||||
func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
|
func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
|
||||||
x := m.CursorX()
|
x := m.CursorX()
|
||||||
y := m.CursorY()
|
y := m.CursorY()
|
||||||
@ -227,6 +231,8 @@ func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a MoveBackwardWord) Type() action.MotionType { return action.Charwise }
|
||||||
|
|
||||||
func (a MoveBackwardWord) WithCount(n int) action.Action {
|
func (a MoveBackwardWord) WithCount(n int) action.Action {
|
||||||
return MoveBackwardWord{Count: n}
|
return MoveBackwardWord{Count: n}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,10 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Implements Operator (x)
|
// Implements Operator (d)
|
||||||
type DeleteOperator struct{}
|
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() {
|
switch m.Mode() {
|
||||||
case action.VisualMode:
|
case action.VisualMode:
|
||||||
deleteCharSelection(m, start, end)
|
deleteCharSelection(m, start, end)
|
||||||
@ -16,6 +16,8 @@ func (o DeleteOperator) Operate(m action.Model, start, end action.Position) tea.
|
|||||||
deleteLineSelection(m, start, end)
|
deleteLineSelection(m, start, end)
|
||||||
case action.VisualBlockMode:
|
case action.VisualBlockMode:
|
||||||
deleteBlockSelection(m, start, end)
|
deleteBlockSelection(m, start, end)
|
||||||
|
case action.NormalMode:
|
||||||
|
deleteNormalMode(m, start, end, mtype)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -44,6 +46,36 @@ func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd {
|
|||||||
return nil
|
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) {
|
func deleteCharSelection(m action.Model, start, end action.Position) {
|
||||||
if start.Line == end.Line {
|
if start.Line == end.Line {
|
||||||
line := m.Line(start.Line)
|
line := m.Line(start.Line)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user