Compare commits

...

10 Commits

Author SHA1 Message Date
Hayden Hargreaves
c2b5e6f67c tests: implement tests for insert mode arrows keys 2026-02-12 17:36:07 -07:00
Hayden Hargreaves
39c4bf1b6b feat: implemented insert mode keymaps and ctrl+w, tested 2026-02-12 17:30:06 -07:00
Hayden Hargreaves
49ef0212a6 test: built tests for visual mode and visual delete operator 2026-02-11 18:06:16 -07:00
Hayden Hargreaves
77374ba447 feat: implemented 'dd' and other 'd' for visual mode.
Tested 'dd' but not visual mode.
2026-02-11 17:56:06 -07:00
Hayden Hargreaves
f0f3f95e7b feat: highlighting implemented! No tests yet 2026-02-11 15:00:02 -07:00
Hayden Hargreaves
f2496f91dd fix: added enter command mode action 2026-02-10 22:30:01 -07:00
Hayden Hargreaves
0a149b4e44 feat: implement arrow keys in insert mode. Untested. 2026-02-10 22:26:56 -07:00
Hayden Hargreaves
2cadb09350 feat: implemented '_' action, and tested. 2026-02-10 22:20:03 -07:00
Hayden Hargreaves
84a7983a21 feat: lots of word actions. 'w', 'e', and 'b'. Includes tests. 2026-02-10 22:00:18 -07:00
Hayden Hargreaves
3d3948d7e3 Implement 'del' in insert mode and tests. 2026-02-10 12:12:16 -07:00
21 changed files with 2274 additions and 145 deletions

View File

@ -8,7 +8,7 @@ import (
func main() {
lines := []string{"Hello world", "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}),
tea.WithAltScreen(),

View File

@ -9,6 +9,9 @@ const (
NormalMode Mode = iota
InsertMode
CommandMode
VisualMode
VisualLineMode
VisualBlockMode
)
// Model defines the interface for editor state that actions can modify
@ -28,12 +31,29 @@ type Model interface {
SetCursorY(y int)
ClampCursorX()
// Anchor
AnchorX() int
AnchorY() int
SetAnchorX(x int)
SetAnchorY(y int)
// Insert
InsertKeys() []string
SetInsertKeys(keys []string)
// Settings
TabSize() int
// Mode
Mode() Mode
SetMode(mode Mode)
IsVisualMode() bool
// Insert recording (for count replay)
SetInsertRecording(count int, action Action)
// ExitInsertMode handles replay, cursor step-back, and mode transition on esc
ExitInsertMode()
}
// Position represents a location in the buffer
@ -55,7 +75,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

View File

@ -1,6 +1,10 @@
package action
import tea "github.com/charmbracelet/bubbletea"
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// EnterInsert implements Action (i)
type EnterInsert struct {
@ -121,3 +125,154 @@ func (a OpenLineAbove) Execute(m Model) tea.Cmd {
func (a OpenLineAbove) WithCount(n int) Action {
return OpenLineAbove{Count: n}
}
// --- Insert mode edit actions ---
// InsertChar inserts a single character (or rune sequence) at the cursor
type InsertChar struct {
Char string
}
func (a InsertChar) Execute(m Model) tea.Cmd {
x, y := m.CursorX(), m.CursorY()
l := m.Line(y)
if x < len(l) {
m.SetLine(y, l[:x]+a.Char+l[x:])
} else {
m.SetLine(y, l+a.Char)
}
m.SetCursorX(x + len(a.Char))
return nil
}
// InsertNewline splits the current line at the cursor (enter key)
type InsertNewline struct{}
func (a InsertNewline) Execute(m Model) tea.Cmd {
x, y := m.CursorX(), m.CursorY()
l := m.Line(y)
if x == len(l) {
m.InsertLine(y+1, "")
} else {
m.SetLine(y, l[:x])
m.InsertLine(y+1, l[x:])
}
m.SetCursorY(y + 1)
m.SetCursorX(0)
return nil
}
// InsertBackspace deletes the character before the cursor
type InsertBackspace struct{}
func (a InsertBackspace) Execute(m Model) tea.Cmd {
x, y := m.CursorX(), m.CursorY()
l := m.Line(y)
if x > 0 {
m.SetLine(y, l[:x-1]+l[x:])
m.SetCursorX(x - 1)
} else if y > 0 {
prevLine := m.Line(y - 1)
newX := len(prevLine)
m.SetLine(y-1, prevLine+l)
m.DeleteLine(y)
m.SetCursorY(y - 1)
m.SetCursorX(newX)
}
return nil
}
// InsertDelete deletes the character under/after the cursor (delete key)
type InsertDelete struct{}
func (a InsertDelete) Execute(m Model) tea.Cmd {
x, y := m.CursorX(), m.CursorY()
l := m.Line(y)
if x == len(l) && y < m.LineCount()-1 {
nextLine := m.Line(y + 1)
m.SetLine(y, l+nextLine)
m.DeleteLine(y + 1)
} else if x < len(l) {
m.SetLine(y, l[:x]+l[x+1:])
}
return nil
}
// InsertTab inserts spaces equal to the tab size
type InsertTab struct{}
func (a InsertTab) Execute(m Model) tea.Cmd {
x, y := m.CursorX(), m.CursorY()
l := m.Line(y)
tabs := strings.Repeat(" ", m.TabSize())
if x < len(l) {
m.SetLine(y, l[:x]+tabs+l[x:])
} else {
m.SetLine(y, l+tabs)
}
m.SetCursorX(x + len(tabs))
return nil
}
// InsertDeletePreviousWord deletes the word before the cursor (ctrl+w)
type InsertDeletePreviousWord struct{}
func isWordChar(c byte) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_'
}
func isPunctuation(c byte) bool {
return c != ' ' && c != '\t' && !isWordChar(c)
}
func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
x, y := m.CursorX(), m.CursorY()
line := m.Line(y)
// At start of line: merge with previous line (same as backspace)
if x == 0 {
if y > 0 {
prevLine := m.Line(y - 1)
newX := len(prevLine)
m.SetLine(y-1, prevLine+line)
m.DeleteLine(y)
m.SetCursorY(y - 1)
m.SetCursorX(newX)
}
return nil
}
// Scan backwards to find the new cursor position (don't mutate yet)
newX := x
// If we are on puncuation, we should just skip them all and quit
if isPunctuation(line[newX-1]) {
for newX > 0 && isPunctuation(line[newX-1]) {
newX--
}
m.SetLine(y, line[:newX]+line[x:])
m.SetCursorX(newX)
return nil
}
// Skip whitespace immediately before the cursor
for newX > 0 && (line[newX-1] == ' ' || line[newX-1] == '\t') {
newX--
}
// Skip the word characters before the cursor
for newX > 0 && isWordChar(line[newX-1]) {
newX--
}
// Delete everything from newX up to x in one operation
m.SetLine(y, line[:newX]+line[x:])
m.SetCursorX(newX)
return nil
}

View File

@ -8,3 +8,41 @@ type Quit struct{}
func (a Quit) Execute(m Model) tea.Cmd {
return tea.Quit
}
// Quit implements Action (:)
type EnterComandMode struct{}
func (a EnterComandMode) Execute(m Model) tea.Cmd {
m.SetMode(CommandMode)
return nil
}
// Quit implements Action (v)
type EnterVisualMode struct{}
func (a EnterVisualMode) Execute(m Model) tea.Cmd {
m.SetAnchorX(m.CursorX())
m.SetAnchorY(m.CursorY())
m.SetMode(VisualMode)
return nil
}
// Quit implements Action (V)
type EnterVisualLineMode struct{}
func (a EnterVisualLineMode) Execute(m Model) tea.Cmd {
m.SetAnchorX(m.CursorX())
m.SetAnchorY(m.CursorY())
m.SetMode(VisualLineMode)
return nil
}
// Quit implements Action (ctrl+v)
type EnterVisualBlockMode struct{}
func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd {
m.SetAnchorX(m.CursorX())
m.SetAnchorY(m.CursorY())
m.SetMode(VisualBlockMode)
return nil
}

View File

@ -23,6 +23,10 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
case "ctrl+d":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD})
case "ctrl+v":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV})
case "ctrl+w":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW})
default:
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
}
@ -44,7 +48,7 @@ func newTestModelWithCursorPos(t *testing.T, pos action.Position) *teatest.TestM
return teatest.NewTestModel(t, NewModel(lines, pos), teatest.WithInitialTermSize(80, 24))
}
func newTestModelWithCursorPosAndLines(t *testing.T, lines []string, pos action.Position) *teatest.TestModel {
func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos action.Position) *teatest.TestModel {
return teatest.NewTestModel(t, NewModel(lines, pos), teatest.WithInitialTermSize(80, 24))
}

