From 84a7983a21f0b35d6e14d5126f29e04da5070155 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Tue, 10 Feb 2026 22:00:18 -0700 Subject: [PATCH] feat: lots of word actions. 'w', 'e', and 'b'. Includes tests. --- internal/editor/helpers_test.go | 2 +- internal/editor/integration_delete_test.go | 6 +- internal/editor/integration_insert_test.go | 40 +- .../editor/integration_motion_basic_test.go | 8 +- .../editor/integration_motion_jump_test.go | 10 +- .../editor/integration_motion_word_test.go | 341 ++++++++++++++++++ internal/editor/model.go | 40 +- internal/editor/style.go | 4 +- internal/editor/view.go | 10 +- internal/input/keymap.go | 3 + internal/motion/word.go | 232 ++++++++++++ 11 files changed, 644 insertions(+), 52 deletions(-) create mode 100644 internal/editor/integration_motion_word_test.go create mode 100644 internal/motion/word.go diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index ef44d8a..040eb6b 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -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)) } diff --git a/internal/editor/integration_delete_test.go b/internal/editor/integration_delete_test.go index ec2b2de..ba5b6e7 100644 --- a/internal/editor/integration_delete_test.go +++ b/internal/editor/integration_delete_test.go @@ -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) diff --git a/internal/editor/integration_insert_test.go b/internal/editor/integration_insert_test.go index 3cf27d8..eec3c29 100644 --- a/internal/editor/integration_insert_test.go +++ b/internal/editor/integration_insert_test.go @@ -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) diff --git a/internal/editor/integration_motion_basic_test.go b/internal/editor/integration_motion_basic_test.go index 934186b..edb4b5f 100644 --- a/internal/editor/integration_motion_basic_test.go +++ b/internal/editor/integration_motion_basic_test.go @@ -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) diff --git a/internal/editor/integration_motion_jump_test.go b/internal/editor/integration_motion_jump_test.go index 1237654..bb3e054 100644 --- a/internal/editor/integration_motion_jump_test.go +++ b/internal/editor/integration_motion_jump_test.go @@ -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) diff --git a/internal/editor/integration_motion_word_test.go b/internal/editor/integration_motion_word_test.go new file mode 100644 index 0000000..0fa80b5 --- /dev/null +++ b/internal/editor/integration_motion_word_test.go @@ -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()) + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go index 061669f..8eb7bed 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -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) { diff --git a/internal/editor/style.go b/internal/editor/style.go index 9381f14..5df1932 100644 --- a/internal/editor/style.go +++ b/internal/editor/style.go @@ -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) } diff --git a/internal/editor/view.go b/internal/editor/view.go index c502e3b..e1475d1 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -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, "~") } diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 80b3618..a6dcc6c 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -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{}, diff --git a/internal/motion/word.go b/internal/motion/word.go new file mode 100644 index 0000000..1be4021 --- /dev/null +++ b/internal/motion/word.go @@ -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} +}