feat: lots of word actions. 'w', 'e', and 'b'. Includes tests.

This commit is contained in:
Hayden Hargreaves 2026-02-10 22:00:18 -07:00
parent 3d3948d7e3
commit 84a7983a21
11 changed files with 644 additions and 52 deletions

View File

@ -44,7 +44,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)
@ -396,7 +396,7 @@ func TestInsertModeBackspace(t *testing.T) {
func TestInsertModeDelete(t *testing.T) {
t.Run("test delete deletes character", func(t *testing.T) {
lines := []string{"world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm)
@ -407,7 +407,7 @@ func TestInsertModeDelete(t *testing.T) {
t.Run("test delete at end of line joins lines", 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", "delete", "esc")
m := getFinalModel(t, tm)
@ -435,7 +435,7 @@ func TestInsertModeDelete(t *testing.T) {
t.Run("test delete at end of last line does nothing", 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", "delete", "esc")
m := getFinalModel(t, tm)
@ -446,7 +446,7 @@ func TestInsertModeDelete(t *testing.T) {
t.Run("test multiple delete", 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, "i", "delete", "delete", "delete", "esc")
m := getFinalModel(t, tm)

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

@ -41,7 +41,7 @@ func TestMoveToBottom(t *testing.T) {
t.Run("test 'G' clamps cursor.x", 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)
@ -99,7 +99,7 @@ func TestMoveToTop(t *testing.T) {
t.Run("test 'gg' clamps cursor.x", 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)
@ -128,7 +128,7 @@ func TestMoveToLineStart(t *testing.T) {
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)
@ -184,7 +184,7 @@ func TestMoveToLineEnd(t *testing.T) {
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)
@ -196,7 +196,7 @@ func TestMoveToLineEnd(t *testing.T) {
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)

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

@ -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,22 @@ 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
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 +39,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(),
}
}
@ -194,6 +200,16 @@ func (m *Model) processInsertKey(key string) {
m.SetLine(y, l[:x]+l[x+1:])
}
// TODO: This handling is wrong, we should be able to delete an entire tab with a single space
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))
// Regular character
default:
if x < len(l) {

View File

@ -1,8 +1,8 @@
package editor
import (
"github.com/charmbracelet/lipgloss"
"git.gophernest.net/azpect/TextEditor/internal/action"
"github.com/charmbracelet/lipgloss"
)
func (m Model) cursorStyle() lipgloss.Style {
@ -26,7 +26,7 @@ func (m Model) gutterStyle(currentLine bool) lipgloss.Style {
fg = lipgloss.Color("#d69d00")
}
return lipgloss.NewStyle().
Width(m.s_gutter).
Width(m.gutterSize).
Background(lipgloss.Color("236")).
Foreground(fg)
}

View File

@ -21,17 +21,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))
@ -49,7 +49,7 @@ func (m Model) View() string {
}
}
} else {
format := fmt.Sprintf("%%-%ds ", m.s_gutter-1)
format := fmt.Sprintf("%%-%ds ", m.gutterSize-1)
fmt.Fprintf(&view, format, "~")
}

View File

@ -22,6 +22,9 @@ func NewNormalKeymap() *Keymap {
"gg": motion.MoveToTop{},
"0": motion.MoveToLineStart{},
"$": motion.MoveToLineEnd{},
"w": motion.MoveForwardWord{Count: 1},
"e": motion.MoveForwardWordEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
},
operators: map[string]action.Operator{
// "d": DeleteOp{},

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