View File

@ -20,7 +20,7 @@ func TestDeleteChar(t *testing.T) {
t.Run("test 'x' in middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "x")
m := getFinalModel(t, tm)
@ -31,7 +31,7 @@ func TestDeleteChar(t *testing.T) {
t.Run("test 'x' at end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 4, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "x")
m := getFinalModel(t, tm)
@ -77,7 +77,7 @@ func TestDeleteCharWithCount(t *testing.T) {
t.Run("test '2x' from middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 1, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "2", "x")
m := getFinalModel(t, tm)

View File

@ -32,7 +32,7 @@ func TestEnterInsert(t *testing.T) {
t.Run("test 'i' insert in middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm)
@ -43,7 +43,7 @@ func TestEnterInsert(t *testing.T) {
t.Run("test 'i' cursor moves back on esc", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm)
@ -77,7 +77,7 @@ func TestEnterInsertAfter(t *testing.T) {
t.Run("test 'a' from middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "a", "X", "esc")
m := getFinalModel(t, tm)
@ -90,7 +90,7 @@ func TestEnterInsertAfter(t *testing.T) {
func TestEnterInsertLineStart(t *testing.T) {
t.Run("test 'I' enters insert mode at line start", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm)
@ -101,7 +101,7 @@ func TestEnterInsertLineStart(t *testing.T) {
t.Run("test 'I' from end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm)
@ -125,7 +125,7 @@ func TestEnterInsertLineEnd(t *testing.T) {
t.Run("test 'A' from middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "A", "X", "esc")
m := getFinalModel(t, tm)
@ -154,7 +154,7 @@ func TestOpenLineBelow(t *testing.T) {
t.Run("test 'o' from middle of file", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
@ -168,7 +168,7 @@ func TestOpenLineBelow(t *testing.T) {
t.Run("test 'o' at end of file", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
@ -233,7 +233,7 @@ func TestOpenLineBelowWithCount(t *testing.T) {
func TestOpenLineAbove(t *testing.T) {
t.Run("test 'O' creates line above", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "O", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
@ -261,7 +261,7 @@ func TestOpenLineAbove(t *testing.T) {
t.Run("test 'O' cursor at start of new line", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 1})
sendKeys(tm, "O", "esc")
m := getFinalModel(t, tm)
@ -274,7 +274,7 @@ func TestOpenLineAbove(t *testing.T) {
func TestOpenLineAboveWithCount(t *testing.T) {
t.Run("test '3O' creates 3 lines above", func(t *testing.T) {
lines := []string{"line 1"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "3", "O", "x", "esc")
m := getFinalModel(t, tm)
@ -294,7 +294,7 @@ func TestOpenLineAboveWithCount(t *testing.T) {
func TestInsertModeEnter(t *testing.T) {
t.Run("test enter splits line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm)
@ -311,7 +311,7 @@ func TestInsertModeEnter(t *testing.T) {
t.Run("test enter at end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm)
@ -347,7 +347,7 @@ func TestInsertModeEnter(t *testing.T) {
func TestInsertModeBackspace(t *testing.T) {
t.Run("test backspace deletes character", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm)
@ -358,7 +358,7 @@ func TestInsertModeBackspace(t *testing.T) {
t.Run("test backspace at start of line joins lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm)
@ -383,7 +383,7 @@ func TestInsertModeBackspace(t *testing.T) {
t.Run("test multiple backspaces", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "backspace", "backspace", "backspace", "esc")
m := getFinalModel(t, tm)
@ -392,3 +392,367 @@ func TestInsertModeBackspace(t *testing.T) {
}
})
}
func TestInsertModeDelete(t *testing.T) {
t.Run("test delete deletes character", func(t *testing.T) {
lines := []string{"world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "word" {
t.Errorf("lines[0] = %q, want 'word'", m.lines[0])
}
})
t.Run("test delete at end of line joins lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 1 {
t.Errorf("len(lines) = %d, want 1", len(m.lines))
}
if m.lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
}
})
t.Run("test delete at start of empty line joins lines", func(t *testing.T) {
lines := []string{"", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 1 {
t.Errorf("len(lines) = %d, want 1", len(m.lines))
}
if m.lines[0] != "world" {
t.Errorf("lines[0] = %q, want 'world'", m.lines[0])
}
})
t.Run("test delete at end of last line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
}
})
t.Run("test multiple delete", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "i", "delete", "delete", "delete", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "ho" {
t.Errorf("lines[0] = %q, want 'he'", m.lines[0])
}
})
}
func TestInsertModeArrowKeys(t *testing.T) {
t.Run("test left arrow moves cursor left", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "left", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
}
})
t.Run("test right arrow moves cursor right", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "i", "right", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
}
})
t.Run("test up arrow moves cursor up", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 1})
sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
}
if m.lines[1] != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.lines[1])
}
})
t.Run("test down arrow moves cursor down", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
}
if m.lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1])
}
})
t.Run("test left arrow at start of line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i", "left", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
}
})
t.Run("test right arrow at end of line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "a", "right", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
}
})
t.Run("test up arrow at first line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
}
})
t.Run("test down arrow at last line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
}
})
t.Run("test up arrow clamps cursor to shorter line", func(t *testing.T) {
lines := []string{"hi", "hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 1})
sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hiX" {
t.Errorf("lines[0] = %q, want 'hiX'", m.lines[0])
}
})
t.Run("test down arrow clamps cursor to shorter line", func(t *testing.T) {
lines := []string{"hello", "hi"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[1] != "hiX" {
t.Errorf("lines[1] = %q, want 'hiX'", m.lines[1])
}
})
t.Run("test multiple arrow keys in sequence", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i", "right", "right", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1])
}
})
}
func TestInsertModeDeletePreviousWord(t *testing.T) {
t.Run("test 'ctrl+w' deletes word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hello " {
t.Errorf("lines[0] = %q, want 'hello '", m.lines[0])
}
if m.CursorX() != 5 {
t.Errorf("CursorX() = %d, want '5'", m.CursorX())
}
})
t.Run("test 'ctrl+w' deletes word with whitespace", func(t *testing.T) {
lines := []string{"hello "}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0])
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want '0'", m.CursorX())
}
})
t.Run("test 'ctrl+w' deletes word until period", func(t *testing.T) {
lines := []string{"hello wo..."}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hello wo" {
t.Errorf("lines[0] = %q, want 'hello wo'", m.lines[0])
}
if m.CursorX() != 7 {
t.Errorf("CursorX() = %d, want '7'", m.CursorX())
}
})
t.Run("test 'ctrl+w' deletes line when blank", func(t *testing.T) {
lines := []string{"", ""}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "a", "ctrl+w", "esc")
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())
}
})
t.Run("test 'ctrl+w' deletes all whitespace when line is only whitespace", func(t *testing.T) {
lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want '1'", m.LineCount())
}
if m.Line(0) != "" {
t.Errorf("Line(0) = %s, want ''", m.Line(0))
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want '0'", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want '0'", m.CursorY())
}
})
t.Run("test 'ctrl+w' at start of first line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'ctrl+w' from middle of word", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.lines[0])
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'ctrl+w' deletes word after punctuation", func(t *testing.T) {
lines := []string{"...hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 7, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "..." {
t.Errorf("lines[0] = %q, want '...'", m.lines[0])
}
if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want 2", m.CursorX())
}
})
t.Run("test 'ctrl+w' with tabs as whitespace", func(t *testing.T) {
lines := []string{"hello\tworld"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hello\t" {
t.Errorf("lines[0] = %q, want 'hello\\t'", m.lines[0])
}
if m.CursorX() != 5 {
t.Errorf("CursorX() = %d, want 5", m.CursorX())
}
})
t.Run("test 'ctrl+w' at start of line merges with previous line content", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
}
if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'ctrl+w' with underscore in word", func(t *testing.T) {
lines := []string{"hello_world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0])
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
}

