diff --git a/FEATURES.md b/FEATURES.md index 8d23b2e..72f84ab 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -12,8 +12,8 @@ - [x] `w` - Forward to start of word - [x] `e` - Forward to end of word - [x] `b` - Backward to start of word -- [ ] `W` - Forward to start of WORD (whitespace-delimited) -- [ ] `E` - Forward to end of WORD +- [x] `W` - Forward to start of WORD (whitespace-delimited) +- [x] `E` - Forward to end of WORD - [ ] `B` - Backward to start of WORD - [ ] `ge` - Backward to end of word @@ -22,7 +22,7 @@ - [x] `$` - Move to end of line - [x] `_` - Move to first non-whitespace - [x] `^` - Move to first non-whitespace (alias for `_`) -- [ ] `|` - Move to column N +- [x] `|` - Move to column N ### File Movement - [x] `G` - Move to bottom of file (or line N with count) @@ -410,7 +410,7 @@ Buffers are in-memory representations of files. A buffer exists for each open fi ### Well Tested - [x] Basic motions (h, j, k, l) - [x] Word motions (w, e, b) -- [x] Jump motions (G, gg, 0, $, _, ^) +- [x] Jump motions (G, gg, 0, $, _, ^, |) - [x] Scroll actions (ctrl+u, ctrl+d) - [x] Delete operator (d, dd) - [x] Yank operator (y, yy) diff --git a/internal/editor/integration_motion_word_test.go b/internal/editor/integration_motion_word_test.go index 0fa80b5..2ec8617 100644 --- a/internal/editor/integration_motion_word_test.go +++ b/internal/editor/integration_motion_word_test.go @@ -339,3 +339,1349 @@ func TestMoveBackwardWord(t *testing.T) { } }) } + +// ============================================================================= +// W (WORD motion) Tests +// ============================================================================= +// In Vim, W moves forward to the start of the next WORD. +// A WORD is a sequence of non-blank characters separated by whitespace. +// Unlike 'w', punctuation does NOT create word boundaries for 'W'. +// +// Key difference from 'w': +// "hello,world" with 'w': stops at "hello", ",", "world" (3 words) +// "hello,world" with 'W': entire thing is 1 WORD +// +// ============================================================================= + +func TestMoveForwardWORD(t *testing.T) { + // --- Basic Movement --- + + 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) + // Should move to 'w' in "world" at index 6 + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test 'WW' moves forward two WORDs", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W", "W") + + m := getFinalModel(t, tm) + // Should move to 't' in "three" at index 8 + if m.CursorX() != 8 { + t.Errorf("CursorX() = %d, want 8", m.CursorX()) + } + }) + + t.Run("test 'WWW' moves forward three WORDs", func(t *testing.T) { + lines := []string{"one two three four"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W", "W", "W") + + m := getFinalModel(t, tm) + // Should move to 'f' in "four" at index 14 + if m.CursorX() != 14 { + t.Errorf("CursorX() = %d, want 14", m.CursorX()) + } + }) + + t.Run("test 'W' from middle of WORD", 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) + // From middle of "hello", should move to start of "world" + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test 'W' from last char of WORD", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // From 'o' in "hello", should move to 'w' in "world" + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test 'W' from space before WORD", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // From space, should move to 'w' in "world" + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + // --- Key Difference: Punctuation Handling --- + + t.Run("test 'W' skips punctuation (does NOT stop at dot)", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // Unlike 'w', 'W' treats "hello.world" as one WORD + // Should skip to "next" at index 12 + if m.CursorX() != 12 { + t.Errorf("CursorX() = %d, want 12", m.CursorX()) + } + }) + + t.Run("test 'W' skips comma", func(t *testing.T) { + lines := []string{"hello,world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "hello,world" is one WORD, should move to "next" + if m.CursorX() != 12 { + t.Errorf("CursorX() = %d, want 12", m.CursorX()) + } + }) + + t.Run("test 'W' skips multiple punctuation", func(t *testing.T) { + lines := []string{"hello...world!!! next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "hello...world!!!" is one WORD + if m.CursorX() != 17 { + t.Errorf("CursorX() = %d, want 17", m.CursorX()) + } + }) + + t.Run("test 'W' skips brackets and parens", func(t *testing.T) { + lines := []string{"func(arg) next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "func(arg)" is one WORD + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'W' skips operators", func(t *testing.T) { + lines := []string{"a+=b next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "a+=b" is one WORD + if m.CursorX() != 5 { + t.Errorf("CursorX() = %d, want 5", m.CursorX()) + } + }) + + t.Run("test 'W' skips quotes", func(t *testing.T) { + lines := []string{`"hello" next`} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // `"hello"` is one WORD + if m.CursorX() != 8 { + t.Errorf("CursorX() = %d, want 8", m.CursorX()) + } + }) + + t.Run("test 'W' handles underscore (same as w)", func(t *testing.T) { + lines := []string{"hello_world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "hello_world" is one WORD (same behavior as w for underscores) + if m.CursorX() != 12 { + t.Errorf("CursorX() = %d, want 12", m.CursorX()) + } + }) + + t.Run("test 'W' on pure punctuation sequence", func(t *testing.T) { + lines := []string{"... next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "..." is one WORD + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test 'W' from punctuation to next WORD", func(t *testing.T) { + lines := []string{"hello. world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // From '.' which is part of "hello.", should move to "world" + if m.CursorX() != 7 { + t.Errorf("CursorX() = %d, want 7", m.CursorX()) + } + }) + + // --- Line Crossing --- + + 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("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) + + t.Run("test 'W' skips empty lines", 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("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + }) + + t.Run("test 'W' skips multiple empty lines", 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("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 4 { + t.Errorf("CursorY() = %d, want 4", m.CursorY()) + } + }) + + t.Run("test 'W' skips whitespace-only lines", 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("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + }) + + t.Run("test 'W' from middle line to next", func(t *testing.T) { + lines := []string{"first", "second third", "fourth"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 7, Line: 1}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // From "third" on line 1, should move to "fourth" on line 2 + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + }) + + // --- End of File --- + + t.Run("test 'W' at end of file stays put", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // At last char, no more WORDs, should stay (or move to end) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'W' on last WORD of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // On "world", no next WORD, should go to end or stay + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'W' past end of single line file", func(t *testing.T) { + lines := []string{"word"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // Only one WORD, should move to end or stay + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + // --- Whitespace Handling --- + + t.Run("test 'W' skips multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + if m.CursorX() != 9 { + t.Errorf("CursorX() = %d, want 9", m.CursorX()) + } + }) + + t.Run("test 'W' skips tabs", func(t *testing.T) { + lines := []string{"hello\tworld"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // Tab is whitespace, "world" starts after tab + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test 'W' skips mixed whitespace", func(t *testing.T) { + lines := []string{"hello \t world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "hello \t world" - world starts at index 9 + // h=0, e=1, l=2, l=3, o=4, space=5, tab=6, space=7, space=8, w=9 + if m.CursorX() != 9 { + t.Errorf("CursorX() = %d, want 9", m.CursorX()) + } + }) + + t.Run("test 'W' with leading whitespace", func(t *testing.T) { + lines := []string{" hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // From start (in whitespace), should move to "hello" at index 3 + if m.CursorX() != 3 { + t.Errorf("CursorX() = %d, want 3", m.CursorX()) + } + }) + + t.Run("test 'W' from leading whitespace", func(t *testing.T) { + lines := []string{" hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // From middle of leading whitespace, move to "hello" + if m.CursorX() != 3 { + t.Errorf("CursorX() = %d, want 3", m.CursorX()) + } + }) + + // --- Count Handling --- + + t.Run("test '2W' moves forward two WORDs", func(t *testing.T) { + lines := []string{"one two three four"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "2", "W") + + m := getFinalModel(t, tm) + // Skip "one" and "two", land on "three" + if m.CursorX() != 8 { + t.Errorf("CursorX() = %d, want 8", m.CursorX()) + } + }) + + t.Run("test '3W' moves forward three WORDs", func(t *testing.T) { + lines := []string{"one two three four five"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "3", "W") + + m := getFinalModel(t, tm) + // Skip "one", "two", "three", land on "four" + if m.CursorX() != 14 { + t.Errorf("CursorX() = %d, want 14", m.CursorX()) + } + }) + + t.Run("test '5W' with fewer WORDs available", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "5", "W") + + m := getFinalModel(t, tm) + // Only 3 WORDs, should stop at/after last WORD + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test '2W' across lines", func(t *testing.T) { + lines := []string{"one", "two", "three"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "2", "W") + + m := getFinalModel(t, tm) + // Skip "one" and "two", land on "three" + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + }) + + t.Run("test '10W' moves as far as possible", func(t *testing.T) { + lines := []string{"a b c"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "1", "0", "W") + + m := getFinalModel(t, tm) + // Only 3 WORDs + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + // --- Comparison with 'w' --- + + t.Run("test 'W' vs 'w' on dotted text", func(t *testing.T) { + // This test documents the difference between W and w + lines := []string{"hello.world next"} + + // Test W behavior + tm1 := newTestModelWithLines(t, lines) + sendKeys(tm1, "W") + m1 := getFinalModel(t, tm1) + + // Test w behavior + tm2 := newTestModelWithLines(t, lines) + sendKeys(tm2, "w") + m2 := getFinalModel(t, tm2) + + // W should skip to "next", w should stop at "." + if m1.CursorX() == m2.CursorX() { + t.Errorf("W and w should behave differently on punctuation") + } + if m1.CursorX() != 12 { + t.Errorf("W: CursorX() = %d, want 12", m1.CursorX()) + } + if m2.CursorX() != 5 { + t.Errorf("w: CursorX() = %d, want 5", m2.CursorX()) + } + }) + + // --- Edge Cases --- + + t.Run("test 'W' on empty file", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + 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 'W' on single character", func(t *testing.T) { + lines := []string{"a"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // Single char, no next WORD + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'W' on single punctuation", func(t *testing.T) { + lines := []string{"."} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'W' on line with only whitespace after cursor", func(t *testing.T) { + lines := []string{"hello ", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // Should skip trailing whitespace and go to next line + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) + + t.Run("test 'W' complex code-like text", func(t *testing.T) { + lines := []string{"func(a,b) { return a+b; }"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // "func(a,b)" is one WORD, next is "{" + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'WW' on complex code-like text", func(t *testing.T) { + lines := []string{"func(a,b) { return a+b; }"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "W", "W") + + m := getFinalModel(t, tm) + // After "func(a,b)" and "{", should be on "return" + if m.CursorX() != 12 { + t.Errorf("CursorX() = %d, want 12", m.CursorX()) + } + }) + + t.Run("test 'W' with URL-like text", func(t *testing.T) { + lines := []string{"visit https://example.com/path?q=1 today"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // URL is one WORD, should move to "today" + if m.CursorX() != 35 { + t.Errorf("CursorX() = %d, want 35", m.CursorX()) + } + }) + + t.Run("test 'W' with email-like text", func(t *testing.T) { + lines := []string{"contact user@example.com now"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 8, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // Email is one WORD, should move to "now" + if m.CursorX() != 25 { + t.Errorf("CursorX() = %d, want 25", m.CursorX()) + } + }) + + t.Run("test 'W' with file path", func(t *testing.T) { + lines := []string{"edit /home/user/file.txt now"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "W") + + m := getFinalModel(t, tm) + // Path is one WORD, should move to "now" + if m.CursorX() != 25 { + t.Errorf("CursorX() = %d, want 25", m.CursorX()) + } + }) +} + +func TestMoveForwardWORDWithOperator(t *testing.T) { + t.Run("test 'dW' deletes WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "W") + + m := getFinalModel(t, tm) + // Should delete "hello.world " (including trailing space) + if m.Line(0) != "next" { + t.Errorf("Line(0) = %q, want 'next'", m.Line(0)) + } + }) + + t.Run("test 'dW' vs 'dw' on dotted text", func(t *testing.T) { + // dW deletes entire "hello.world " + lines1 := []string{"hello.world next"} + tm1 := newTestModelWithLines(t, lines1) + sendKeys(tm1, "d", "W") + m1 := getFinalModel(t, tm1) + + // dw deletes only "hello" + lines2 := []string{"hello.world next"} + tm2 := newTestModelWithLines(t, lines2) + sendKeys(tm2, "d", "w") + m2 := getFinalModel(t, tm2) + + if m1.Line(0) != "next" { + t.Errorf("dW: Line(0) = %q, want 'next'", m1.Line(0)) + } + if m2.Line(0) != ".world next" { + t.Errorf("dw: Line(0) = %q, want '.world next'", m2.Line(0)) + } + }) + + t.Run("test 'd2W' deletes two WORDs", func(t *testing.T) { + lines := []string{"one two three four"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "2", "W") + + m := getFinalModel(t, tm) + if m.Line(0) != "three four" { + t.Errorf("Line(0) = %q, want 'three four'", m.Line(0)) + } + }) + + t.Run("test 'yW' yanks WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "y", "W") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // Should yank "hello.world " (with trailing space) + if len(reg.Content) != 1 || reg.Content[0] != "hello.world " { + t.Errorf("register content = %q, want 'hello.world '", reg.Content) + } + }) + + t.Run("test 'y2W' yanks two WORDs", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "y", "2", "W") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 1 || reg.Content[0] != "one two " { + t.Errorf("register content = %q, want 'one two '", reg.Content) + } + }) + + t.Run("test 'cW' changes WORD (placeholder - c not implemented)", func(t *testing.T) { + // Skip if change operator not implemented + t.Skip("Change operator not implemented yet") + }) +} + +func TestMoveForwardWORDInVisualMode(t *testing.T) { + t.Run("test 'vW' selects WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "W") + + m := getFinalModel(t, tm) + if m.Mode() != action.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.AnchorX() != 0 { + t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) + } + // Should extend to start of "next" + if m.CursorX() != 12 { + t.Errorf("CursorX() = %d, want 12", m.CursorX()) + } + }) + + t.Run("test 'vWd' deletes selection", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "W", "d") + + m := getFinalModel(t, tm) + // Visual selection from 0 to 12, delete "hello.world " + if m.Line(0) != "ext" { + t.Errorf("Line(0) = %q, want 'ext'", m.Line(0)) + } + }) + + t.Run("test 'v2W' selects two WORDs", func(t *testing.T) { + lines := []string{"one two three four"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "2", "W") + + m := getFinalModel(t, tm) + if m.AnchorX() != 0 { + t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) + } + // Should extend to start of "three" + if m.CursorX() != 8 { + t.Errorf("CursorX() = %d, want 8", m.CursorX()) + } + }) + + t.Run("test 'VW' in visual line mode", func(t *testing.T) { + lines := []string{"hello world", "next line"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "V", "W") + + m := getFinalModel(t, tm) + if m.Mode() != action.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // W should still move cursor, but line mode selects whole lines + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) +} + +// ============================================================================= +// E (WORD end) Motion Tests +// ============================================================================= +// E moves forward to the end of the current WORD. +// Key difference from 'e': E treats ALL non-whitespace as one WORD. +// On "hello.world", 'e' stops at 'o' (end of "hello"), 'E' stops at 'd' (end of "hello.world"). + +func TestMoveForwardWORDEnd(t *testing.T) { + // ------------------------------------------------------------------------- + // Basic Movement + // ------------------------------------------------------------------------- + + t.Run("test 'E' moves to end of WORD", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "hello" ends at index 4 + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test 'EE' moves to end of second WORD", func(t *testing.T) { + lines := []string{"hello world test"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E") + + m := getFinalModel(t, tm) + // "world" ends at index 10 + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'EEE' moves to end of third WORD", func(t *testing.T) { + lines := []string{"hello world test"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E", "E") + + m := getFinalModel(t, tm) + // "test" ends at index 15 + if m.CursorX() != 15 { + t.Errorf("CursorX() = %d, want 15", m.CursorX()) + } + }) + + t.Run("test 'E' from middle of 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) + // From index 2 in "hello", E goes to index 4 (end of "hello") + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test 'E' from last char of WORD moves to next WORD end", 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) + // From end of "hello", E goes to end of "world" (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'E' from space moves to next WORD end", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // From space, E goes to end of "world" (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + // ------------------------------------------------------------------------- + // Punctuation Handling (KEY DIFFERENCE from 'e') + // ------------------------------------------------------------------------- + + t.Run("test 'E' goes through punctuation (does NOT stop at dot)", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // E treats "hello.world" as one WORD, ends at 'd' (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'e' vs 'E' on dotted text", func(t *testing.T) { + // First test 'e' + lines := []string{"hello.world next"} + tm1 := newTestModelWithLines(t, lines) + sendKeys(tm1, "e") + m1 := getFinalModel(t, tm1) + + // 'e' should stop at end of "hello" (index 4) + if m1.CursorX() != 4 { + t.Errorf("'e': CursorX() = %d, want 4", m1.CursorX()) + } + + // Now test 'E' + tm2 := newTestModelWithLines(t, lines) + sendKeys(tm2, "E") + m2 := getFinalModel(t, tm2) + + // 'E' should go to end of "hello.world" (index 10) + if m2.CursorX() != 10 { + t.Errorf("'E': CursorX() = %d, want 10", m2.CursorX()) + } + }) + + t.Run("test 'E' goes through comma", func(t *testing.T) { + lines := []string{"foo,bar baz"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "foo,bar" ends at index 6 + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test 'E' goes through multiple punctuation", func(t *testing.T) { + lines := []string{"a...b...c next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "a...b...c" ends at index 8 + if m.CursorX() != 8 { + t.Errorf("CursorX() = %d, want 8", m.CursorX()) + } + }) + + t.Run("test 'E' goes through brackets and parens", func(t *testing.T) { + lines := []string{"func(arg) next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "func(arg)" ends at index 8 + if m.CursorX() != 8 { + t.Errorf("CursorX() = %d, want 8", m.CursorX()) + } + }) + + t.Run("test 'E' goes through operators", func(t *testing.T) { + lines := []string{"a+b*c next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "a+b*c" ends at index 4 + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test 'E' goes through quotes", func(t *testing.T) { + lines := []string{`"hello" next`} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // `"hello"` ends at index 6 + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test 'E' on pure punctuation sequence", func(t *testing.T) { + lines := []string{"... next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "..." ends at index 2 + if m.CursorX() != 2 { + t.Errorf("CursorX() = %d, want 2", m.CursorX()) + } + }) + + t.Run("test 'E' from punctuation within WORD", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // From '.', E goes to 'd' (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + // ------------------------------------------------------------------------- + // Line Crossing + // ------------------------------------------------------------------------- + + t.Run("test 'E' moves to next line when at end", 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) + // Should be at end of "world" on line 1, index 4 + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test 'E' skips empty lines", func(t *testing.T) { + lines := []string{"hello", "", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E") + + m := getFinalModel(t, tm) + // First E: end of "hello" (0,4) + // Second E: skips empty line, end of "world" (2,4) + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test 'E' skips whitespace-only lines", func(t *testing.T) { + lines := []string{"hello", " ", "world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E") + + m := getFinalModel(t, tm) + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + // ------------------------------------------------------------------------- + // End of File Behavior + // ------------------------------------------------------------------------- + + t.Run("test 'E' at end of file stays put", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + // Go to end of file + sendKeys(tm, "E", "E", "E") + + m := getFinalModel(t, tm) + // Should stay at end of "hello" (index 4) + 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 'E' on last WORD of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E", "E") + + m := getFinalModel(t, tm) + // Should be at end of "world" (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'E' on single character file", func(t *testing.T) { + lines := []string{"a"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // Already at end, should stay at 0 + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + // ------------------------------------------------------------------------- + // Whitespace Handling + // ------------------------------------------------------------------------- + + t.Run("test 'E' skips multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E") + + m := getFinalModel(t, tm) + // "world" ends at index 13 + if m.CursorX() != 13 { + t.Errorf("CursorX() = %d, want 13", m.CursorX()) + } + }) + + t.Run("test 'E' skips tabs", func(t *testing.T) { + lines := []string{"hello\tworld"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E") + + m := getFinalModel(t, tm) + // "world" ends at index 10 + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'E' with leading whitespace", func(t *testing.T) { + lines := []string{" hello world"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // From start (in whitespace), E goes to end of "hello" (index 7) + if m.CursorX() != 7 { + t.Errorf("CursorX() = %d, want 7", m.CursorX()) + } + }) + + // ------------------------------------------------------------------------- + // Count Handling + // ------------------------------------------------------------------------- + + t.Run("test '2E' moves to end of second WORD", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "2", "E") + + m := getFinalModel(t, tm) + // "two" ends at index 6 + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test '3E' moves to end of third WORD", func(t *testing.T) { + lines := []string{"one two three four"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "3", "E") + + m := getFinalModel(t, tm) + // "three" ends at index 12 + if m.CursorX() != 12 { + t.Errorf("CursorX() = %d, want 12", m.CursorX()) + } + }) + + t.Run("test '5E' with fewer WORDs available", func(t *testing.T) { + lines := []string{"one two"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "5", "E") + + m := getFinalModel(t, tm) + // Only 2 WORDs, should stop at end of "two" (index 6) + if m.CursorX() != 6 { + t.Errorf("CursorX() = %d, want 6", m.CursorX()) + } + }) + + t.Run("test '2E' across lines", func(t *testing.T) { + lines := []string{"hello", "world test"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "2", "E") + + m := getFinalModel(t, tm) + // First E: end of "hello" (0,4) + // Second E: end of "world" (1,4) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + // ------------------------------------------------------------------------- + // Complex/Real-World Cases + // ------------------------------------------------------------------------- + + t.Run("test 'E' with URL-like text", func(t *testing.T) { + lines := []string{"https://example.com/path next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // Entire URL is one WORD, ends at index 23 + if m.CursorX() != 23 { + t.Errorf("CursorX() = %d, want 23", m.CursorX()) + } + }) + + t.Run("test 'E' with email-like text", func(t *testing.T) { + lines := []string{"user@example.com next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // Entire email is one WORD, ends at index 15 + if m.CursorX() != 15 { + t.Errorf("CursorX() = %d, want 15", m.CursorX()) + } + }) + + t.Run("test 'E' with file path", func(t *testing.T) { + lines := []string{"/home/user/file.txt next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // Entire path is one WORD, ends at index 18 + if m.CursorX() != 18 { + t.Errorf("CursorX() = %d, want 18", m.CursorX()) + } + }) + + t.Run("test 'E' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "foo.bar(baz)" ends at index 11 + if m.CursorX() != 11 { + t.Errorf("CursorX() = %d, want 11", m.CursorX()) + } + }) + + t.Run("test 'EE' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next.thing"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E", "E") + + m := getFinalModel(t, tm) + // Second WORD "next.thing" ends at index 22 + if m.CursorX() != 22 { + t.Errorf("CursorX() = %d, want 22", m.CursorX()) + } + }) + + t.Run("test 'E' on array access", func(t *testing.T) { + lines := []string{"arr[0] next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // "arr[0]" ends at index 5 + if m.CursorX() != 5 { + t.Errorf("CursorX() = %d, want 5", m.CursorX()) + } + }) + + t.Run("test 'E' on method chain", func(t *testing.T) { + lines := []string{"obj.method().chain() next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "E") + + m := getFinalModel(t, tm) + // Entire chain is one WORD, ends at index 19 + if m.CursorX() != 19 { + t.Errorf("CursorX() = %d, want 19", m.CursorX()) + } + }) +} + +func TestMoveForwardWORDEndWithOperator(t *testing.T) { + t.Run("test 'dE' deletes to end of WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "E") + + m := getFinalModel(t, tm) + // Should delete "hello.world" leaving " next" + if m.Line(0) != " next" { + t.Errorf("Line(0) = %q, want ' next'", m.Line(0)) + } + }) + + t.Run("test 'dE' vs 'de' on dotted text", func(t *testing.T) { + // First test 'de' + lines := []string{"hello.world next"} + tm1 := newTestModelWithLines(t, lines) + sendKeys(tm1, "d", "e") + m1 := getFinalModel(t, tm1) + + // 'de' should delete "hello" leaving ".world next" + if m1.Line(0) != ".world next" { + t.Errorf("'de': Line(0) = %q, want '.world next'", m1.Line(0)) + } + + // Now test 'dE' + tm2 := newTestModelWithLines(t, lines) + sendKeys(tm2, "d", "E") + m2 := getFinalModel(t, tm2) + + // 'dE' should delete "hello.world" leaving " next" + if m2.Line(0) != " next" { + t.Errorf("'dE': Line(0) = %q, want ' next'", m2.Line(0)) + } + }) + + t.Run("test 'd2E' deletes two WORDs", func(t *testing.T) { + lines := []string{"one.a two.b three"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "d", "2", "E") + + m := getFinalModel(t, tm) + // Should delete "one.a two.b" leaving " three" + if m.Line(0) != " three" { + t.Errorf("Line(0) = %q, want ' three'", m.Line(0)) + } + }) + + t.Run("test 'yE' yanks WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "y", "E") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // E is inclusive, should yank "hello.world" (no trailing space) + if len(reg.Content) != 1 || reg.Content[0] != "hello.world" { + t.Errorf("register content = %q, want 'hello.world'", reg.Content) + } + }) + + t.Run("test 'y2E' yanks two WORDs", func(t *testing.T) { + lines := []string{"foo.bar baz.qux rest"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "y", "2", "E") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // E is inclusive, should yank "foo.bar baz.qux" (no trailing space) + if len(reg.Content) != 1 || reg.Content[0] != "foo.bar baz.qux" { + t.Errorf("register content = %q, want 'foo.bar baz.qux'", reg.Content) + } + }) +} + +func TestMoveForwardWORDEndInVisualMode(t *testing.T) { + t.Run("test 'vE' selects to end of WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "E") + + m := getFinalModel(t, tm) + if m.Mode() != action.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + // Cursor at end of "hello.world" (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test 'vEd' deletes selection", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "E", "d") + + m := getFinalModel(t, tm) + // Should delete "hello.world" leaving " next" + if m.Line(0) != " next" { + t.Errorf("Line(0) = %q, want ' next'", m.Line(0)) + } + }) + + t.Run("test 'v2E' selects to end of second WORD", func(t *testing.T) { + lines := []string{"foo.bar baz.qux rest"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "v", "2", "E") + + m := getFinalModel(t, tm) + // Cursor at end of "baz.qux" (index 14) + if m.CursorX() != 14 { + t.Errorf("CursorX() = %d, want 14", m.CursorX()) + } + }) + + t.Run("test 'VE' in visual line mode", func(t *testing.T) { + lines := []string{"hello.world", "next line"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "V", "E") + + m := getFinalModel(t, tm) + if m.Mode() != action.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // Cursor at end of "hello.world" (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 51b2386..070c1f9 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -28,7 +28,9 @@ func NewNormalKeymap() *Keymap { "^": motion.MoveToLineContentStart{}, "|": motion.MoveToColumn{Count: 0}, "w": motion.MoveForwardWord{Count: 1}, + "W": motion.MoveForwardWORD{Count: 1}, "e": motion.MoveForwardWordEnd{Count: 1}, + "E": motion.MoveForwardWORDEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, "ctrl+u": motion.ScrollUpHalfPage{}, "ctrl+d": motion.ScrollDownHalfPage{}, @@ -74,7 +76,9 @@ func NewVisualKeymap() *Keymap { "^": motion.MoveToLineContentStart{}, "|": motion.MoveToColumn{Count: 0}, "w": motion.MoveForwardWord{Count: 1}, + "W": motion.MoveForwardWORD{Count: 1}, "e": motion.MoveForwardWordEnd{Count: 1}, + "E": motion.MoveForwardWORDEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, }, operators: map[string]action.Operator{ diff --git a/internal/motion/word.go b/internal/motion/word.go index 601d4de..1c40bfb 100644 --- a/internal/motion/word.go +++ b/internal/motion/word.go @@ -64,6 +64,45 @@ func nextWordStart(m action.Model, x, y int) (int, int) { return x, y } +func nextWORDStart(m action.Model, x, y int) (int, int) { + line := m.Line(y) + + // Skip current WORD (all non-whitespace is one class for W) + for x < len(line) && line[x] != ' ' && line[x] != '\t' { + 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) @@ -121,6 +160,54 @@ func nextWordEnd(m action.Model, x, y int) (int, int) { 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 WORD (all non-whitespace is one class) + for x+1 < len(line) && line[x+1] != ' ' && line[x+1] != '\t' { + x++ + } + + return x, y +} + func prevWordStart(m action.Model, x, y int) (int, int) { line := m.Line(y) @@ -193,6 +280,28 @@ func (a MoveForwardWord) WithCount(n int) action.Action { return MoveForwardWord{Count: n} } +// MoveForwardWORD implements Motion (W) - charwise +type MoveForwardWORD struct { + Count int +} + +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) Type() action.MotionType { return action.CharwiseExclusive } + +func (a MoveForwardWORD) WithCount(n int) action.Action { + return MoveForwardWORD{Count: n} +} + // MoveForwardWordEnd implements Motion (e) - charwise type MoveForwardWordEnd struct { Count int @@ -215,6 +324,28 @@ func (a MoveForwardWordEnd) WithCount(n int) action.Action { return MoveForwardWordEnd{Count: n} } +// MoveForwardWORDEnd implements Motion (E) - charwise +type MoveForwardWORDEnd struct { + Count int +} + +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) Type() action.MotionType { return action.CharwiseInclusive } + +func (a MoveForwardWORDEnd) WithCount(n int) action.Action { + return MoveForwardWORDEnd{Count: n} +} + // MoveBackwardWord implements Motion (b) - charwise type MoveBackwardWord struct { Count int