diff --git a/internal/editor/integration_motion_word_test.go b/internal/editor/integration_motion_word_test.go index 73cdffb..08f813c 100644 --- a/internal/editor/integration_motion_word_test.go +++ b/internal/editor/integration_motion_word_test.go @@ -1685,3 +1685,828 @@ func TestMoveForwardWORDEndInVisualMode(t *testing.T) { } }) } + +// --- B Motion Tests --- + +func TestMoveBackwardWORD(t *testing.T) { + t.Run("test 'B' moves backward one WORD", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should move to start of "world" (index 6) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'BB' moves backward two WORDs", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "B", "B") + + m := getFinalModel(t, tm) + // Should move to start of "one" (index 0) + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test '2B' moves backward two WORDs with count", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "2", "B") + + m := getFinalModel(t, tm) + // Should move to start of "one" (index 0) + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' on punctuation-heavy text", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // "hello.world" is one WORD, should move to "next" (index 12) + if m.ActiveWindow().Cursor.Col != 12 { + t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' vs 'b' on punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + + // Test 'b' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "b") + m1 := getFinalModel(t, tm1) + + // 'b' moves to start of "next" (index 12) + if m1.ActiveWindow().Cursor.Col != 12 { + t.Errorf("'b': CursorX() = %d, want 12", m1.ActiveWindow().Cursor.Col) + } + + // Test 'B' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "B") + m2 := getFinalModel(t, tm2) + + // 'B' treats "hello.world" as one WORD, moves to index 12 + if m2.ActiveWindow().Cursor.Col != 12 { + t.Errorf("'B': CursorX() = %d, want 12", m2.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' crosses lines backward", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 1}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should move to start of "hello" on previous line + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' at beginning of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) // Cursor at 0,0 + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should stay at 0,0 + if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'B' with multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should skip spaces and move to "hello" + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' on empty lines", func(t *testing.T) { + lines := []string{"hello", "", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 2}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should skip empty line and move to "hello" + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // "foo.bar(baz)" is one WORD, should move to "next" (index 13) + if m.ActiveWindow().Cursor.Col != 13 { + t.Errorf("CursorX() = %d, want 13", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'BB' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next.thing"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 22, Line: 0}) + sendKeys(tm, "B", "B") + + m := getFinalModel(t, tm) + // Should move to start of first WORD "foo.bar(baz)" (index 0) + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestMoveBackwardWORDWithOperator(t *testing.T) { + t.Run("test 'dB' deletes backward WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "d", "B") + + m := getFinalModel(t, tm) + // Should delete " next" leaving "hello.worldt" + if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'dB' vs 'db' on dotted text", func(t *testing.T) { + lines := []string{"hello.world next"} + + // First test 'db' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "d", "b") + m1 := getFinalModel(t, tm1) + + // 'db' should delete "next" leaving "hello.world " + if m1.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("'db': Line(0) = %q, want 'hello.world t'", m1.ActiveBuffer().Lines[0].String()) + } + + // Now test 'dB' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "d", "B") + m2 := getFinalModel(t, tm2) + + // 'dB' should delete " next" leaving "hello.worldt" + if m2.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("'dB': Line(0) = %q, want 'hello.worldt'", m2.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'd2B' deletes two WORDs backward", func(t *testing.T) { + lines := []string{"one.a two.b three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "d", "2", "B") + + m := getFinalModel(t, tm) + // Should delete "two.b three" leaving "one.a e" + if m.ActiveBuffer().Lines[0].String() != "one.a e" { + t.Errorf("Line(0) = %q, want 'one.a e'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'yB' yanks WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "y", "B") + + m := getFinalModel(t, tm) + // Text should remain unchanged + if m.ActiveBuffer().Lines[0].String() != "hello.world next" { + t.Errorf("Line(0) = %q, want 'hello.world next'", m.ActiveBuffer().Lines[0].String()) + } + // Cursor should be at start of yanked region (index 12) + if m.ActiveWindow().Cursor.Col != 12 { + t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'cB' changes WORD backward", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "c", "B") + + m := getFinalModel(t, tm) + // Should delete " next" and enter insert mode + if m.Mode() != core.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestMoveBackwardWORDVisualMode(t *testing.T) { + t.Run("test 'vB' selects backward WORD", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "B") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + // Cursor at start of "next" (index 12) + if m.ActiveWindow().Cursor.Col != 12 { + t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vBd' deletes selected WORD", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "B", "d") + + m := getFinalModel(t, tm) + // Should delete " next" leaving "hello.worldt" + if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'v2B' selects backward two WORDs", func(t *testing.T) { + lines := []string{"foo.bar baz.qux rest"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 19, Line: 0}) + sendKeys(tm, "v", "2", "B") + + m := getFinalModel(t, tm) + // Cursor at start of "baz.qux" (index 8) + if m.ActiveWindow().Cursor.Col != 8 { + t.Errorf("CursorX() = %d, want 8", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'VB' in visual line mode", func(t *testing.T) { + lines := []string{"hello.world", "next line"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 1}) + sendKeys(tm, "V", "B") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // Should select both lines + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + }) +} + +// --- ge Motion Tests --- + +func TestMoveBackwardWordEnd(t *testing.T) { + t.Run("test 'ge' moves backward to previous word end", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gege' moves backward two word ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "g", "e", "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test '2ge' moves backward two word ends with count", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "2", "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' on punctuation-heavy text", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' crosses lines backward", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "hello" on previous line (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' at beginning of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) // Cursor at 0,0 + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should stay at 0,0 + if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'ge' with multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should skip spaces and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' on empty lines", func(t *testing.T) { + lines := []string{"hello", "", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should skip empty line and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' respects word classes", func(t *testing.T) { + lines := []string{"foo-bar baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "bar" (index 6) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' on method chain", func(t *testing.T) { + lines := []string{"obj.method().chain() next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 24, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of ")" (index 19) + if m.ActiveWindow().Cursor.Col != 19 { + t.Errorf("CursorX() = %d, want 19", m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestMoveBackwardWordEndWithOperator(t *testing.T) { + t.Run("test 'dge' deletes backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "d", "g", "e") + + m := getFinalModel(t, tm) + // Should delete from 't' back to end of "world" (inclusive) + if m.ActiveBuffer().Lines[0].String() != "hello world t" { + t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'd2ge' deletes backward two word ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "d", "2", "g", "e") + + m := getFinalModel(t, tm) + // Should delete backward to end of "one" + if m.ActiveBuffer().Lines[0].String() != "one e" { + t.Errorf("Line(0) = %q, want 'one e'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'yge' yanks backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "y", "g", "e") + + m := getFinalModel(t, tm) + // Text should remain unchanged + if m.ActiveBuffer().Lines[0].String() != "hello world next" { + t.Errorf("Line(0) = %q, want 'hello world next'", m.ActiveBuffer().Lines[0].String()) + } + // Cursor should be at end of "world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'cge' changes backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "c", "g", "e") + + m := getFinalModel(t, tm) + // Should delete and enter insert mode + if m.Mode() != core.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.ActiveBuffer().Lines[0].String() != "hello world t" { + t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestMoveBackwardWordEndVisualMode(t *testing.T) { + t.Run("test 'vge' selects backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "e") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + // Cursor at end of "world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vged' deletes selection", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "e", "d") + + m := getFinalModel(t, tm) + // Should delete selection leaving "hello world t" + if m.ActiveBuffer().Lines[0].String() != "hello world t" { + t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'v2ge' selects backward two word ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "v", "2", "g", "e") + + m := getFinalModel(t, tm) + // Cursor at end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'Vge' in visual line mode", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "V", "g", "e") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // Should select both lines + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + }) +} + +// --- gE Motion Tests --- + +func TestMoveBackwardWORDEnd(t *testing.T) { + t.Run("test 'gE' moves backward to previous WORD end", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gEgE' moves backward two WORD ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "g", "E", "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test '2gE' moves backward two WORD ends with count", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "2", "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' on punctuation-heavy text", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // "hello.world" is one WORD ending at index 10 + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' vs 'ge' on punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + + // Test 'ge' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "g", "e") + m1 := getFinalModel(t, tm1) + + // 'ge' treats punctuation as separate word + if m1.ActiveWindow().Cursor.Col != 10 { + t.Errorf("'ge': CursorX() = %d, want 10", m1.ActiveWindow().Cursor.Col) + } + + // Test 'gE' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "g", "E") + m2 := getFinalModel(t, tm2) + + // 'gE' treats "hello.world" as one WORD + if m2.ActiveWindow().Cursor.Col != 10 { + t.Errorf("'gE': CursorX() = %d, want 10", m2.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' crosses lines backward", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "hello" on previous line (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' at beginning of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) // Cursor at 0,0 + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should stay at 0,0 + if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'gE' with multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should skip spaces and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' on empty lines", func(t *testing.T) { + lines := []string{"hello", "", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should skip empty line and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // "foo.bar(baz)" is one WORD ending at index 11 + if m.ActiveWindow().Cursor.Col != 11 { + t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' on method chain", func(t *testing.T) { + lines := []string{"obj.method().chain() next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 24, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Entire chain is one WORD, ends at index 19 + if m.ActiveWindow().Cursor.Col != 19 { + t.Errorf("CursorX() = %d, want 19", m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestMoveBackwardWORDEndWithOperator(t *testing.T) { + t.Run("test 'dgE' deletes backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "d", "g", "E") + + m := getFinalModel(t, tm) + // Should delete from 't' back to end of "hello.world" (inclusive) + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'dgE' vs 'dge' on dotted text", func(t *testing.T) { + lines := []string{"hello.world next"} + + // First test 'dge' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "d", "g", "e") + m1 := getFinalModel(t, tm1) + + // 'dge' should delete to end of "world" + if m1.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("'dge': Line(0) = %q, want 'hello.world t'", m1.ActiveBuffer().Lines[0].String()) + } + + // Now test 'dgE' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "d", "g", "E") + m2 := getFinalModel(t, tm2) + + // 'dgE' should delete to end of "hello.world" (treats as one WORD) + if m2.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("'dgE': Line(0) = %q, want 'hello.world t'", m2.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'd2gE' deletes backward two WORD ends", func(t *testing.T) { + lines := []string{"one.a two.b three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "d", "2", "g", "E") + + m := getFinalModel(t, tm) + // Should delete backward to end of "one.a" + if m.ActiveBuffer().Lines[0].String() != "one.a e" { + t.Errorf("Line(0) = %q, want 'one.a e'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'ygE' yanks backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "y", "g", "E") + + m := getFinalModel(t, tm) + // Text should remain unchanged + if m.ActiveBuffer().Lines[0].String() != "hello.world next" { + t.Errorf("Line(0) = %q, want 'hello.world next'", m.ActiveBuffer().Lines[0].String()) + } + // Cursor should be at end of "hello.world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'cgE' changes backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "c", "g", "E") + + m := getFinalModel(t, tm) + // Should delete and enter insert mode + if m.Mode() != core.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestMoveBackwardWORDEndVisualMode(t *testing.T) { + t.Run("test 'vgE' selects backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "E") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + // Cursor at end of "hello.world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vgEd' deletes selection", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "E", "d") + + m := getFinalModel(t, tm) + // Should delete selection leaving "hello.world t" + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'v2gE' selects backward two WORD ends", func(t *testing.T) { + lines := []string{"foo.bar baz.qux rest"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 19, Line: 0}) + sendKeys(tm, "v", "2", "g", "E") + + m := getFinalModel(t, tm) + // Cursor at end of "foo.bar" (index 6) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'VgE' in visual line mode", func(t *testing.T) { + lines := []string{"hello.world", "next line"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "V", "g", "E") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // Should select both lines + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + }) +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 8f0db71..4876bed 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -38,6 +38,9 @@ func NewNormalKeymap() *Keymap { "e": motion.MoveForwardWordEnd{Count: 1}, "E": motion.MoveForwardWORDEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, + "B": motion.MoveBackwardWORD{Count: 1}, + "ge": motion.MoveBackwardWordEnd{Count: 1}, + "gE": motion.MoveBackwardWORDEnd{Count: 1}, "ctrl+u": motion.ScrollUpHalfPage{}, "ctrl+d": motion.ScrollDownHalfPage{}, ";": action.RepeatFind{Count: 1, Reverse: false}, @@ -124,6 +127,9 @@ func NewVisualKeymap() *Keymap { "e": motion.MoveForwardWordEnd{Count: 1}, "E": motion.MoveForwardWORDEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, + "B": motion.MoveBackwardWORD{Count: 1}, + "ge": motion.MoveBackwardWordEnd{Count: 1}, + "gE": motion.MoveBackwardWORDEnd{Count: 1}, // TODO: O and o. These are fun ones! Should be simple too }, operators: map[string]action.Operator{ diff --git a/internal/motion/word.go b/internal/motion/word.go index 0ba5426..8984276 100644 --- a/internal/motion/word.go +++ b/internal/motion/word.go @@ -412,3 +412,219 @@ func (a MoveBackwardWord) Type() core.MotionType { return core.CharwiseExclusive func (a MoveBackwardWord) WithCount(n int) action.Action { return MoveBackwardWord{Count: n} } + +// prevWORDStart: Finds the start of the previous WORD from position (x,y), +// treating all non-whitespace as a single class. +func prevWORDStart(buf *core.Buffer, x, y int) (int, int) { + line := buf.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 = buf.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 = buf.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 WORD (all non-whitespace is one class) + for x-1 >= 0 && line[x-1] != ' ' && line[x-1] != '\t' { + x-- + } + + return x, y +} + +// prevWordEnd: Finds the end of the previous word from position (x,y), +// respecting word character classes. +func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { + line := buf.Line(y) + + // Back one to avoid being stuck on the current end + x-- + if x < 0 { + if y == 0 { + return 0, 0 // beginning of file, stay put + } + y-- + line = buf.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, this is our word end! + } + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if len(line) == 0 { + return 0, y // empty line acts as a word boundary + } + } + + // We're now at the end of a word - that's the answer! + return x, y +} + +// prevWORDEnd: Finds the end of the previous WORD from position (x,y), +// treating all non-whitespace as a single class. +func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) { + line := buf.Line(y) + + // Back one to avoid being stuck on the current end + x-- + if x < 0 { + if y == 0 { + return 0, 0 // beginning of file, stay put + } + y-- + line = buf.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, this is our WORD end! + } + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if len(line) == 0 { + return 0, y // empty line acts as a word boundary + } + } + + // We're now at the end of a WORD - that's the answer! + return x, y +} + +// MoveBackwardWORD implements Motion (B) - charwise +type MoveBackwardWORD struct { + Count int +} + +// MoveBackwardWORD.Execute: Moves the cursor backward by Count WORDs (B motion). +func (a MoveBackwardWORD) Execute(m action.Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x := win.Cursor.Col + y := win.Cursor.Line + for i := 0; i < a.Count; i++ { + x, y = prevWORDStart(buf, x, y) + } + win.SetCursorCol(x) + win.SetCursorLine(y) + return nil +} + +// MoveBackwardWORD.Type: Returns CharwiseExclusive for backward WORD motion. +func (a MoveBackwardWORD) Type() core.MotionType { return core.CharwiseExclusive } + +// MoveBackwardWORD.WithCount: Returns a new MoveBackwardWORD with the given count. +func (a MoveBackwardWORD) WithCount(n int) action.Action { + return MoveBackwardWORD{Count: n} +} + +// MoveBackwardWordEnd implements Motion (ge) - charwise +type MoveBackwardWordEnd struct { + Count int +} + +// MoveBackwardWordEnd.Execute: Moves the cursor to the end of the previous word (ge motion). +func (a MoveBackwardWordEnd) Execute(m action.Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x := win.Cursor.Col + y := win.Cursor.Line + for i := 0; i < a.Count; i++ { + x, y = prevWordEnd(buf, x, y) + } + win.SetCursorCol(x) + win.SetCursorLine(y) + return nil +} + +// MoveBackwardWordEnd.Type: Returns CharwiseInclusive for backward word-end motion. +func (a MoveBackwardWordEnd) Type() core.MotionType { return core.CharwiseInclusive } + +// MoveBackwardWordEnd.WithCount: Returns a new MoveBackwardWordEnd with the given count. +func (a MoveBackwardWordEnd) WithCount(n int) action.Action { + return MoveBackwardWordEnd{Count: n} +} + +// BUG: gE and ge are broken + +// MoveBackwardWORDEnd implements Motion (gE) - charwise +type MoveBackwardWORDEnd struct { + Count int +} + +// MoveBackwardWORDEnd.Execute: Moves the cursor to the end of the previous WORD (gE motion). +func (a MoveBackwardWORDEnd) Execute(m action.Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x := win.Cursor.Col + y := win.Cursor.Line + for i := 0; i < a.Count; i++ { + x, y = prevWORDEnd(buf, x, y) + } + win.SetCursorCol(x) + win.SetCursorLine(y) + return nil +} + +// MoveBackwardWORDEnd.Type: Returns CharwiseInclusive for backward WORD-end motion. +func (a MoveBackwardWORDEnd) Type() core.MotionType { return core.CharwiseInclusive } + +// MoveBackwardWORDEnd.WithCount: Returns a new MoveBackwardWORDEnd with the given count. +func (a MoveBackwardWORDEnd) WithCount(n int) action.Action { + return MoveBackwardWORDEnd{Count: n} +}