View File

@ -64,7 +64,7 @@ func TestMoveDownWithOverflow(t *testing.T) {
lines := []string{"long line", "small"}
t.Run("test 'j' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 8, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 8, Line: 0})
sendKeys(tm, "j")
m := getFinalModel(t, tm)
@ -75,7 +75,7 @@ func TestMoveDownWithOverflow(t *testing.T) {
})
t.Run("test 'j' without overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "j")
m := getFinalModel(t, tm)
@ -143,7 +143,7 @@ func TestMoveUpWithOverflow(t *testing.T) {
lines := []string{"small", "long line"}
t.Run("test 'k' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "k")
m := getFinalModel(t, tm)
@ -154,7 +154,7 @@ func TestMoveUpWithOverflow(t *testing.T) {
})
t.Run("test 'k' without overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 1})
sendKeys(tm, "k")
m := getFinalModel(t, tm)

View File

@ -14,8 +14,8 @@ func TestMoveToBottom(t *testing.T) {
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY())
}
})
@ -24,8 +24,8 @@ func TestMoveToBottom(t *testing.T) {
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY())
}
})
@ -34,23 +34,23 @@ func TestMoveToBottom(t *testing.T) {
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY())
}
})
t.Run("test 'G' clamps cursor.x", func(t *testing.T) {
t.Run("test 'G' clamps CursorX()", func(t *testing.T) {
lines := []string{"long line here", "short"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY())
}
want := len(lines[1])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
}
})
@ -60,8 +60,8 @@ func TestMoveToBottom(t *testing.T) {
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
}
@ -72,8 +72,8 @@ func TestMoveToTop(t *testing.T) {
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
@ -82,8 +82,8 @@ func TestMoveToTop(t *testing.T) {
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
@ -92,28 +92,28 @@ func TestMoveToTop(t *testing.T) {
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'gg' clamps cursor.x", func(t *testing.T) {
t.Run("test 'gg' clamps CursorX()", func(t *testing.T) {
lines := []string{"short", "long line here"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 1})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
}
})
}
// --- 0 and $ Tests ---
// --- 0, $ and _ Tests ---
func TestMoveToLineStart(t *testing.T) {
t.Run("test '0' from middle of line", func(t *testing.T) {
@ -121,19 +121,19 @@ func TestMoveToLineStart(t *testing.T) {
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test '0' from end of line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: len(lines[0]), Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: len(lines[0]), Line: 0})
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
@ -142,8 +142,8 @@ func TestMoveToLineStart(t *testing.T) {
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
@ -153,8 +153,8 @@ func TestMoveToLineStart(t *testing.T) {
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
@ -163,8 +163,8 @@ func TestMoveToLineStart(t *testing.T) {
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.y != 2 {
t.Errorf("cursor.y = %d, want 2", m.cursor.y)
if m.CursorY() != 2 {
t.Errorf("CursorY() = %d, want 2", m.CursorY())
}
})
}
@ -177,32 +177,32 @@ func TestMoveToLineEnd(t *testing.T) {
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
}
})
t.Run("test '$' from middle of line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
}
})
t.Run("test '$' already at end", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: len(lines[0]), Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: len(lines[0]), Line: 0})
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
}
})
@ -212,8 +212,8 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
@ -222,8 +222,87 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
if m.cursor.y != 2 {
t.Errorf("cursor.y = %d, want 2", m.cursor.y)
if m.CursorY() != 2 {
t.Errorf("CursorY() = %d, want 2", m.CursorY())
}
})
}
func TestMoveToLineContentStart(t *testing.T) {
t.Run("test '_' from middle of line with no leading whitespace", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0})
sendKeys(tm, "_")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test '_' from middle of line with leading whitespace", func(t *testing.T) {
lines := []string{" hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "_")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test '_' from start of line with leading whitespace", func(t *testing.T) {
lines := []string{" hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "_")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test '_' from start of line with no leading whitespace", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "_")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test '_' from middle of line with only whitespace", func(t *testing.T) {
lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "_")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test '_' from end of line with only whitespace", func(t *testing.T) {
lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "_")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test '_' on empty line", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "_")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
}

