diff --git a/internal/action/interface.go b/internal/action/interface.go index b676228..e50032d 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -108,3 +108,9 @@ type Resolvable interface { Motion Resolve(m Model) Motion } + +type TextObject interface { + // GetRange calculates both endpoints for the text object + // modifier: "i" (inner) or "a" (around) + GetRange(m Model, cursor core.Position, modifier string) (start, end core.Position, mtype core.MotionType) +} diff --git a/internal/editor/integration_textobject_test.go b/internal/editor/integration_textobject_test.go new file mode 100644 index 0000000..b0a5a42 --- /dev/null +++ b/internal/editor/integration_textobject_test.go @@ -0,0 +1,596 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/core" +) + +// ============================================================================ +// Word Text Object Tests (iw/aw) +// ============================================================================ + +func TestTextObjectInnerWord(t *testing.T) { + t.Run("test 'viw' selects inner word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "v", "i", "w") + + m := getFinalModel(t, tm) + if !m.Mode().IsVisualMode() { + t.Errorf("Expected visual mode") + } + // Should select "hello" (cols 0-4) + if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'diw' deletes inner word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "d", "i", "w") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != " world" { + t.Errorf("lines[0] = %q, want ' world'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'ciw' changes inner word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "c", "i", "w") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != " world" { + t.Errorf("lines[0] = %q, want ' world'", m.ActiveBuffer().Lines[0]) + } + if m.Mode() != core.InsertMode { + t.Errorf("Expected insert mode after ciw") + } + }) + + t.Run("test 'yiw' yanks inner word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "y", "i", "w") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatalf("Default register not found") + } + if len(reg.Content) != 1 || reg.Content[0] != "hello" { + t.Errorf("register content = %v, want ['hello']", reg.Content) + } + }) + + t.Run("test 'viw' at start of word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "v", "i", "w") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'viw' at end of word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) + sendKeys(tm, "v", "i", "w") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'viw' on underscore word", func(t *testing.T) { + lines := []string{"hello_world test"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0}) + sendKeys(tm, "v", "i", "w") + + m := getFinalModel(t, tm) + // Should select "hello_world" (cols 0-10) + if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=10", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'viw' on punctuation", func(t *testing.T) { + lines := []string{"foo-bar baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", "w") + + m := getFinalModel(t, tm) + // Should select just "-" (col 3) + if m.ActiveWindow().Anchor.Col != 3 || m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("anchor=%d, cursor=%d, want anchor=3, cursor=3", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestTextObjectAroundWord(t *testing.T) { + t.Run("test 'vaw' selects around word with trailing space", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "v", "a", "w") + + m := getFinalModel(t, tm) + // Should select "hello " (cols 0-5, includes trailing space) + if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'daw' deletes around word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "d", "a", "w") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "world" { + t.Errorf("lines[0] = %q, want 'world'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'daw' on last word (no trailing space)", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0}) + sendKeys(tm, "d", "a", "w") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "hello " { + t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// WORD Text Object Tests (iW/aW) +// ============================================================================ + +func TestTextObjectInnerWORD(t *testing.T) { + t.Run("test 'viW' selects inner WORD", func(t *testing.T) { + lines := []string{"foo-bar baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "v", "i", "W") + + m := getFinalModel(t, tm) + // Should select "foo-bar" (cols 0-6) + if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=6", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'diW' deletes inner WORD", func(t *testing.T) { + lines := []string{"foo-bar.baz test"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) + sendKeys(tm, "d", "i", "W") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != " test" { + t.Errorf("lines[0] = %q, want ' test'", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestTextObjectAroundWORD(t *testing.T) { + t.Run("test 'vaW' selects around WORD with trailing space", func(t *testing.T) { + lines := []string{"foo-bar baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "v", "a", "W") + + m := getFinalModel(t, tm) + // Should select "foo-bar " (cols 0-7, includes trailing space) + if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=7", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'daW' deletes around WORD", func(t *testing.T) { + lines := []string{"foo-bar baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "d", "a", "W") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "baz" { + t.Errorf("lines[0] = %q, want 'baz'", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// Delimiter Text Object Tests (i"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", "<") + + m := getFinalModel(t, tm) + // Should select "hello" (cols 1-5) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vi>' works same as 'vi<'", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", ">") + + m := getFinalModel(t, tm) + // Should select "hello" (cols 1-5) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'di<' deletes inner angle brackets", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "i", "<") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "<>" { + t.Errorf("lines[0] = %q, want '<>'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'da<' deletes around angle brackets", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "a", "<") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'vi<' on empty brackets does nothing", func(t *testing.T) { + lines := []string{"<>"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", "<") + + m := getFinalModel(t, tm) + // Should remain unchanged + if m.ActiveBuffer().Lines[0] != "<>" { + t.Errorf("lines[0] = %q, want '<>'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'vi<' in nested brackets", func(t *testing.T) { + lines := []string{">"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) + sendKeys(tm, "v", "i", "<") + + m := getFinalModel(t, tm) + // Should select "bar" (cols 5-7, the innermost pair) + if m.ActiveWindow().Anchor.Col != 5 || m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("anchor=%d, cursor=%d, want anchor=5, cursor=7", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestTextObjectParentheses(t *testing.T) { + t.Run("test 'vi(' selects inner parentheses", func(t *testing.T) { + lines := []string{"(hello)"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", "(") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vi)' works same as 'vi('", func(t *testing.T) { + lines := []string{"(hello)"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", ")") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'di(' deletes inner parentheses", func(t *testing.T) { + lines := []string{"func(hello)"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "func()" { + t.Errorf("lines[0] = %q, want 'func()'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'da(' deletes around parentheses", func(t *testing.T) { + lines := []string{"func(hello)"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0}) + sendKeys(tm, "d", "a", "(") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "func" { + t.Errorf("lines[0] = %q, want 'func'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'vi(' on empty parens does nothing", func(t *testing.T) { + lines := []string{"()"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "()" { + t.Errorf("lines[0] = %q, want '()'", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestTextObjectBraces(t *testing.T) { + t.Run("test 'vi{' selects inner braces", func(t *testing.T) { + lines := []string{"{hello}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", "{") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'di{' deletes inner braces", func(t *testing.T) { + lines := []string{"{hello}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "{}" { + t.Errorf("lines[0] = %q, want '{}'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'da{' deletes around braces", func(t *testing.T) { + lines := []string{"{hello}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "a", "{") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestTextObjectBrackets(t *testing.T) { + t.Run("test 'vi[' selects inner brackets", func(t *testing.T) { + lines := []string{"[hello]"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", "[") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'di[' deletes inner brackets", func(t *testing.T) { + lines := []string{"[hello]"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "i", "[") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "[]" { + t.Errorf("lines[0] = %q, want '[]'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'da[' deletes around brackets", func(t *testing.T) { + lines := []string{"[hello]"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "a", "[") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestTextObjectDoubleQuotes(t *testing.T) { + t.Run("test 'vi\"' selects inner double quotes", func(t *testing.T) { + lines := []string{`"hello"`} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", `"`) + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'di\"' deletes inner double quotes", func(t *testing.T) { + lines := []string{`"hello"`} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "i", `"`) + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != `""` { + t.Errorf("lines[0] = %q, want '\"\"'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'da\"' deletes around double quotes", func(t *testing.T) { + lines := []string{`"hello"`} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "a", `"`) + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'vi\"' on empty quotes does nothing", func(t *testing.T) { + lines := []string{`""`} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", `"`) + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != `""` { + t.Errorf("lines[0] = %q, want '\"\"'", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestTextObjectSingleQuotes(t *testing.T) { + t.Run("test 'vi'' selects inner single quotes", func(t *testing.T) { + lines := []string{"'hello'"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", "'") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'di'' deletes inner single quotes", func(t *testing.T) { + lines := []string{"'hello'"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "i", "'") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "''" { + t.Errorf("lines[0] = %q, want \"''\"", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'da'' deletes around single quotes", func(t *testing.T) { + lines := []string{"'hello'"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "a", "'") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) +} + +func TestTextObjectBackticks(t *testing.T) { + t.Run("test 'vi`' selects inner backticks", func(t *testing.T) { + lines := []string{"`hello`"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "v", "i", "`") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5", + m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'di`' deletes inner backticks", func(t *testing.T) { + lines := []string{"`hello`"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "i", "`") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "``" { + t.Errorf("lines[0] = %q, want '``'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'da`' deletes around backticks", func(t *testing.T) { + lines := []string{"`hello`"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "d", "a", "`") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) +} + +// ============================================================================ +// Edge Cases and Complex Scenarios +// ============================================================================ + +func TestTextObjectEdgeCases(t *testing.T) { + t.Run("test 'diw' on single character word", func(t *testing.T) { + lines := []string{"a b c"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", "w") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != " b c" { + t.Errorf("lines[0] = %q, want ' b c'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'ci<' then type replacement", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) + sendKeys(tm, "c", "i", "<") + sendKeyString(tm, "world") + sendKeys(tm, "esc") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test 'yi(' then paste", func(t *testing.T) { + lines := []string{"func(arg)", "test"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) + sendKeys(tm, "y", "i", "(") + sendKeys(tm, "j", "p") + + m := getFinalModel(t, tm) + // 'p' pastes after cursor, so "arg" is pasted after 't' -> "testarg" + if m.ActiveBuffer().Lines[1] != "testarg" { + t.Errorf("lines[1] = %q, want 'testarg'", m.ActiveBuffer().Lines[1]) + } + }) + + t.Run("test text object cursor outside delimiters does nothing", func(t *testing.T) { + lines := []string{"before (hello) after"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + // Should remain unchanged since cursor is not inside parens + if m.ActiveBuffer().Lines[0] != "before (hello) after" { + t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0]) + } + }) +} diff --git a/internal/editor/update.go b/internal/editor/update.go index 305a5aa..08d98ac 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -1,83 +1,83 @@ package editor import ( - "git.gophernest.net/azpect/TextEditor/internal/core" - tea "github.com/charmbracelet/bubbletea" + "git.gophernest.net/azpect/TextEditor/internal/core" + tea "github.com/charmbracelet/bubbletea" ) // Model.Update: Handles BubbleTea messages including window resizes and key // presses. Routes input to the handler and adjusts scroll after updates. func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd + var cmd tea.Cmd - switch msg := msg.(type) { + switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.termHeight = msg.Height - m.termWidth = msg.Width + case tea.WindowSizeMsg: + m.termHeight = msg.Height + m.termWidth = msg.Width - // TODO: Implement a layout method that handles this - // - // func (m *Model) layoutWindows() { - // if len(m.windows) == 0 { - // return - // } - // - // if len(m.windows) == 1 { - // // Single window - full screen - // m.windows[0].Width = m.termWidth - // m.windows[0].Height = m.termHeight - // return - // } - // - // // Multiple windows - distribute space - // // This is where you'd implement split layout logic - // // For example, horizontal split: - // halfHeight := m.termHeight / 2 - // for i, win := range m.windows { - // win.Width = m.termWidth - // if i < len(m.windows)-1 { - // win.Height = halfHeight - // } else { - // // Last window gets remainder - // win.Height = m.termHeight - (halfHeight * (len(m.windows) - 1)) - // } - // } - // } - for i := range m.windows { - m.windows[i].Height = msg.Height - m.windows[i].Width = msg.Width - } + // TODO: Implement a layout method that handles this + // + // func (m *Model) layoutWindows() { + // if len(m.windows) == 0 { + // return + // } + // + // if len(m.windows) == 1 { + // // Single window - full screen + // m.windows[0].Width = m.termWidth + // m.windows[0].Height = m.termHeight + // return + // } + // + // // Multiple windows - distribute space + // // This is where you'd implement split layout logic + // // For example, horizontal split: + // halfHeight := m.termHeight / 2 + // for i, win := range m.windows { + // win.Width = m.termWidth + // if i < len(m.windows)-1 { + // win.Height = halfHeight + // } else { + // // Last window gets remainder + // win.Height = m.termHeight - (halfHeight * (len(m.windows) - 1)) + // } + // } + // } + for i := range m.windows { + m.windows[i].Height = msg.Height + m.windows[i].Width = msg.Width + } - case tea.KeyMsg: - // TODO: This needs to be removed, but for now its required for the tests. - // Ctrl+C always quits regardless of mode - if msg.Type == tea.KeyCtrlC { - return m, tea.Quit - } + case tea.KeyMsg: + // TODO: This needs to be removed, but for now its required for the tests. + // Ctrl+C always quits regardless of mode + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } - // TODO: This is not great - // TODO: Any vim action should exit also - // Simple override for command output mode for now - if m.Mode() == core.CommandOutputMode { - // TODO: Implement g/G/d/u - switch msg.String() { - case "enter": - m.SetMode(core.NormalMode) - m.SetCommandOutput(&core.CommandOutput{}) - case "j": - m.CommandOutput().ScrollDown(m.termHeight) - case "k": - m.CommandOutput().ScrollUp() - } - } else { - cmd = m.input.Handle(m, msg.String()) - } - } + // TODO: This is not great + // TODO: Any vim action should exit also + // Simple override for command output mode for now + if m.Mode() == core.CommandOutputMode { + // TODO: Implement g/G/d/u + switch msg.String() { + case "enter": + m.SetMode(core.NormalMode) + m.SetCommandOutput(&core.CommandOutput{}) + case "j": + m.CommandOutput().ScrollDown(m.termHeight) + case "k": + m.CommandOutput().ScrollUp() + } + } else { + cmd = m.input.Handle(m, msg.String()) + } + } - // Keep cursor in view after any update - win := m.ActiveWindow() - win.AdjustScroll() + // Keep cursor in view after any update + win := m.ActiveWindow() + win.AdjustScroll() - return m, cmd + return m, cmd } diff --git a/internal/input/handler.go b/internal/input/handler.go index 3102be4..0d961b8 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -14,7 +14,8 @@ const ( StateCount StateOperatorPending StateMotionCount - StateWaitingForChar // Waiting for character argument (f/t/F/T) + StateWaitingForChar // Waiting for character argument (f/t/F/T) + StateWaitingForTextObject // Waiting for text object (after i/a) ) // Handler: Manages input processing with a state machine for vim-style commands. @@ -28,6 +29,7 @@ type Handler struct { buffer string // for display (what user has typed) pending string // partial key sequence (e.g., "g" waiting for second key) charMotionType string // which char motion is waiting: "f", "t", "F", or "T" + modifier string // which modifier used for text object: "i" or "a" // Keymaps normalKeymap *Keymap @@ -77,6 +79,22 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { return h.handleCharMotion(m, key) } + if h.state == StateWaitingForTextObject { + return h.handleTextObjectKey(m, key) + } + + // i/a after operator or in visual mode = text object modifier + if (key == "i" || key == "a") && h.pending == "" { + if h.state == StateOperatorPending || + h.state == StateMotionCount || + m.Mode().IsVisualMode() { + h.modifier = key + h.state = StateWaitingForTextObject + h.buffer += key + return nil + } + } + // Try to accumulate count (only if no pending sequence) if h.pending == "" && h.tryAccumulateCount(key) { return nil @@ -139,6 +157,8 @@ func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) return h.handleInitial(m, kind, binding, key) case StateOperatorPending, StateMotionCount: return h.handleAfterOperator(m, kind, binding, key) + case StateWaitingForTextObject: + return h.handleTextObject(m, kind, binding, key) } h.Reset() return nil @@ -220,6 +240,11 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, return nil } + // Do not quit when we see a/i (allow for text objects) + if kind == "modifier" { + return nil + } + // Motion after operator if kind == "motion" { mot := binding.(action.Motion) @@ -321,6 +346,60 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd { return cmd } +// Handler.handleTextObject: Handles input when waiting for text object after i/a. +// Processes text objects like 'w', ')', '"', etc. and applies pending operator if any. +func (h *Handler) handleTextObject(m action.Model, kind string, binding any, key string) tea.Cmd { + // Not sure what count is fore + // count := h.effectiveCount() + + if kind != "text_object" { + // Invalid - expected a text object + h.Reset() + return nil + } + + textObj := binding.(action.TextObject) + win := m.ActiveWindow() + + // Calculate the region + start, end, mtype := textObj.GetRange(m, win.Cursor, h.modifier) + + // If we have an operator pending (e.g., "diw") + if h.operator != nil { + cmd := h.operator.Operate(m, start, end, mtype) + h.Reset() + return cmd + } + + // In visual mode (e.g., "viw") + if m.Mode().IsVisualMode() { + // Set anchor and cursor to define the selection + win.Anchor = start + win.Cursor = end + h.Reset() + return nil + } + + // Shouldn't reach here - text object without operator or visual mode + h.Reset() + return nil +} + +// Handler.handleTextObjectKey: Handles the key press when waiting for a text object. +// Looks up the text object directly (bypassing normal motion lookup). +func (h *Handler) handleTextObjectKey(m action.Model, key string) tea.Cmd { + // Look up text object directly + textObj, ok := h.currentKeymap.textObjects[key] + if !ok { + // Not a valid text object + h.Reset() + return nil + } + + // Call the existing handleTextObject with the found text object + return h.handleTextObject(m, "text_object", textObj, key) +} + // Handler.tryAccumulateCount: Attempts to add a digit to the count. Returns // true if successful, false if the key is not a digit or is an invalid count. func (h *Handler) tryAccumulateCount(key string) bool { @@ -379,6 +458,7 @@ func (h *Handler) Reset() { h.buffer = "" h.pending = "" h.charMotionType = "" + h.modifier = "" } // Handler.Pending: Returns the accumulated input buffer for display. diff --git a/internal/input/keymap.go b/internal/input/keymap.go index cf2e433..38a63ce 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -5,14 +5,17 @@ import ( "git.gophernest.net/azpect/TextEditor/internal/command" "git.gophernest.net/azpect/TextEditor/internal/motion" "git.gophernest.net/azpect/TextEditor/internal/operator" + "git.gophernest.net/azpect/TextEditor/internal/textobject" ) // Keymap: Maps key sequences to motions, operators, and actions. type Keymap struct { motions map[string]action.Motion operators map[string]action.Operator - actions map[string]action.Action // standalone actions: i.e., 'i', 'a' - charMotions map[string]action.Motion // motions that need character argument: f/t/F/T + actions map[string]action.Action // standalone actions: i.e., 'i', 'a' + charMotions map[string]action.Motion // motions that need character argument: f/t/F/T + modifiers map[string]any // modifiers for text objects: i/a + textObjects map[string]action.TextObject // motions that need text objects: i.e., 'viw' } // NewNormalKeymap: Creates a keymap for normal mode with all standard vim bindings. @@ -73,6 +76,26 @@ func NewNormalKeymap() *Keymap { "t": action.FindChar{Forward: true, Inclusive: false, Repeated: false}, "T": action.FindChar{Forward: false, Inclusive: false, Repeated: false}, }, + modifiers: map[string]any{ + "i": nil, + "a": nil, + }, + textObjects: map[string]action.TextObject{ + "w": textobject.Word{}, + "W": textobject.WORD{}, + // TODO: 's' and 'p' + "{": textobject.Delimiter{Char: '{'}, + "}": textobject.Delimiter{Char: '}'}, + "(": textobject.Delimiter{Char: '('}, + ")": textobject.Delimiter{Char: ')'}, + "[": textobject.Delimiter{Char: '['}, + "]": textobject.Delimiter{Char: ']'}, + "<": textobject.Delimiter{Char: '<'}, + ">": textobject.Delimiter{Char: '>'}, + "\"": textobject.Delimiter{Char: '"'}, + "'": textobject.Delimiter{Char: '\''}, + "`": textobject.Delimiter{Char: '`'}, + }, } } @@ -114,6 +137,25 @@ func NewVisualKeymap() *Keymap { "t": action.FindChar{Forward: true, Inclusive: false}, "T": action.FindChar{Forward: false, Inclusive: false}, }, + modifiers: map[string]any{ + "i": nil, + "a": nil, + }, + textObjects: map[string]action.TextObject{ + "w": textobject.Word{}, + "W": textobject.WORD{}, + "{": textobject.Delimiter{Char: '{'}, + "}": textobject.Delimiter{Char: '}'}, + "(": textobject.Delimiter{Char: '('}, + ")": textobject.Delimiter{Char: ')'}, + "[": textobject.Delimiter{Char: '['}, + "]": textobject.Delimiter{Char: ']'}, + "<": textobject.Delimiter{Char: '<'}, + ">": textobject.Delimiter{Char: '>'}, + "\"": textobject.Delimiter{Char: '"'}, + "'": textobject.Delimiter{Char: '\''}, + "`": textobject.Delimiter{Char: '`'}, + }, } } @@ -173,6 +215,12 @@ func (km *Keymap) Lookup(key string) (kind string, value any) { if cm, ok := km.charMotions[key]; ok { return "char_motion", cm } + if mo, ok := km.modifiers[key]; ok { + return "modifier", mo + } + if to, ok := km.textObjects[key]; ok { + return "text_object", to + } return "", nil } @@ -198,6 +246,16 @@ func (km *Keymap) HasPrefix(prefix string) bool { return true } } + for key := range km.modifiers { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + return true + } + } + for key := range km.textObjects { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + return true + } + } return false } diff --git a/internal/textobject/delimiter.go b/internal/textobject/delimiter.go new file mode 100644 index 0000000..5fc67fa --- /dev/null +++ b/internal/textobject/delimiter.go @@ -0,0 +1,123 @@ +package textobject + +import ( + "fmt" + "slices" + + "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/core" +) + +// Map opposite char +var DirectionalDelimiterMap map[rune]rune = map[rune]rune{ + '(': ')', + '[': ']', + '{': '}', + '<': '>', +} + +var singleDelimiterList []rune = []rune{'"', '\'', '`'} + +func getStartDelimiterFromEnd(d rune) (rune, bool) { + if slices.Contains(singleDelimiterList, d) { + return d, true + } + + for start, end := range DirectionalDelimiterMap { + if end == d { + return start, true + } + } + + return ' ', false +} + +func getEndDelimiterFromStart(d rune) (rune, bool) { + if slices.Contains(singleDelimiterList, d) { + return d, true + } + + end, found := DirectionalDelimiterMap[d] + return end, found +} + +// Delimiter implements text object for words (iw/aw) +type Delimiter struct { + Char rune +} + +// TODO: This should allow for many lines, not just a single line +func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) { + buf := m.ActiveBuffer() + line := buf.Lines[cursor.Line] + + // Determine which is a starting delimiter and which ends + _, isStartingDelimiter := DirectionalDelimiterMap[to.Char] + var ( + startDelim rune + endDelim rune + startFound bool = true + endFound bool = true + ) + + if isStartingDelimiter { + startDelim = to.Char + endDelim, endFound = getEndDelimiterFromStart(to.Char) + } else { + endDelim = to.Char + startDelim, startFound = getStartDelimiterFromEnd(to.Char) + } + + if !endFound || !startFound { + m.SetCommandOutput(&core.CommandOutput{ + Lines: []string{fmt.Sprintf("Could not find delimiters from '%c'", to.Char)}, + Inline: true, + IsError: false, + }) + return cursor, cursor, core.CharwiseExclusive + } + + // Find object boundaries + start, sOk := findDelimiterStart(line, startDelim, cursor.Col, modifier == "a") + end, eOk := findDelimiterEnd(line, endDelim, cursor.Col, modifier == "a") + + // Handle the case where they are not found + if !sOk || !eOk { + return cursor, cursor, core.CharwiseExclusive + } + + // This happens when nothing is between the delimiter, fixes the bugs we found + if start.Col > end.Col { + return cursor, cursor, core.CharwiseExclusive + } + + // Word object's don't span lines + start.Line = cursor.Line + end.Line = cursor.Line + + return start, end, core.CharwiseInclusive +} + +func findDelimiterStart(line string, delimiter rune, col int, includeDelimiter bool) (core.Position, bool) { + for i := col; i >= 0; i-- { + if rune(line[i]) == delimiter { + if includeDelimiter { + return core.Position{Line: 0, Col: i}, true + } + return core.Position{Line: 0, Col: i + 1}, true + } + } + return core.Position{Line: 0, Col: 0}, false +} + +func findDelimiterEnd(line string, delimiter rune, col int, includeDelimiter bool) (core.Position, bool) { + for i := col; i < len(line); i++ { + if rune(line[i]) == delimiter { + if includeDelimiter { + return core.Position{Line: 0, Col: i}, true + } + return core.Position{Line: 0, Col: i - 1}, true + } + } + return core.Position{Line: 0, Col: 0}, false +} diff --git a/internal/textobject/word.go b/internal/textobject/word.go new file mode 100644 index 0000000..67855fa --- /dev/null +++ b/internal/textobject/word.go @@ -0,0 +1,205 @@ +package textobject + +import ( + "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/core" +) + +// Word implements text object for words (iw/aw) +type Word struct{} + +func (to Word) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) { + buf := m.ActiveBuffer() + line := buf.Lines[cursor.Line] + + // Find word boundaries + start := findWordStart(line, cursor.Col) + end := findWordEnd(line, cursor.Col, modifier == "a") + + // Word object's don't span lines + start.Line = cursor.Line + end.Line = cursor.Line + + return start, end, core.CharwiseInclusive +} + +// Word implements text object for WORDs (iW/aW) +type WORD struct{} + +func (to WORD) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) { + buf := m.ActiveBuffer() + line := buf.Lines[cursor.Line] + + // Find word boundaries + start := findWORDStart(line, cursor.Col) + end := findWORDEnd(line, cursor.Col, modifier == "a") + + // Word object's don't span lines + start.Line = cursor.Line + end.Line = cursor.Line + + return start, end, core.CharwiseInclusive +} + +// isWordChar: Returns true if the character is a word character (alphanumeric +// or underscore). COPIED FROM internal/motion/word.go +func isWordChar(c byte) bool { + return (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_' +} + +func findWordStart(line string, col int) core.Position { + if col >= len(line) || col < 0 { + return core.Position{Line: 0, Col: 0} + } + + curChar := line[col] + + // Don't start on whitespace (shouldn't happen for text objects) + if curChar == ' ' || curChar == '\t' { + return core.Position{Line: 0, Col: col} + } + + // Determine if we're on word char or punctuation + onWordChar := isWordChar(curChar) + + // Move backwards while in the same character class + i := col + for i > 0 { + prevChar := line[i-1] + + // Stop at whitespace + if prevChar == ' ' || prevChar == '\t' { + break + } + + // Stop if character class changes + if isWordChar(prevChar) != onWordChar { + break + } + + i-- + } + + return core.Position{Line: 0, Col: i} +} + +func findWordEnd(line string, col int, includeWhitespace bool) core.Position { + if col >= len(line) || col < 0 { + return core.Position{Line: 0, Col: 0} + } + + curChar := line[col] + + // Don't start on whitespace + if curChar == ' ' || curChar == '\t' { + return core.Position{Line: 0, Col: col} + } + + // Determine if we're on word char or punctuation + onWordChar := isWordChar(curChar) + + // Move forward while in the same character class + i := col + for i < len(line) { + c := line[i] + + // Stop at whitespace + if c == ' ' || c == '\t' { + break + } + + // Stop if character class changes + if isWordChar(c) != onWordChar { + break + } + + i++ + } + + // i is now one past the end, so back up + i-- + + // If including whitespace, skip trailing spaces/tabs + if includeWhitespace { + i++ // Move forward to whitespace + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + i-- // Back to last whitespace + } + + return core.Position{Line: 0, Col: i} +} + +func findWORDStart(line string, col int) core.Position { + if col >= len(line) || col < 0 { + return core.Position{Line: 0, Col: 0} + } + + curChar := line[col] + + // Don't start on whitespace (shouldn't happen for text objects) + if curChar == ' ' || curChar == '\t' { + return core.Position{Line: 0, Col: col} + } + + // For WORD, all non-whitespace is one class + // Just move backwards until we hit whitespace or start of line + i := col + for i > 0 { + prevChar := line[i-1] + + // Stop at whitespace + if prevChar == ' ' || prevChar == '\t' { + break + } + + i-- + } + + return core.Position{Line: 0, Col: i} +} + +func findWORDEnd(line string, col int, includeWhitespace bool) core.Position { + if col >= len(line) || col < 0 { + return core.Position{Line: 0, Col: 0} + } + + curChar := line[col] + + // Don't start on whitespace + if curChar == ' ' || curChar == '\t' { + return core.Position{Line: 0, Col: col} + } + + // For WORD, all non-whitespace is one class + // Move forward until we hit whitespace or end of line + i := col + for i < len(line) { + c := line[i] + + // Stop at whitespace + if c == ' ' || c == '\t' { + break + } + + i++ + } + + // i is now one past the end, so back up + i-- + + // If including whitespace, skip trailing spaces/tabs + if includeWhitespace { + i++ // Move forward to whitespace + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + i-- // Back to last whitespace + } + + return core.Position{Line: 0, Col: i} +}