View File

@ -0,0 +1,341 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// --- w, e, and b Tests ---
func TestMoveForwardWord(t *testing.T) {
t.Run("test 'w' moves forward one word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "w")
m := getFinalModel(t, tm)
if m.CursorX() != 6 {
t.Errorf("m.CursorX() = %d, want 6", m.CursorX())
}
})
t.Run("test 'www' moves forward three words", func(t *testing.T) {
lines := []string{"hello world hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "w", "w", "w")
m := getFinalModel(t, tm)
if m.CursorX() != 18 {
t.Errorf("m.CursorX() = %d, want 18", m.CursorX())
}
})
t.Run("test 'w' moves to next line", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "w")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("m.CursorX() = %d, want 0", m.CursorX())
}
if m.CursorY() != 1 {
t.Errorf("m.CursorY() = %d, want 1", m.CursorY())
}
})
t.Run("test 'w' at end of file preserves position", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "w")
m := getFinalModel(t, tm)
if m.CursorX() != 5 {
t.Errorf("m.CursorX() = %d, want 5", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("m.CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'w' stops at punctuation", func(t *testing.T) {
lines := []string{"hello.word"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "w")
m := getFinalModel(t, tm)
if m.CursorX() != 5 {
t.Errorf("m.CursorX() = %d, want 5", m.CursorX())
}
})
t.Run("test 'w' skips underscore", func(t *testing.T) {
lines := []string{"hello_world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "w")
m := getFinalModel(t, tm)
if m.CursorX() != 11 {
t.Errorf("m.CursorX() = %d, want 11", m.CursorX())
}
})
t.Run("test 'w' moves once past punctuation", func(t *testing.T) {
lines := []string{".hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "w")
m := getFinalModel(t, tm)
if m.CursorX() != 1 {
t.Errorf("m.CursorX() = %d, want 1", m.CursorX())
}
})
t.Run("test 'w' from middle of word skips only remaining chars", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "w")
m := getFinalModel(t, tm)
if m.CursorX() != 6 {
t.Errorf("m.CursorX() = %d, want 6", m.CursorX())
}
})
}
func TestMoveToWordEnd(t *testing.T) {
t.Run("test 'e' moves to end of current word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("m.CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test 'eee' moves to end of three words", func(t *testing.T) {
lines := []string{"hello world hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "e", "e", "e")
m := getFinalModel(t, tm)
if m.CursorX() != 16 {
t.Errorf("m.CursorX() = %d, want 16", m.CursorX())
}
})
t.Run("test 'e' moves to next line when at end of word", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("m.CursorX() = %d, want 4", m.CursorX())
}
if m.CursorY() != 1 {
t.Errorf("m.CursorY() = %d, want 1", m.CursorY())
}
})
t.Run("test 'e' at end of file preserves position", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("m.CursorX() = %d, want 4", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("m.CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'e' stops at end of word before punctuation", func(t *testing.T) {
lines := []string{"hello.world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("m.CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test 'e' stops at end of punctuation sequence", func(t *testing.T) {
lines := []string{"..hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 1 {
t.Errorf("m.CursorX() = %d, want 1", m.CursorX())
}
})
t.Run("test 'e' skips underscore", func(t *testing.T) {
lines := []string{"hello_world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 10 {
t.Errorf("m.CursorX() = %d, want 10", m.CursorX())
}
})
t.Run("test 'e' moves past leading punctuation to end of word", func(t *testing.T) {
lines := []string{".hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 5 {
t.Errorf("m.CursorX() = %d, want 5", m.CursorX())
}
})
t.Run("test 'e' from middle of word moves to end of current word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "e")
m := getFinalModel(t, tm)
if m.CursorX() != 4 {
t.Errorf("m.CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test 'ee' lands at end of each class in multi-char punctuation", func(t *testing.T) {
lines := []string{"hello..world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "e", "e")
m := getFinalModel(t, tm)
if m.CursorX() != 6 {
t.Errorf("m.CursorX() = %d, want 6", m.CursorX())
}
})
}
func TestMoveBackwardWord(t *testing.T) {
t.Run("test 'b' moves to start of current word", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("m.CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'b' at word start moves to end previous", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("m.CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'bbb' moves to back three word", func(t *testing.T) {
lines := []string{"hello world hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 23, Line: 0})
sendKeys(tm, "b", "b", "b")
m := getFinalModel(t, tm)
if m.CursorX() != 6 {
t.Errorf("m.CursorX() = %d, want 6", m.CursorX())
}
})
t.Run("test 'b' moves to prev line when at start of line", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("m.CursorX() = %d, want 0", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("m.CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'b' at start of file preserves position", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("m.CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'b' stops at punctuation", func(t *testing.T) {
lines := []string{"hello.world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 5 {
t.Errorf("m.CursorX() = %d, want 5", m.CursorX())
}
})
t.Run("test 'b' stops at end of punctuation sequence", func(t *testing.T) {
lines := []string{"..hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("m.CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'b' stops at end of punctuation sequence before word", func(t *testing.T) {
lines := []string{"hello..world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 7, Line: 0})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 5 {
t.Errorf("m.CursorX() = %d, want 5", m.CursorX())
}
})
t.Run("test 'b' stops at end of punctuation sequence on newline", func(t *testing.T) {
lines := []string{"hello.", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 5 {
t.Errorf("m.CursorX() = %d, want 5", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("m.CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'b' skips underscore", func(t *testing.T) {
lines := []string{"hello_world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "b")
m := getFinalModel(t, tm)
if m.CursorX() != 0 {
t.Errorf("m.CursorX() = %d, want 0", m.CursorX())
}
})
}

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

@ -0,0 +1,272 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// --- Visual Mode Selection State Tests ---
func TestVisualModeSelectionState(t *testing.T) {
t.Run("test 'v' enters visual mode and sets anchor at cursor", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "v")
m := getFinalModel(t, tm)
if m.Mode() != action.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
if m.AnchorX() != 3 {
t.Errorf("AnchorX() = %d, want 3", m.AnchorX())
}
if m.AnchorY() != 0 {
t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
}
})
t.Run("test 'vl' moves cursor right, anchor stays", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l")
m := getFinalModel(t, tm)
if m.AnchorX() != 0 {
t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
}
if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX())
}
})
t.Run("test 'vh' creates backward selection", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "h")
m := getFinalModel(t, tm)
if m.AnchorX() != 3 {
t.Errorf("AnchorX() = %d, want 3", m.AnchorX())
}
if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want 2", m.CursorX())
}
})
t.Run("test 'vj' extends selection down", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "j")
m := getFinalModel(t, tm)
if m.AnchorX() != 2 {
t.Errorf("AnchorX() = %d, want 2", m.AnchorX())
}
if m.AnchorY() != 0 {
t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
}
if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY())
}
})
t.Run("test 'V' enters visual line mode and sets anchor at cursor", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "V")
m := getFinalModel(t, tm)
if m.Mode() != action.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
if m.AnchorY() != 1 {
t.Errorf("AnchorY() = %d, want 1", m.AnchorY())
}
})
t.Run("test 'ctrl+v' enters visual block mode and sets anchor at cursor", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 1})
sendKeys(tm, "ctrl+v")
m := getFinalModel(t, tm)
if m.Mode() != action.VisualBlockMode {
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
}
if m.AnchorX() != 2 {
t.Errorf("AnchorX() = %d, want 2", m.AnchorX())
}
if m.AnchorY() != 1 {
t.Errorf("AnchorY() = %d, want 1", m.AnchorY())
}
})
t.Run("test 'esc' returns to normal mode from visual", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "esc")
m := getFinalModel(t, tm)
if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
})
}
// --- Visual Mode Delete Tests ---
func TestVisualModeDelete(t *testing.T) {
t.Run("test 'vd' deletes single char under cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "d")
m := getFinalModel(t, tm)
if m.Line(0) != "ello" {
t.Errorf("Line(0) = %q, want \"ello\"", m.Line(0))
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'vlll d' deletes four chars on same line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "l", "d")
m := getFinalModel(t, tm)
if m.Line(0) != "o world" {
t.Errorf("Line(0) = %q, want \"o world\"", m.Line(0))
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
})
t.Run("test 'v' backward selection 'hh d' deletes correct range", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "h", "h", "d")
// anchor=3, cursor=1 → normalized start=1, end=3 → delete "ell" → "ho"
m := getFinalModel(t, tm)
if m.Line(0) != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0))
}
if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX())
}
})
t.Run("test 'vj d' deletes char selection across two lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "j", "d")
// start=(2,0), end=(2,1) → prefix="he", suffix="ld" → "held"
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "held" {
t.Errorf("Line(0) = %q, want \"held\"", m.Line(0))
}
if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want 2", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'Vd' deletes current line", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "d")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.Line(0))
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'Vjd' deletes two lines", func(t *testing.T) {
lines := []string{"hello", "world", "testing"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "j", "d")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "testing" {
t.Errorf("Line(0) = %q, want \"testing\"", m.Line(0))
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'Vkd' deletes two lines with backward selection", func(t *testing.T) {
lines := []string{"hello", "world", "testing"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "V", "k", "d")
// anchor=line2, cursor=line1 → normalized start=line1, end=line2 → delete both
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))
}
})
t.Run("test 'ctrl+v ljd' deletes block selection", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v", "l", "j", "d")
// anchor=(0,0), cursor=(1,1) → block cols 0-1, lines 0-1
// "hello"[:0]+"hello"[2:] = "llo"
// "world"[:0]+"world"[2:] = "rld"
m := getFinalModel(t, tm)
if m.Line(0) != "llo" {
t.Errorf("Line(0) = %q, want \"llo\"", m.Line(0))
}
if m.Line(1) != "rld" {
t.Errorf("Line(1) = %q, want \"rld\"", m.Line(1))
}
if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX())
}
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
})
t.Run("test 'ctrl+v' backward col selection deletes correct block", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "ctrl+v", "h", "h", "j", "d")
// anchor=(3,0), cursor=(1,1) → cols min(3,1)=1 to max(3,1)=3, lines 0-1
// "hello"[:1]+"hello"[4:] = "h"+"o" = "ho"
// "world"[:1]+"world"[4:] = "w"+"d" = "wd"
m := getFinalModel(t, tm)
if m.Line(0) != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0))
}
if m.Line(1) != "wd" {
t.Errorf("Line(1) = %q, want \"wd\"", m.Line(1))
}
})
}

View File

@ -1,6 +1,8 @@
package editor
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/input"
tea "github.com/charmbracelet/bubbletea"
@ -12,19 +14,23 @@ type cursor struct {
}
type Model struct {
lines []string
cursor cursor
s_gutter int
mode action.Mode
win_h int
win_w int
command string
input *input.Handler
lines []string
cursor cursor
anchor cursor // starting point for visual modes
mode action.Mode
win_h int
win_w int
command string
input *input.Handler
// Insert repetition
insertCount int
insertKeys []string
insertAction action.Action
// Settings
gutterSize int
tabSize int
}
func NewModel(lines []string, pos action.Position) Model {
@ -34,10 +40,11 @@ func NewModel(lines []string, pos action.Position) Model {
x: pos.Col,
y: pos.Line,
},
s_gutter: 5,
mode: action.NormalMode,
command: "",
input: input.NewHandler(),
gutterSize: 5,
tabSize: 2,
mode: action.NormalMode,
command: "",
input: input.NewHandler(),
}
}
@ -100,6 +107,37 @@ func (m *Model) SetCursorY(y int) {
m.cursor.y = y
}
// Anchor methods
func (m *Model) AnchorX() int {
return m.anchor.x
}
func (m *Model) AnchorY() int {
return m.anchor.y
}
func (m *Model) SetAnchorX(x int) {
m.anchor.x = x
}
func (m *Model) SetAnchorY(y int) {
m.anchor.y = y
}
// Insert methods
func (m *Model) InsertKeys() []string {
return m.insertKeys
}
func (m *Model) SetInsertKeys(keys []string) {
m.insertKeys = keys
}
// Settings
func (m *Model) TabSize() int {
return m.tabSize
}
func (m *Model) ClampCursorX() {
lineLen := len(m.lines[m.cursor.y])
if lineLen == 0 {
@ -117,6 +155,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{}
@ -151,6 +195,18 @@ func (m *Model) replayInsert() {
}
}
func (m *Model) ExitInsertMode() {
if m.insertCount > 1 {
m.replayInsert()
}
if m.cursor.x > 0 {
m.cursor.x--
}
m.mode = action.NormalMode
m.insertCount = 0
m.insertKeys = nil
}
func (m *Model) processInsertKey(key string) {
x := m.CursorX()
y := m.CursorY()
@ -158,17 +214,12 @@ func (m *Model) processInsertKey(key string) {
switch key {
case "enter":
// Simple case, at end, just create a line
if x == len(l) {
m.InsertLine(y+1, "")
// otherwise, splice
} else {
m.SetLine(y, l[:x])
m.InsertLine(y+1, l[x:])
}
m.SetCursorY(y + 1)
m.SetCursorX(0)
@ -185,7 +236,53 @@ func (m *Model) processInsertKey(key string) {
m.SetCursorX(newX)
}
// Regular character
case "delete":
if x == len(l) && y < m.LineCount()-1 {
nextLine := m.Line(y + 1)
m.SetLine(y, l+nextLine)
m.DeleteLine(y + 1)
} else if x < len(l) {
m.SetLine(y, l[:x]+l[x+1:])
}
case "tab":
tabs := strings.Repeat(" ", m.tabSize)
if x < len(l) {
m.SetLine(y, l[:x]+tabs+l[x:])
} else {
m.SetLine(y, l+tabs)
}
m.SetCursorX(x + len(tabs))
case "up":
if y > 0 {
m.SetCursorY(y - 1)
m.ClampCursorX()
}
case "down":
if y+1 < m.LineCount() {
m.SetCursorY(y + 1)
m.ClampCursorX()
}
case "left":
if x > 0 {
m.SetCursorX(x - 1)
} else if y > 0 {
prevLine := m.Line(y - 1)
m.SetCursorX(len(prevLine))
m.SetCursorY(y - 1)
}
case "right":
if x < len(l) {
m.SetCursorX(x + 1)
} else if y+1 < m.LineCount() {
m.SetCursorX(0)
m.SetCursorY(y + 1)
}
default:
if x < len(l) {
m.SetLine(y, l[:x]+key+l[x:])

View File

@ -1,13 +1,16 @@
package editor
import (
"github.com/charmbracelet/lipgloss"
"git.gophernest.net/azpect/TextEditor/internal/action"
"github.com/charmbracelet/lipgloss"
)
func (m Model) cursorStyle() lipgloss.Style {
switch m.mode {
case action.NormalMode:
case action.NormalMode,
action.VisualMode,
action.VisualBlockMode,
action.VisualLineMode:
// Block cursor for normal mode
return lipgloss.NewStyle().Reverse(true)
case action.InsertMode:
@ -20,13 +23,25 @@ func (m Model) cursorStyle() lipgloss.Style {
}
}
// DEBUGGING STYLE
func (m Model) visualAnchorStyle() lipgloss.Style {
bg := lipgloss.Color("#a89020")
return lipgloss.NewStyle().Background(bg)
}
func (m Model) gutterStyle(currentLine bool) lipgloss.Style {
bg := lipgloss.Color("236")
fg := lipgloss.Color("243")
if currentLine {
fg = lipgloss.Color("#d69d00")
}
return lipgloss.NewStyle().
Width(m.s_gutter).
Background(lipgloss.Color("236")).
Width(m.gutterSize).
Background(bg).
Foreground(fg)
}
func (m Model) visualHighlightStyle() lipgloss.Style {
bg := lipgloss.Color("#7a6a00")
return lipgloss.NewStyle().Background(bg)
}

View File

@ -13,36 +13,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.win_w = msg.Width
case tea.KeyMsg:
// BUG: for use in debugging, until we have command mode
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch m.mode {
case action.NormalMode:
case action.NormalMode,
action.InsertMode,
action.VisualMode,
action.VisualBlockMode,
action.VisualLineMode:
return m, m.input.Handle(&m, msg.String())
// TODO: This should be handled elsewhere
case action.InsertMode:
key := msg.String()
switch key {
case "esc":
if m.insertCount > 1 {
m.replayInsert()
}
// Allow i to step back, but a to stay put
if m.cursor.x > 0 {
m.cursor.x--
}
m.mode = action.NormalMode
m.insertCount = 0
m.insertKeys = nil
case "ctrl+c", "ctrl+d":
return m, tea.Quit
default:
// Record and process
m.insertKeys = append(m.insertKeys, key)
m.processInsertKey(key)
}
// The only one left to migrate!
case action.CommandMode:
switch msg.String() {
case "esc":

View File

@ -7,6 +7,55 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action"
)
func posInsideSelection(m Model, col, line int) bool {
switch m.Mode() {
case action.VisualLineMode:
startY := min(m.AnchorY(), m.CursorY())
endY := max(m.AnchorY(), m.CursorY())
return line >= startY && line <= endY
case action.VisualMode:
ax := m.AnchorX()
ay := m.AnchorY()
cx := m.CursorX()
cy := m.CursorY()
// Normalize so start is always before end in document order
var startX, startY, endX, endY int
if ay < cy || (ay == cy && ax <= cx) {
startX, startY = ax, ay
endX, endY = cx, cy
} else {
startX, startY = cx, cy
endX, endY = ax, ay
}
// Position is inside if it falls within [start, end] inclusive
afterStart := line > startY || (line == startY && col >= startX)
beforeEnd := line < endY || (line == endY && col <= endX)
return afterStart && beforeEnd
case action.VisualBlockMode:
startX := min(m.AnchorX(), m.CursorX())
startY := min(m.AnchorY(), m.CursorY())
endX := max(m.AnchorX(), m.CursorX())
endY := max(m.AnchorY(), m.CursorY())
return col >= startX && col <= endX &&
line >= startY && line <= endY
default:
return false
}
}
func posIsAnchor(m Model, col, line int) bool {
ax := m.AnchorX()
ay := m.AnchorY()
return col == ax && line == ay
}
func (m Model) View() string {
var view strings.Builder
@ -21,17 +70,17 @@ func (m Model) View() string {
)
if y > m.cursor.y {
lineNumber = y - m.cursor.y
gutter = fmt.Sprintf("%*d ", m.s_gutter-1, lineNumber)
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
} else if y < m.cursor.y {
lineNumber = m.cursor.y - y
gutter = fmt.Sprintf("%*d ", m.s_gutter-1, lineNumber)
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
} else {
lineNumber = y + 1
currentLine = true
if lineNumber < 100 {
gutter = fmt.Sprintf("%*d ", m.s_gutter-2, lineNumber)
gutter = fmt.Sprintf("%*d ", m.gutterSize-2, lineNumber)
} else {
gutter = fmt.Sprintf("%*d ", m.s_gutter-1, lineNumber)
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
}
}
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
@ -45,11 +94,20 @@ func (m Model) View() string {
view.WriteString(m.cursorStyle().Render(" "))
}
} else if x < len(runes) {
view.WriteRune(runes[x])
if m.IsVisualMode() && posIsAnchor(m, x, y) {
view.WriteString(m.visualAnchorStyle().Render(string(runes[x])))
} 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 m.IsVisualMode() && posInsideSelection(m, x, y) {
view.WriteString(m.visualHighlightStyle().Render(" "))
}
}
} else {
format := fmt.Sprintf("%%-%ds ", m.s_gutter-1)
format := fmt.Sprintf("%%-%ds ", m.gutterSize-1)
fmt.Fprintf(&view, format, "~")
}
@ -65,11 +123,20 @@ func (m Model) View() string {
modeString = "INSERT"
case action.CommandMode:
modeString = "COMMAND"
case action.VisualMode:
modeString = "VISUAL"
case action.VisualLineMode:
modeString = "V-LINE"
case action.VisualBlockMode:
modeString = "V-BLOCK"
}
// DEBUG BAR! Def not the final bar
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)
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 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)
}

View File

@ -1,8 +1,8 @@
package input
import (
tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/action"
tea "github.com/charmbracelet/bubbletea"
)
type InputState int
@ -27,12 +27,22 @@ type Handler struct {
operatorKey string // track which key started operator (for dd, yy, cc)
buffer string // for display (what user has typed)
pending string // partial key sequence (e.g., "g" waiting for second key)
keymap *Keymap
// Keymaps
normalKeymap *Keymap
visualKeymap *Keymap
insertKeymap *Keymap
currentKeymap *Keymap
}
func NewHandler() *Handler {
return &Handler{
keymap: NewNormalKeymap(),
// keymap: NewNormalKeymap(),
normalKeymap: NewNormalKeymap(),
visualKeymap: NewVisualKeymap(),
insertKeymap: NewInsertKeymap(),
currentKeymap: nil,
}
}
@ -40,9 +50,19 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
// ESC always resets everything
if key == "esc" {
h.Reset()
if m.Mode() == action.InsertMode {
m.ExitInsertMode()
} else {
m.SetMode(action.NormalMode)
}
return nil
}
// Insert mode bypasses the normal state machine entirely
if m.Mode() == action.InsertMode {
return h.handleInsertKey(m, key)
}
// Try to accumulate count (only if no pending sequence)
if h.pending == "" && h.tryAccumulateCount(key) {
return nil
@ -51,8 +71,18 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
// Build the sequence (pending + new key)
sequence := h.pending + key
// Set working keymap
switch m.Mode() {
case action.NormalMode:
h.currentKeymap = h.normalKeymap
case action.VisualMode,
action.VisualLineMode,
action.VisualBlockMode:
h.currentKeymap = h.visualKeymap
}
// Check for exact match with full sequence
kind, binding := h.keymap.Lookup(sequence)
kind, binding := h.currentKeymap.Lookup(sequence)
if kind != "" {
h.pending = ""
h.buffer += key
@ -60,7 +90,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
}
// No exact match - could this be a prefix of something?
if h.keymap.HasPrefix(sequence) {
if h.currentKeymap.HasPrefix(sequence) {
h.pending = sequence
h.buffer += key
return nil // wait for more keys
@ -69,7 +99,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
// Not a prefix either - if we had pending, try just the new key
if h.pending != "" {
h.pending = ""
kind, binding = h.keymap.Lookup(key)
kind, binding = h.currentKeymap.Lookup(key)
if kind != "" {
h.buffer = key
return h.dispatch(m, kind, binding, key)
@ -107,7 +137,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
@ -131,7 +171,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
}
@ -212,3 +252,29 @@ func (h *Handler) Reset() {
func (h *Handler) Pending() string {
return h.buffer
}
func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
// Record the key for count replay (e.g. 5i...)
m.SetInsertKeys(append(m.InsertKeys(), key))
// Check the insert keymap first
kind, binding := h.insertKeymap.Lookup(key)
switch kind {
case "action":
return binding.(action.Action).Execute(m)
case "motion":
return binding.(action.Motion).Execute(m)
}
// Fallback: treat as a regular character to insert
return action.InsertChar{Char: key}.Execute(m)
}
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 (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/motion"
"git.gophernest.net/azpect/TextEditor/internal/operator"
)
type Keymap struct {
@ -19,14 +20,21 @@ func NewNormalKeymap() *Keymap {
"h": motion.MoveLeft{Count: 1},
"l": motion.MoveRight{Count: 1},
"G": motion.MoveToBottom{},
"gg": motion.MoveToTop{}, // multi-key example
"gg": motion.MoveToTop{},
"0": motion.MoveToLineStart{},
"$": motion.MoveToLineEnd{},
"_": motion.MoveToLineContentStart{},
"w": motion.MoveForwardWord{Count: 1},
"e": motion.MoveForwardWordEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
},
operators: map[string]action.Operator{
// "d": DeleteOp{},
"d": operator.DeleteOperator{},
// "c": ChangeOp{},
// "y": YankOp{},
// "p": PasteOp{},
// "s": SubstitueOp{},
// "~": SwapCaseOp{},
},
actions: map[string]action.Action{
"i": action.EnterInsert{},
@ -37,10 +45,67 @@ func NewNormalKeymap() *Keymap {
"O": action.OpenLineAbove{},
"x": action.DeleteChar{Count: 1},
"ctrl+c": action.Quit{},
":": action.EnterComandMode{},
"v": action.EnterVisualMode{},
"V": action.EnterVisualLineMode{},
"ctrl+v": action.EnterVisualBlockMode{},
},
}
}
func NewVisualKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"j": motion.MoveDown{Count: 1},
"k": motion.MoveUp{Count: 1},
"h": motion.MoveLeft{Count: 1},
"l": motion.MoveRight{Count: 1},
"G": motion.MoveToBottom{},
"gg": motion.MoveToTop{},
"0": motion.MoveToLineStart{},
"$": motion.MoveToLineEnd{},
"_": motion.MoveToLineContentStart{},
"w": motion.MoveForwardWord{Count: 1},
"e": motion.MoveForwardWordEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
},
operators: map[string]action.Operator{
"d": operator.DeleteOperator{},
"x": operator.DeleteOperator{},
// "c": ChangeOp{},
// "y": YankOp{},
// "p": PasteOp{},
// "s": SubstitueOp{},
// "~": SwapCaseOp{},
},
actions: map[string]action.Action{
"ctrl+c": action.Quit{},
// ":": action.EnterComandMode{}, // Different OP
},
}
}
func NewInsertKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"down": motion.MoveDown{Count: 1},
"up": motion.MoveUp{Count: 1},
"left": motion.MoveLeft{Count: 1},
"right": motion.MoveRight{Count: 1},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
"enter": action.InsertNewline{},
"backspace": action.InsertBackspace{},
"delete": action.InsertDelete{},
"tab": action.InsertTab{},
"ctrl+w": action.InsertDeletePreviousWord{},
"ctrl+c": action.Quit{},
},
}
}
// Lookup returns what type of binding a key is
func (km *Keymap) Lookup(key string) (kind string, value any) {
if m, ok := km.motions[key]; ok {

View File

@ -1,8 +1,8 @@
package motion
import (
tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/action"
tea "github.com/charmbracelet/bubbletea"
)
// MoveToTop implements Motion (gg)
@ -40,3 +40,26 @@ func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
m.ClampCursorX()
return nil
}
// MoveToLineContentStart implements Motion (_)
type MoveToLineContentStart struct{}
func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
line := m.Line(m.CursorY())
x := 0
for x < len(line) {
ch := line[x]
if ch != ' ' && ch != '\t' {
break
}
x++
}
// If we are on the last char, we overflew, back once
if x == len(line) && x > 0 {
x--
}
m.SetCursorX(x)
return nil
}

232
internal/motion/word.go Normal file
View File

@ -0,0 +1,232 @@
package motion
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
tea "github.com/charmbracelet/bubbletea"
)
func isWordChar(c byte) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_'
}
func isWordPunctuation(c byte) bool {
return c != ' ' && c != '\t' && !isWordChar(c)
}
func nextWordStart(m action.Model, x, y int) (int, int) {
line := m.Line(y)
// Skip current class
if x < len(line) {
if isWordChar(line[x]) {
for x < len(line) && isWordChar(line[x]) {
x++
}
} else if line[x] != ' ' && line[x] != '\t' {
// punctuation class
for x < len(line) && isWordPunctuation(line[x]) {
x++
}
}
}
// Skip whitespace and cross lines if needed
for {
// Walk over white space
for x < len(line) && (line[x] == ' ' || line[x] == '\t') {
x++
}
// Were on the new word, nothing else to do (no lines to cross
if x < len(line) {
break
}
// If next line is the end of the file, exit now
if y+1 >= m.LineCount() {
return x, y
}
// Move to first char of next line
y++
line = m.Line(y)
x = 0
// If the first char of the new line is no whitespace, stay here!
if len(line) > 0 && line[0] != ' ' && line[0] != '\t' {
break
}
}
return x, y
}
func nextWordEnd(m action.Model, x, y int) (int, int) {
line := m.Line(y)
// Advance once to avoid being stuck on the current end
x++
if x >= len(line) {
// At last line of file, pin cursor to end of file
if y+1 >= m.LineCount() {
return len(line) - 1, y
}
// Otherwise, move to next line
y++
x = 0
line = m.Line(y)
}
// Skip whitespace and cross lines if needed
for {
// Walk over white space
for x < len(line) && (line[x] == ' ' || line[x] == '\t') {
x++
}
// Were on the new word, nothing else to do (no lines to cross
if x < len(line) {
break
}
// If next line is the end of the file, exit now
if y+1 >= m.LineCount() {
return x, y
}
// Move to first char of next line
y++
line = m.Line(y)
x = 0
}
// Move to end of current char class, stop before it ends
if isWordChar(line[x]) {
for x+1 < len(line) && isWordChar(line[x+1]) {
x++
}
} else {
for x+1 < len(line) &&
line[x+1] != ' ' &&
line[x+1] != '\t' &&
!isWordChar(line[x+1]) {
x++
}
}
return x, y
}
func prevWordStart(m action.Model, x, y int) (int, int) {
line := m.Line(y)
// Back one to avoid being stuck on the current start
x--
if x < 0 {
if y == 0 {
return 0, 0 // beginning of file, stay put
}
y--
line = m.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y // landed on an empty line
}
}
// Skip whitespace backward, crossing lines if needed
for {
for x >= 0 && (line[x] == ' ' || line[x] == '\t') {
x--
}
if x >= 0 {
break // landed on a non-whitespace char
}
if y == 0 {
return 0, 0
}
y--
line = m.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
}
}
// Skip to the start of the current char class
if isWordChar(line[x]) {
for x-1 >= 0 && isWordChar(line[x-1]) {
x--
}
} else {
for x-1 >= 0 && isWordPunctuation(line[x-1]) {
x--
}
}
return x, y
}
type MoveForwardWord struct {
Count int
}
// Execute implements [action.Action].
func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
x := m.CursorX()
y := m.CursorY()
for i := 0; i < a.Count; i++ {
x, y = nextWordStart(m, x, y)
}
m.SetCursorX(x)
m.SetCursorY(y)
return nil
}
func (a MoveForwardWord) WithCount(n int) action.Action {
return MoveForwardWord{Count: n}
}
type MoveForwardWordEnd struct {
Count int
}
// Execute implements [action.Action].
func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
x := m.CursorX()
y := m.CursorY()
for i := 0; i < a.Count; i++ {
x, y = nextWordEnd(m, x, y)
}
m.SetCursorX(x)
m.SetCursorY(y)
return nil
}
func (a MoveForwardWordEnd) WithCount(n int) action.Action {
return MoveForwardWordEnd{Count: n}
}
type MoveBackwardWord struct {
Count int
}
// Execute implements [action.Action].
func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
x := m.CursorX()
y := m.CursorY()
for i := 0; i < a.Count; i++ {
x, y = prevWordStart(m, x, y)
}
m.SetCursorX(x)
m.SetCursorY(y)
return nil
}
func (a MoveBackwardWord) WithCount(n int) action.Action {
return MoveBackwardWord{Count: n}
}

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