diff --git a/actions.go b/actions.go deleted file mode 100644 index 3e0775f..0000000 --- a/actions.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import tea "github.com/charmbracelet/bubbletea" - -// Action is the base interface - anything executable -type Action interface { - Execute(m *model) tea.Cmd -} - -// Motion moves the cursor and returns the range covered -type Motion interface { - Action -} - -// Operator acts on a range (delete, yank, change) -type Operator interface { - Operate(m *model, start, end Position) tea.Cmd - // DoublePress handles dd, yy, cc (line-wise) - DoublePress(m *model) tea.Cmd -} - -// Repeatable actions track count -type Repeatable interface { - WithCount(n int) Action -} - -type Position struct { - Line, Col int -} diff --git a/cmd/gim/main.go b/cmd/gim/main.go new file mode 100644 index 0000000..32a8e63 --- /dev/null +++ b/cmd/gim/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "git.gophernest.net/azpect/TextEditor/internal/editor" + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + // TODO: Not how this should work, of course + lines := []string{"Hello world", "line 2", "line 3", "line 4", "line 5"} + tea.NewProgram( + editor.NewModel(lines), + tea.WithAltScreen(), + ).Run() +} diff --git a/go.mod b/go.mod index 0d1281f..18d02b4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c ) require ( @@ -14,7 +15,6 @@ require ( github.com/charmbracelet/x/ansi v0.11.5 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect - github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect diff --git a/go.sum b/go.sum index aace593..3388b18 100644 --- a/go.sum +++ b/go.sum @@ -50,7 +50,5 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/internal/action/action.go b/internal/action/action.go new file mode 100644 index 0000000..f9207a7 --- /dev/null +++ b/internal/action/action.go @@ -0,0 +1,64 @@ +package action + +import tea "github.com/charmbracelet/bubbletea" + +// Mode constants for editor mode +type Mode int + +const ( + NormalMode Mode = iota + InsertMode + CommandMode +) + +// Model defines the interface for editor state that actions can modify +type Model interface { + // Text buffer + Lines() []string + Line(idx int) string + SetLine(idx int, content string) + InsertLine(idx int, content string) + DeleteLine(idx int) + LineCount() int + + // Cursor + CursorX() int + CursorY() int + SetCursorX(x int) + SetCursorY(y int) + ClampCursorX() + + // Mode + Mode() Mode + SetMode(mode Mode) + + // Insert recording (for count replay) + SetInsertRecording(count int, action Action) +} + +// Position represents a location in the buffer +type Position struct { + Line, Col int +} + +// Action is the base interface - anything executable +type Action interface { + Execute(m Model) tea.Cmd +} + +// Motion moves the cursor and returns the range covered +type Motion interface { + Action +} + +// Operator acts on a range (delete, yank, change) +type Operator interface { + Operate(m Model, start, end Position) tea.Cmd + // DoublePress handles dd, yy, cc (line-wise) + DoublePress(m Model) tea.Cmd +} + +// Repeatable actions track count +type Repeatable interface { + WithCount(n int) Action +} diff --git a/internal/action/delete.go b/internal/action/delete.go new file mode 100644 index 0000000..1fc4850 --- /dev/null +++ b/internal/action/delete.go @@ -0,0 +1,23 @@ +package action + +import tea "github.com/charmbracelet/bubbletea" + +// DeleteChar implements Action (x) +type DeleteChar struct { + Count int +} + +func (a DeleteChar) Execute(m Model) tea.Cmd { + pos := m.CursorX() + line := m.Line(m.CursorY()) + for i := 0; i < a.Count && pos < len(line); i++ { + line = line[:pos] + line[pos+1:] + m.SetLine(m.CursorY(), line) + } + + return nil +} + +func (a DeleteChar) WithCount(n int) Action { + return DeleteChar{Count: n} +} diff --git a/internal/action/insert.go b/internal/action/insert.go new file mode 100644 index 0000000..462d4de --- /dev/null +++ b/internal/action/insert.go @@ -0,0 +1,123 @@ +package action + +import tea "github.com/charmbracelet/bubbletea" + +// EnterInsert implements Action (i) +type EnterInsert struct { + Count int +} + +func (a EnterInsert) Execute(m Model) tea.Cmd { + // Start recording + m.SetInsertRecording(a.Count, a) + m.SetMode(InsertMode) + return nil +} + +func (a EnterInsert) WithCount(n int) Action { + return EnterInsert{Count: n} +} + +// EnterInsertAfter implements Action (a) +type EnterInsertAfter struct { + Count int +} + +func (a EnterInsertAfter) Execute(m Model) tea.Cmd { + m.SetCursorX(m.CursorX() + 1) + m.ClampCursorX() + + // Start recording + m.SetInsertRecording(a.Count, a) + m.SetMode(InsertMode) + return nil +} + +func (a EnterInsertAfter) WithCount(n int) Action { + return EnterInsertAfter{Count: n} +} + +// EnterInsertLineStart implements Action (I) +type EnterInsertLineStart struct { + Count int +} + +func (a EnterInsertLineStart) Execute(m Model) tea.Cmd { + m.SetCursorX(0) + m.ClampCursorX() + + // Start recording + m.SetInsertRecording(a.Count, a) + m.SetMode(InsertMode) + return nil +} + +func (a EnterInsertLineStart) WithCount(n int) Action { + return EnterInsertLineStart{Count: n} +} + +// EnterInsertLineEnd implements Action (A) +type EnterInsertLineEnd struct { + Count int +} + +func (a EnterInsertLineEnd) Execute(m Model) tea.Cmd { + m.SetCursorX(len(m.Line(m.CursorY()))) + m.ClampCursorX() + + // Start recording + m.SetInsertRecording(a.Count, a) + m.SetMode(InsertMode) + return nil +} + +func (a EnterInsertLineEnd) WithCount(n int) Action { + return EnterInsertLineEnd{Count: n} +} + +// OpenLineBelow implements Action (o) +type OpenLineBelow struct { + Count int +} + +func (a OpenLineBelow) Execute(m Model) tea.Cmd { + pos := m.CursorY() + + if pos >= m.LineCount() { + m.InsertLine(m.LineCount(), "") + } else { + m.InsertLine(pos+1, "") + } + + m.SetCursorY(m.CursorY() + 1) + m.SetCursorX(0) + + // Start recording + m.SetInsertRecording(a.Count, a) + m.SetMode(InsertMode) + return nil +} + +func (a OpenLineBelow) WithCount(n int) Action { + return OpenLineBelow{Count: n} +} + +// OpenLineAbove implements Action (O) +type OpenLineAbove struct { + Count int +} + +func (a OpenLineAbove) Execute(m Model) tea.Cmd { + pos := m.CursorY() + m.InsertLine(pos, "") + m.SetCursorX(0) + + // Start recording + m.SetInsertRecording(a.Count, a) + m.SetMode(InsertMode) + return nil +} + +func (a OpenLineAbove) WithCount(n int) Action { + return OpenLineAbove{Count: n} +} diff --git a/internal/action/misc.go b/internal/action/misc.go new file mode 100644 index 0000000..d45f40a --- /dev/null +++ b/internal/action/misc.go @@ -0,0 +1,10 @@ +package action + +import tea "github.com/charmbracelet/bubbletea" + +// Quit implements Action (ctrl+c) +type Quit struct{} + +func (a Quit) Execute(m Model) tea.Cmd { + return tea.Quit +} diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go new file mode 100644 index 0000000..384a49c --- /dev/null +++ b/internal/editor/helpers_test.go @@ -0,0 +1,56 @@ +package editor + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// sendKeys sends a sequence of keys to the test model +func sendKeys(tm *teatest.TestModel, keys ...string) { + for _, key := range keys { + switch key { + case "esc": + tm.Send(tea.KeyMsg{Type: tea.KeyEscape}) + case "enter": + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + case "backspace": + tm.Send(tea.KeyMsg{Type: tea.KeyBackspace}) + case "ctrl+c": + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) + case "ctrl+d": + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD}) + default: + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + } + } +} + +// newTestModel creates a test model with default content +func newTestModel(t *testing.T) *teatest.TestModel { + lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"} + return teatest.NewTestModel(t, NewModel(lines), teatest.WithInitialTermSize(80, 24)) +} + +func newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel { + return teatest.NewTestModel(t, NewModel(lines), teatest.WithInitialTermSize(80, 24)) +} + +func newTestModelWithCursorPos(t *testing.T, pos action.Position) *teatest.TestModel { + lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"} + return teatest.NewTestModel(t, NewModelWithPos(lines, pos), teatest.WithInitialTermSize(80, 24)) +} + +func newTestModelWithCursorPosAndLines(t *testing.T, lines []string, pos action.Position) *teatest.TestModel { + return teatest.NewTestModel(t, NewModelWithPos(lines, pos), teatest.WithInitialTermSize(80, 24)) +} + +// getFinalModel extracts the final model state (sends ctrl+c to quit first) +func getFinalModel(t *testing.T, tm *teatest.TestModel) Model { + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) + fm := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) + return fm.(Model) +} diff --git a/internal/editor/integration_delete_test.go b/internal/editor/integration_delete_test.go new file mode 100644 index 0000000..ec2b2de --- /dev/null +++ b/internal/editor/integration_delete_test.go @@ -0,0 +1,88 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +func TestDeleteChar(t *testing.T) { + t.Run("test 'x' deletes character under cursor", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x") + + m := getFinalModel(t, tm) + if m.lines[0] != "ello" { + t.Errorf("lines[0] = %q, want 'ello'", m.lines[0]) + } + }) + + t.Run("test 'x' in middle of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "x") + + m := getFinalModel(t, tm) + if m.lines[0] != "helo" { + t.Errorf("lines[0] = %q, want 'helo'", m.lines[0]) + } + }) + + t.Run("test 'x' at end of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 4, Line: 0}) + sendKeys(tm, "x") + + m := getFinalModel(t, tm) + if m.lines[0] != "hell" { + t.Errorf("lines[0] = %q, want 'hell'", m.lines[0]) + } + }) + + t.Run("test 'xx' deletes two characters", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "x", "x") + + m := getFinalModel(t, tm) + if m.lines[0] != "llo" { + t.Errorf("lines[0] = %q, want 'llo'", m.lines[0]) + } + }) +} + +func TestDeleteCharWithCount(t *testing.T) { + t.Run("test '3x' deletes three characters", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "3", "x") + + m := getFinalModel(t, tm) + if m.lines[0] != "lo" { + t.Errorf("lines[0] = %q, want 'lo'", m.lines[0]) + } + }) + + t.Run("test '10x' with overflow", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "1", "0", "x") + + m := getFinalModel(t, tm) + if m.lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.lines[0]) + } + }) + + t.Run("test '2x' from middle", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 1, Line: 0}) + sendKeys(tm, "2", "x") + + m := getFinalModel(t, tm) + if m.lines[0] != "hlo" { + t.Errorf("lines[0] = %q, want 'hlo'", m.lines[0]) + } + }) +} diff --git a/internal/editor/integration_insert_test.go b/internal/editor/integration_insert_test.go new file mode 100644 index 0000000..3c090ec --- /dev/null +++ b/internal/editor/integration_insert_test.go @@ -0,0 +1,394 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// --- Insert Mode Entry Tests --- + +func TestEnterInsert(t *testing.T) { + t.Run("test 'i' enters insert mode", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "i") + + m := getFinalModel(t, tm) + if m.mode != action.InsertMode { + t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode) + } + }) + + t.Run("test 'i' insert at beginning", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "Xhello" { + t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) + } + }) + + t.Run("test 'i' insert in middle", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "i", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "heXllo" { + t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) + } + }) + + t.Run("test 'i' cursor moves back on esc", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "i", "X", "esc") + + m := getFinalModel(t, tm) + if m.cursor.x != 2 { + t.Errorf("cursor.x = %d, want 2", m.cursor.x) + } + }) +} + +func TestEnterInsertAfter(t *testing.T) { + t.Run("test 'a' enters insert mode", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "a") + + m := getFinalModel(t, tm) + if m.mode != action.InsertMode { + t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode) + } + }) + + t.Run("test 'a' inserts after cursor", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "a", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "hXello" { + t.Errorf("lines[0] = %q, want 'hXello'", m.lines[0]) + } + }) + + t.Run("test 'a' from middle of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "a", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "helXlo" { + t.Errorf("lines[0] = %q, want 'helXlo'", m.lines[0]) + } + }) +} + +func TestEnterInsertLineStart(t *testing.T) { + t.Run("test 'I' enters insert mode at line start", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "I", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "Xhello" { + t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) + } + }) + + t.Run("test 'I' from end of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "I", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "Xhello" { + t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) + } + }) +} + +func TestEnterInsertLineEnd(t *testing.T) { + t.Run("test 'A' enters insert mode at line end", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "A", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "helloX" { + t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0]) + } + }) + + t.Run("test 'A' from middle of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "A", "X", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "helloX" { + t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0]) + } + }) +} + +// --- Open Line Tests --- + +func TestOpenLineBelow(t *testing.T) { + t.Run("test 'o' creates line below", func(t *testing.T) { + lines := []string{"line 1", "line 2"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "o", "n", "e", "w", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 3 { + t.Errorf("len(lines) = %d, want 3", len(m.lines)) + } + if m.lines[1] != "new" { + t.Errorf("lines[1] = %q, want 'new'", m.lines[1]) + } + }) + + t.Run("test 'o' from middle of file", func(t *testing.T) { + lines := []string{"line 1", "line 2", "line 3"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "o", "n", "e", "w", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 4 { + t.Errorf("len(lines) = %d, want 4", len(m.lines)) + } + if m.lines[2] != "new" { + t.Errorf("lines[2] = %q, want 'new'", m.lines[2]) + } + }) + + t.Run("test 'o' at end of file", func(t *testing.T) { + lines := []string{"line 1", "line 2"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "o", "n", "e", "w", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 3 { + t.Errorf("len(lines) = %d, want 3", len(m.lines)) + } + if m.lines[2] != "new" { + t.Errorf("lines[2] = %q, want 'new'", m.lines[2]) + } + }) + + t.Run("test 'o' cursor moves to new line", func(t *testing.T) { + lines := []string{"line 1", "line 2"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "o", "esc") + + m := getFinalModel(t, tm) + if m.cursor.y != 1 { + t.Errorf("cursor.y = %d, want 1", m.cursor.y) + } + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) +} + +func TestOpenLineBelowWithCount(t *testing.T) { + t.Run("test '3o' creates 3 lines", func(t *testing.T) { + lines := []string{"line 1"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "3", "o", "x", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 4 { + t.Errorf("len(lines) = %d, want 4", len(m.lines)) + } + for i := 1; i <= 3; i++ { + if m.lines[i] != "x" { + t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i]) + } + } + }) + + t.Run("test '2o' with multiple chars", func(t *testing.T) { + lines := []string{"line 1"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "2", "o", "a", "b", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 3 { + t.Errorf("len(lines) = %d, want 3", len(m.lines)) + } + if m.lines[1] != "ab" { + t.Errorf("lines[1] = %q, want 'ab'", m.lines[1]) + } + if m.lines[2] != "ab" { + t.Errorf("lines[2] = %q, want 'ab'", m.lines[2]) + } + }) +} + +func TestOpenLineAbove(t *testing.T) { + t.Run("test 'O' creates line above", func(t *testing.T) { + lines := []string{"line 1", "line 2"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "O", "n", "e", "w", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 3 { + t.Errorf("len(lines) = %d, want 3", len(m.lines)) + } + if m.lines[1] != "new" { + t.Errorf("lines[1] = %q, want 'new'", m.lines[1]) + } + }) + + t.Run("test 'O' at top of file", func(t *testing.T) { + lines := []string{"line 1", "line 2"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "O", "n", "e", "w", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 3 { + t.Errorf("len(lines) = %d, want 3", len(m.lines)) + } + if m.lines[0] != "new" { + t.Errorf("lines[0] = %q, want 'new'", m.lines[0]) + } + }) + + t.Run("test 'O' cursor at start of new line", func(t *testing.T) { + lines := []string{"line 1", "line 2"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 1}) + sendKeys(tm, "O", "esc") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) +} + +func TestOpenLineAboveWithCount(t *testing.T) { + t.Run("test '3O' creates 3 lines above", func(t *testing.T) { + lines := []string{"line 1"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "3", "O", "x", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 4 { + t.Errorf("len(lines) = %d, want 4", len(m.lines)) + } + for i := 0; i < 3; i++ { + if m.lines[i] != "x" { + t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i]) + } + } + }) +} + +// --- Insert Mode Special Keys --- + +func TestInsertModeEnter(t *testing.T) { + t.Run("test enter splits line", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "i", "enter", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 2 { + t.Errorf("len(lines) = %d, want 2", len(m.lines)) + } + if m.lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) + } + if m.lines[1] != " world" { + t.Errorf("lines[1] = %q, want ' world'", m.lines[1]) + } + }) + + t.Run("test enter at end of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "i", "enter", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 2 { + t.Errorf("len(lines) = %d, want 2", len(m.lines)) + } + if m.lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) + } + if m.lines[1] != "" { + t.Errorf("lines[1] = %q, want ''", m.lines[1]) + } + }) + + t.Run("test enter at start of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i", "enter", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 2 { + t.Errorf("len(lines) = %d, want 2", len(m.lines)) + } + if m.lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.lines[0]) + } + if m.lines[1] != "hello" { + t.Errorf("lines[1] = %q, want 'hello'", m.lines[1]) + } + }) +} + +func TestInsertModeBackspace(t *testing.T) { + t.Run("test backspace deletes character", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "i", "backspace", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "helo" { + t.Errorf("lines[0] = %q, want 'helo'", m.lines[0]) + } + }) + + t.Run("test backspace at start of line joins lines", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "i", "backspace", "esc") + + m := getFinalModel(t, tm) + if len(m.lines) != 1 { + t.Errorf("len(lines) = %d, want 1", len(m.lines)) + } + if m.lines[0] != "helloworld" { + t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0]) + } + }) + + t.Run("test backspace at start of first line does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i", "backspace", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) + } + }) + + t.Run("test multiple backspaces", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "i", "backspace", "backspace", "backspace", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "he" { + t.Errorf("lines[0] = %q, want 'he'", m.lines[0]) + } + }) +} diff --git a/internal/editor/integration_motion_basic_test.go b/internal/editor/integration_motion_basic_test.go new file mode 100644 index 0000000..934186b --- /dev/null +++ b/internal/editor/integration_motion_basic_test.go @@ -0,0 +1,277 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +func TestMoveDown(t *testing.T) { + t.Run("test 'j'", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "j") + + m := getFinalModel(t, tm) + if m.cursor.y != 1 { + t.Errorf("cursor.y = %d, want 1", m.cursor.y) + } + }) + + t.Run("test 'jjjj'", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "j", "j", "j", "j") + + m := getFinalModel(t, tm) + if m.cursor.y != 4 { + t.Errorf("cursor.y = %d, want 4", m.cursor.y) + } + }) + + t.Run("test 'jjjjjjjjj's with overflow", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "j", "j", "j", "j", "j", "j", "j", "j", "j") + + m := getFinalModel(t, tm) + if m.cursor.y != 5 { + t.Errorf("cursor.y = %d, want 5", m.cursor.y) + } + }) +} + +func TestMoveDownWithCount(t *testing.T) { + t.Run("test '3j'", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "3", "j") + + m := getFinalModel(t, tm) + if m.cursor.y != 3 { + t.Errorf("cursor.y = %d, want 3", m.cursor.y) + } + }) + + t.Run("test '10j' with overflow", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "1", "0", "j") + + m := getFinalModel(t, tm) + if m.cursor.y != 5 { + t.Errorf("cursor.y = %d, want 5", m.cursor.y) + } + }) +} + +func TestMoveDownWithOverflow(t *testing.T) { + lines := []string{"long line", "small"} + + t.Run("test 'j' with overflow", func(t *testing.T) { + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 8, Line: 0}) + sendKeys(tm, "j") + + m := getFinalModel(t, tm) + want := len(lines[1]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) + + t.Run("test 'j' without overflow", func(t *testing.T) { + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "j") + + m := getFinalModel(t, tm) + if m.cursor.x != 3 { + t.Errorf("cursor.x = %d, want 3", m.cursor.x) + } + }) +} + +func TestMoveUp(t *testing.T) { + t.Run("test 'k'", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2}) + sendKeys(tm, "k") + + m := getFinalModel(t, tm) + if m.cursor.y != 1 { + t.Errorf("cursor.y = %d, want 1", m.cursor.y) + } + }) + + t.Run("test 'kkkk'", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 4}) + sendKeys(tm, "k", "k", "k", "k") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + }) + + t.Run("test 'k' at top (no movement)", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "k", "k", "k") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + }) +} + +func TestMoveUpWithCount(t *testing.T) { + t.Run("test '3k'", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5}) + sendKeys(tm, "3", "k") + + m := getFinalModel(t, tm) + if m.cursor.y != 2 { + t.Errorf("cursor.y = %d, want 2", m.cursor.y) + } + }) + + t.Run("test '10k' with overflow", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3}) + sendKeys(tm, "1", "0", "k") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + }) +} + +func TestMoveUpWithOverflow(t *testing.T) { + lines := []string{"small", "long line"} + + t.Run("test 'k' with overflow", func(t *testing.T) { + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 1}) + sendKeys(tm, "k") + + m := getFinalModel(t, tm) + want := len(lines[0]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) + + t.Run("test 'k' without overflow", func(t *testing.T) { + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 1}) + sendKeys(tm, "k") + + m := getFinalModel(t, tm) + if m.cursor.x != 3 { + t.Errorf("cursor.x = %d, want 3", m.cursor.x) + } + }) +} + +func TestMoveRight(t *testing.T) { + t.Run("test 'l'", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "l") + + m := getFinalModel(t, tm) + if m.cursor.x != 1 { + t.Errorf("cursor.x = %d, want 1", m.cursor.x) + } + }) + + t.Run("test 'llll'", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "l", "l", "l", "l") + + m := getFinalModel(t, tm) + if m.cursor.x != 4 { + t.Errorf("cursor.x = %d, want 4", m.cursor.x) + } + }) + + t.Run("test 'l' at end of line (no movement past end)", func(t *testing.T) { + lines := []string{"abc"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "l", "l", "l", "l", "l", "l") + + m := getFinalModel(t, tm) + want := len(lines[0]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) +} + +func TestMoveRightWithCount(t *testing.T) { + t.Run("test '3l'", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "3", "l") + + m := getFinalModel(t, tm) + if m.cursor.x != 3 { + t.Errorf("cursor.x = %d, want 3", m.cursor.x) + } + }) + + t.Run("test '10l' with overflow", func(t *testing.T) { + lines := []string{"short"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "1", "0", "l") + + m := getFinalModel(t, tm) + want := len(lines[0]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) +} + +func TestMoveLeft(t *testing.T) { + t.Run("test 'h'", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "h") + + m := getFinalModel(t, tm) + if m.cursor.x != 2 { + t.Errorf("cursor.x = %d, want 2", m.cursor.x) + } + }) + + t.Run("test 'hhhh'", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 4, Line: 0}) + sendKeys(tm, "h", "h", "h", "h") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) + + t.Run("test 'h' at start (no movement)", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "h", "h", "h") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) +} + +func TestMoveLeftWithCount(t *testing.T) { + t.Run("test '3h'", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "3", "h") + + m := getFinalModel(t, tm) + if m.cursor.x != 2 { + t.Errorf("cursor.x = %d, want 2", m.cursor.x) + } + }) + + t.Run("test '10h' with overflow", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "1", "0", "h") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) +} diff --git a/internal/editor/integration_motion_jump_test.go b/internal/editor/integration_motion_jump_test.go new file mode 100644 index 0000000..1237654 --- /dev/null +++ b/internal/editor/integration_motion_jump_test.go @@ -0,0 +1,229 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// --- G and gg Tests --- + +func TestMoveToBottom(t *testing.T) { + t.Run("test 'G' from top", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "G") + + m := getFinalModel(t, tm) + if m.cursor.y != 5 { + t.Errorf("cursor.y = %d, want 5", m.cursor.y) + } + }) + + t.Run("test 'G' from middle", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2}) + sendKeys(tm, "G") + + m := getFinalModel(t, tm) + if m.cursor.y != 5 { + t.Errorf("cursor.y = %d, want 5", m.cursor.y) + } + }) + + t.Run("test 'G' already at bottom", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5}) + sendKeys(tm, "G") + + m := getFinalModel(t, tm) + if m.cursor.y != 5 { + t.Errorf("cursor.y = %d, want 5", m.cursor.y) + } + }) + + t.Run("test 'G' clamps cursor.x", func(t *testing.T) { + lines := []string{"long line here", "short"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "G") + + m := getFinalModel(t, tm) + if m.cursor.y != 1 { + t.Errorf("cursor.y = %d, want 1", m.cursor.y) + } + want := len(lines[1]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) + + t.Run("test 'G' on single line file", func(t *testing.T) { + lines := []string{"only line"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "G") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + }) +} + +func TestMoveToTop(t *testing.T) { + t.Run("test 'gg' from bottom", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5}) + sendKeys(tm, "g", "g") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + }) + + t.Run("test 'gg' from middle", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3}) + sendKeys(tm, "g", "g") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + }) + + t.Run("test 'gg' already at top", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "g", "g") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + }) + + t.Run("test 'gg' clamps cursor.x", func(t *testing.T) { + lines := []string{"short", "long line here"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 1}) + sendKeys(tm, "g", "g") + + m := getFinalModel(t, tm) + if m.cursor.y != 0 { + t.Errorf("cursor.y = %d, want 0", m.cursor.y) + } + want := len(lines[0]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) +} + +// --- 0 and $ Tests --- + +func TestMoveToLineStart(t *testing.T) { + t.Run("test '0' from middle of line", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "0") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) + + t.Run("test '0' from end of line", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: len(lines[0]), Line: 0}) + sendKeys(tm, "0") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) + + t.Run("test '0' already at start", func(t *testing.T) { + tm := newTestModel(t) + sendKeys(tm, "0") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) + + t.Run("test '0' on empty line", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "0") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) + + t.Run("test '0' preserves line", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 2}) + sendKeys(tm, "0") + + m := getFinalModel(t, tm) + if m.cursor.y != 2 { + t.Errorf("cursor.y = %d, want 2", m.cursor.y) + } + }) +} + +func TestMoveToLineEnd(t *testing.T) { + t.Run("test '$' from start of line", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "$") + + m := getFinalModel(t, tm) + want := len(lines[0]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) + + t.Run("test '$' from middle of line", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "$") + + m := getFinalModel(t, tm) + want := len(lines[0]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) + + t.Run("test '$' already at end", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: len(lines[0]), Line: 0}) + sendKeys(tm, "$") + + m := getFinalModel(t, tm) + want := len(lines[0]) + if m.cursor.x != want { + t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + } + }) + + t.Run("test '$' on empty line", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "$") + + m := getFinalModel(t, tm) + if m.cursor.x != 0 { + t.Errorf("cursor.x = %d, want 0", m.cursor.x) + } + }) + + t.Run("test '$' preserves line", func(t *testing.T) { + tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2}) + sendKeys(tm, "$") + + m := getFinalModel(t, tm) + if m.cursor.y != 2 { + t.Errorf("cursor.y = %d, want 2", m.cursor.y) + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go new file mode 100644 index 0000000..dc11b95 --- /dev/null +++ b/internal/editor/model.go @@ -0,0 +1,211 @@ +package editor + +import ( + "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/input" + tea "github.com/charmbracelet/bubbletea" +) + +type cursor struct { + x int + y int +} + +type Model struct { + lines []string + cursor cursor + s_gutter int + mode action.Mode + win_h int + win_w int + command string + input *input.Handler + + // Insert repetition + insertCount int + insertKeys []string + insertAction action.Action +} + +func NewModel(lines []string) Model { + return Model{ + lines: lines, + cursor: cursor{ + x: 0, + y: 0, + }, + s_gutter: 5, + mode: action.NormalMode, + command: "", + input: input.NewHandler(), + } +} + +func NewModelWithPos(lines []string, pos action.Position) Model { + return Model{ + lines: lines, + cursor: cursor{ + x: pos.Col, + y: pos.Line, + }, + s_gutter: 5, + mode: action.NormalMode, + command: "", + input: input.NewHandler(), + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +// Implement action.Model interface + +func (m *Model) Lines() []string { + return m.lines +} + +func (m *Model) Line(idx int) string { + if idx < 0 || idx >= len(m.lines) { + return "" + } + return m.lines[idx] +} + +func (m *Model) SetLine(idx int, content string) { + if idx >= 0 && idx < len(m.lines) { + m.lines[idx] = content + } +} + +func (m *Model) InsertLine(idx int, content string) { + if idx < 0 { + idx = 0 + } + if idx > len(m.lines) { + idx = len(m.lines) + } + m.lines = append(m.lines[:idx], append([]string{content}, m.lines[idx:]...)...) +} + +func (m *Model) DeleteLine(idx int) { + if idx >= 0 && idx < len(m.lines) { + m.lines = append(m.lines[:idx], m.lines[idx+1:]...) + } +} + +func (m *Model) LineCount() int { + return len(m.lines) +} + +func (m *Model) CursorX() int { + return m.cursor.x +} + +func (m *Model) CursorY() int { + return m.cursor.y +} + +func (m *Model) SetCursorX(x int) { + m.cursor.x = x +} + +func (m *Model) SetCursorY(y int) { + m.cursor.y = y +} + +func (m *Model) ClampCursorX() { + lineLen := len(m.lines[m.cursor.y]) + if lineLen == 0 { + m.cursor.x = 0 + } else if m.cursor.x >= lineLen { + m.cursor.x = lineLen + } +} + +func (m *Model) Mode() action.Mode { + return m.mode +} + +func (m *Model) SetMode(mode action.Mode) { + m.mode = mode +} + +func (m *Model) SetInsertRecording(count int, act action.Action) { + m.insertCount = count + m.insertKeys = []string{} + m.insertAction = act +} + +func (m *Model) GetCursorPosition() action.Position { + return action.Position{Line: m.cursor.y, Col: m.cursor.x} +} + +func (m *Model) replayInsert() { + // Replay (count - 1) more times + for i := 1; i < m.insertCount; i++ { + // For 'o' and 'O', we need to create a new line first + switch m.insertAction.(type) { + case action.OpenLineBelow: + pos := m.cursor.y + m.lines = append(m.lines[:pos+1], append([]string{""}, m.lines[pos+1:]...)...) + m.cursor.y++ + m.cursor.x = 0 + case action.OpenLineAbove: + pos := m.cursor.y + m.lines = append(m.lines[:pos], append([]string{""}, m.lines[pos:]...)...) + m.cursor.x = 0 + // 'i' and 'a' don't need setup - just replay keys + } + + // Replay each recorded keystroke + for _, key := range m.insertKeys { + m.processInsertKey(key) + } + } +} + +func (m *Model) processInsertKey(key string) { + x := m.CursorX() + y := m.CursorY() + l := m.Line(y) + + switch key { + case "enter": + + // Simple case, at end, just create a line + if x == len(l) { + m.InsertLine(y+1, "") + + // otherwise, splice + } else { + m.SetLine(y, l[:x]) + m.InsertLine(y+1, l[x:]) + } + + m.SetCursorY(y + 1) + m.SetCursorX(0) + + case "backspace": + if x > 0 { + m.SetLine(y, l[:x-1]+l[x:]) + m.SetCursorX(x - 1) + } else if y > 0 { + prevLine := m.Line(y - 1) + newX := len(prevLine) + m.SetLine(y-1, prevLine+l) + m.DeleteLine(y) + m.SetCursorY(y - 1) + m.SetCursorX(newX) + } + + // Regular character + default: + if x < len(l) { + m.SetLine(y, l[:x]+key+l[x:]) + } else { + m.SetLine(y, l+key) + } + m.SetCursorX(x + len(key)) + } +} diff --git a/style.go b/internal/editor/style.go similarity index 52% rename from style.go rename to internal/editor/style.go index 1106857..9381f14 100644 --- a/style.go +++ b/internal/editor/style.go @@ -1,28 +1,26 @@ -package main +package editor -import "github.com/charmbracelet/lipgloss" +import ( + "github.com/charmbracelet/lipgloss" + "git.gophernest.net/azpect/TextEditor/internal/action" +) -func (m model) cursorStyle() lipgloss.Style { +func (m Model) cursorStyle() lipgloss.Style { switch m.mode { - case NormalMode: + case action.NormalMode: // Block cursor for normal mode return lipgloss.NewStyle().Reverse(true) - case InsertMode: + case action.InsertMode: // Bar/underline for insert mode return lipgloss.NewStyle().Underline(true) - case CommandMode: + case action.CommandMode: return lipgloss.NewStyle() - // case VisualMode: - // // Colored block for visual mode - // return lipgloss.NewStyle(). - // Background(lipgloss.Color("62")). - // Foreground(lipgloss.Color("230")) default: return lipgloss.NewStyle().Reverse(true) } } -func (m model) gutterStyle(currentLine bool) lipgloss.Style { +func (m Model) gutterStyle(currentLine bool) lipgloss.Style { fg := lipgloss.Color("243") if currentLine { fg = lipgloss.Color("#d69d00") diff --git a/update.go b/internal/editor/update.go similarity index 70% rename from update.go rename to internal/editor/update.go index 8199f65..e4d620f 100644 --- a/update.go +++ b/internal/editor/update.go @@ -1,8 +1,11 @@ -package main +package editor -import tea "github.com/charmbracelet/bubbletea" +import ( + "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" +) -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -11,11 +14,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch m.mode { - case NormalMode: + case action.NormalMode: return m, m.input.Handle(&m, msg.String()) // TODO: This should be handled elsewhere - case InsertMode: + case action.InsertMode: key := msg.String() switch key { @@ -28,7 +31,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor.x > 0 { m.cursor.x-- } - m.mode = NormalMode + m.mode = action.NormalMode m.insertCount = 0 m.insertKeys = nil @@ -40,10 +43,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.insertKeys = append(m.insertKeys, key) m.processInsertKey(key) } - case CommandMode: + case action.CommandMode: switch msg.String() { case "esc": - m.mode = NormalMode + m.mode = action.NormalMode m.command = "" default: diff --git a/view.go b/internal/editor/view.go similarity index 83% rename from view.go rename to internal/editor/view.go index 70e195b..c502e3b 100644 --- a/view.go +++ b/internal/editor/view.go @@ -1,11 +1,13 @@ -package main +package editor import ( "fmt" "strings" + + "git.gophernest.net/azpect/TextEditor/internal/action" ) -func (m model) View() string { +func (m Model) View() string { var view strings.Builder for y := 0; y < m.win_h-1; y++ { @@ -34,8 +36,6 @@ func (m model) View() string { } view.WriteString(m.gutterStyle(currentLine).Render(gutter)) - // TODO: Do we need to do offset calculation? - runes := []rune(m.lines[y]) for x := 0; x <= len(runes); x++ { if m.cursor.y == y && m.cursor.x == x { @@ -59,19 +59,19 @@ func (m model) View() string { // Draw status bar var modeString string switch m.mode { - case NormalMode: + case action.NormalMode: modeString = "NORMAL" - case InsertMode: + case action.InsertMode: modeString = "INSERT" - case CommandMode: + case action.CommandMode: modeString = "COMMAND" } var bar string - if m.mode == CommandMode { + if m.mode == action.CommandMode { bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) %s ", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.command) } else { - bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) | %s | %+v | %d", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.input.buffer, m.insertKeys, m.insertCount) + bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) | %s | %+v | %d", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.input.Pending(), m.insertKeys, m.insertCount) } view.WriteString(bar) diff --git a/input.go b/internal/input/handler.go similarity index 67% rename from input.go rename to internal/input/handler.go index 229d089..3c2d90e 100644 --- a/input.go +++ b/internal/input/handler.go @@ -1,6 +1,9 @@ -package main +package input -import tea "github.com/charmbracelet/bubbletea" +import ( + tea "github.com/charmbracelet/bubbletea" + "git.gophernest.net/azpect/TextEditor/internal/action" +) type InputState int @@ -11,24 +14,29 @@ const ( StateMotionCount ) -type InputHandler struct { +// PositionGetter is used to get cursor position for operator ranges +type PositionGetter interface { + GetCursorPosition() action.Position +} + +type Handler struct { state InputState count1 int count2 int - operator Operator + operator action.Operator operatorKey string // track which key started operator (for dd, yy, cc) buffer string // for display (what user has typed) pending string // partial key sequence (e.g., "g" waiting for second key) keymap *Keymap } -func NewInputHandler() *InputHandler { - return &InputHandler{ +func NewHandler() *Handler { + return &Handler{ keymap: NewNormalKeymap(), } } -func (h *InputHandler) Handle(m *model, key string) tea.Cmd { +func (h *Handler) Handle(m action.Model, key string) tea.Cmd { // ESC always resets everything if key == "esc" { h.Reset() @@ -74,7 +82,7 @@ func (h *InputHandler) Handle(m *model, key string) tea.Cmd { } // dispatch routes to the right handler based on current state -func (h *InputHandler) dispatch(m *model, kind string, binding any, key string) tea.Cmd { +func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd { switch h.state { case StateReady, StateCount: return h.handleInitial(m, kind, binding, key) @@ -85,31 +93,31 @@ func (h *InputHandler) dispatch(m *model, kind string, binding any, key string) return nil } -func (h *InputHandler) handleInitial(m *model, kind string, binding any, key string) tea.Cmd { +func (h *Handler) handleInitial(m action.Model, kind string, binding any, key string) tea.Cmd { count := h.effectiveCount() switch kind { case "motion": - motion := binding.(Motion) - if r, ok := motion.(Repeatable); ok { - motion = r.WithCount(count).(Motion) + mot := binding.(action.Motion) + if r, ok := mot.(action.Repeatable); ok { + mot = r.WithCount(count).(action.Motion) } - cmd := motion.Execute(m) + cmd := mot.Execute(m) h.Reset() return cmd case "operator": - h.operator = binding.(Operator) + h.operator = binding.(action.Operator) h.operatorKey = key h.state = StateOperatorPending return nil case "action": - action := binding.(Action) - if r, ok := action.(Repeatable); ok { - action = r.WithCount(count) + act := binding.(action.Action) + if r, ok := act.(action.Repeatable); ok { + act = r.WithCount(count) } - cmd := action.Execute(m) + cmd := act.Execute(m) h.Reset() return cmd } @@ -118,7 +126,7 @@ func (h *InputHandler) handleInitial(m *model, kind string, binding any, key str return nil } -func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, key string) tea.Cmd { +func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, key string) tea.Cmd { count := h.effectiveCount() // dd, yy, cc - same operator key pressed twice @@ -130,14 +138,15 @@ func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, k // Motion after operator if kind == "motion" { - motion := binding.(Motion) - if r, ok := motion.(Repeatable); ok { - motion = r.WithCount(count).(Motion) + mot := binding.(action.Motion) + if r, ok := mot.(action.Repeatable); ok { + mot = r.WithCount(count).(action.Motion) } // Get range here - start := m.getCursorPosition() - motion.Execute(m) - end := m.getCursorPosition() + pg := m.(PositionGetter) + start := pg.GetCursorPosition() + mot.Execute(m) + end := pg.GetCursorPosition() cmd := h.operator.Operate(m, start, end) h.Reset() return cmd @@ -147,7 +156,7 @@ func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, k return nil } -func (h *InputHandler) tryAccumulateCount(key string) bool { +func (h *Handler) tryAccumulateCount(key string) bool { if len(key) != 1 || key[0] < '0' || key[0] > '9' { return false } @@ -172,14 +181,14 @@ func (h *InputHandler) tryAccumulateCount(key string) bool { return true } -func (h *InputHandler) currentCount() int { +func (h *Handler) currentCount() int { if h.state == StateOperatorPending || h.state == StateMotionCount { return h.count2 } return h.count1 } -func (h *InputHandler) effectiveCount() int { +func (h *Handler) effectiveCount() int { c1, c2 := h.count1, h.count2 if c1 == 0 { c1 = 1 @@ -190,7 +199,7 @@ func (h *InputHandler) effectiveCount() int { return c1 * c2 } -func (h *InputHandler) Reset() { +func (h *Handler) Reset() { h.state = StateReady h.count1 = 0 h.count2 = 0 @@ -200,6 +209,6 @@ func (h *InputHandler) Reset() { h.pending = "" } -func (h *InputHandler) Pending() string { +func (h *Handler) Pending() string { return h.buffer } diff --git a/internal/input/keymap.go b/internal/input/keymap.go new file mode 100644 index 0000000..2272192 --- /dev/null +++ b/internal/input/keymap.go @@ -0,0 +1,76 @@ +package input + +import ( + "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/motion" +) + +type Keymap struct { + motions map[string]action.Motion + operators map[string]action.Operator + actions map[string]action.Action // standalone actions: i.e., 'i', 'a' +} + +func NewNormalKeymap() *Keymap { + return &Keymap{ + motions: map[string]action.Motion{ + "j": motion.MoveDown{Count: 1}, + "k": motion.MoveUp{Count: 1}, + "h": motion.MoveLeft{Count: 1}, + "l": motion.MoveRight{Count: 1}, + "G": motion.MoveToBottom{}, + "gg": motion.MoveToTop{}, // multi-key example + "0": motion.MoveToLineStart{}, + "$": motion.MoveToLineEnd{}, + }, + operators: map[string]action.Operator{ + // "d": DeleteOp{}, + // "c": ChangeOp{}, + // "y": YankOp{}, + }, + actions: map[string]action.Action{ + "i": action.EnterInsert{}, + "a": action.EnterInsertAfter{}, + "I": action.EnterInsertLineStart{}, + "A": action.EnterInsertLineEnd{}, + "o": action.OpenLineBelow{}, + "O": action.OpenLineAbove{}, + "x": action.DeleteChar{Count: 1}, + "ctrl+c": action.Quit{}, + }, + } +} + +// Lookup returns what type of binding a key is +func (km *Keymap) Lookup(key string) (kind string, value any) { + if m, ok := km.motions[key]; ok { + return "motion", m + } + if o, ok := km.operators[key]; ok { + return "operator", o + } + if a, ok := km.actions[key]; ok { + return "action", a + } + return "", nil +} + +// HasPrefix returns true if any binding starts with this prefix +func (km *Keymap) HasPrefix(prefix string) bool { + for key := range km.motions { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + return true + } + } + for key := range km.operators { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + return true + } + } + for key := range km.actions { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + return true + } + } + return false +} diff --git a/internal/motion/basic.go b/internal/motion/basic.go new file mode 100644 index 0000000..d613736 --- /dev/null +++ b/internal/motion/basic.go @@ -0,0 +1,75 @@ +package motion + +import ( + tea "github.com/charmbracelet/bubbletea" + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// MoveDown implements Motion (j) +type MoveDown struct { + Count int +} + +func (a MoveDown) Execute(m action.Model) tea.Cmd { + for i := 0; i < a.Count && m.CursorY() < m.LineCount()-1; i++ { + m.SetCursorY(m.CursorY() + 1) + } + m.ClampCursorX() + return nil +} + +func (a MoveDown) WithCount(n int) action.Action { + return MoveDown{Count: n} +} + +// MoveUp implements Motion (k) +type MoveUp struct { + Count int +} + +func (a MoveUp) Execute(m action.Model) tea.Cmd { + for i := 0; i < a.Count && m.CursorY() > 0; i++ { + m.SetCursorY(m.CursorY() - 1) + } + m.ClampCursorX() + return nil +} + +func (a MoveUp) WithCount(n int) action.Action { + return MoveUp{Count: n} +} + +// MoveLeft implements Motion (h) +type MoveLeft struct { + Count int +} + +func (a MoveLeft) Execute(m action.Model) tea.Cmd { + for i := 0; i < a.Count && m.CursorX() > 0; i++ { + m.SetCursorX(m.CursorX() - 1) + } + m.ClampCursorX() + return nil +} + +func (a MoveLeft) WithCount(n int) action.Action { + return MoveLeft{Count: n} +} + +// MoveRight implements Motion (l) +type MoveRight struct { + Count int +} + +func (a MoveRight) Execute(m action.Model) tea.Cmd { + lineLen := len(m.Line(m.CursorY())) + for i := 0; i < a.Count && m.CursorX() <= lineLen; i++ { + m.SetCursorX(m.CursorX() + 1) + } + m.ClampCursorX() + return nil +} + +func (a MoveRight) WithCount(n int) action.Action { + return MoveRight{Count: n} +} diff --git a/internal/motion/jump.go b/internal/motion/jump.go new file mode 100644 index 0000000..30a5e1c --- /dev/null +++ b/internal/motion/jump.go @@ -0,0 +1,42 @@ +package motion + +import ( + tea "github.com/charmbracelet/bubbletea" + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// MoveToTop implements Motion (gg) +type MoveToTop struct{} + +func (a MoveToTop) Execute(m action.Model) tea.Cmd { + m.SetCursorY(0) + m.ClampCursorX() + return nil +} + +// MoveToBottom implements Motion (G) +type MoveToBottom struct{} + +func (a MoveToBottom) Execute(m action.Model) tea.Cmd { + m.SetCursorY(m.LineCount() - 1) + m.ClampCursorX() + return nil +} + +// MoveToLineStart implements Motion (0) +type MoveToLineStart struct{} + +func (a MoveToLineStart) Execute(m action.Model) tea.Cmd { + m.SetCursorX(0) + m.ClampCursorX() + return nil +} + +// MoveToLineEnd implements Motion ($) +type MoveToLineEnd struct{} + +func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd { + m.SetCursorX(len(m.Line(m.CursorY()))) + m.ClampCursorX() + return nil +} diff --git a/keymap.go b/keymap.go deleted file mode 100644 index 1dcc2f6..0000000 --- a/keymap.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -type Keymap struct { - motions map[string]Motion - operators map[string]Operator - actions map[string]Action // standalone actions: i.e., 'i', 'a' -} - -func NewNormalKeymap() *Keymap { - return &Keymap{ - motions: map[string]Motion{ - "j": MoveDown{1}, - "k": MoveUp{1}, - "h": MoveLeft{1}, - "l": MoveRight{1}, - "G": MoveToBottom{}, - "gg": MoveToTop{}, // multi-key example - // "w": MoveWord{1}, - // "b": MoveWordBack{1}, - "0": MoveToLineStart{}, - "$": MoveToLineEnd{}, - }, - operators: map[string]Operator{ - // "d": DeleteOp{}, - // "c": ChangeOp{}, - // "y": YankOp{}, - }, - actions: map[string]Action{ - "i": EnterInsert{}, - "a": EnterInsertAfter{}, - "I": EnterInsertLineStart{}, - "A": EnterInsertLineEnd{}, - "o": OpenLineBelow{}, - "O": OpenLineAbove{}, - "x": DeleteChar{1}, - // "p": Paste{}, - // "u": Undo{}, - // "ctrl+r": Redo{}, - "ctrl+c": Quit{}, - }, - } -} - -// Lookup returns what type of binding a key is -func (km *Keymap) Lookup(key string) (kind string, value any) { - if m, ok := km.motions[key]; ok { - return "motion", m - } - if o, ok := km.operators[key]; ok { - return "operator", o - } - if a, ok := km.actions[key]; ok { - return "action", a - } - return "", nil -} - -// HasPrefix returns true if any binding starts with this prefix -func (km *Keymap) HasPrefix(prefix string) bool { - for key := range km.motions { - if len(key) > len(prefix) && key[:len(prefix)] == prefix { - return true - } - } - for key := range km.operators { - if len(key) > len(prefix) && key[:len(prefix)] == prefix { - return true - } - } - for key := range km.actions { - if len(key) > len(prefix) && key[:len(prefix)] == prefix { - return true - } - } - return false -} diff --git a/main.go b/main.go deleted file mode 100644 index 1cd8490..0000000 --- a/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import tea "github.com/charmbracelet/bubbletea" - -func main() { - lines := []string{"Hello world", "line 2", "line 3", "line 4", "line 5"} - tea.NewProgram( - newModel(lines), - tea.WithAltScreen(), - ).Run() -} diff --git a/model.go b/model.go deleted file mode 100644 index 462dd3b..0000000 --- a/model.go +++ /dev/null @@ -1,150 +0,0 @@ -package main - -import tea "github.com/charmbracelet/bubbletea" - -type mode int - -const ( - NormalMode mode = iota - InsertMode - CommandMode -) - -type cursor struct { - x int - y int -} - -type model struct { - lines []string - cursor cursor - s_gutter int - mode mode - win_h int - win_w int - command string - input *InputHandler - - // Insert repetition - insertCount int - insertKeys []string - insertAction Action -} - -func newModel(lines []string) model { - return model{ - lines: lines, - cursor: cursor{ - x: 0, - y: 0, - }, - s_gutter: 5, - mode: NormalMode, - command: "", - input: NewInputHandler(), - } -} - -func newModelWithPos(lines []string, pos Position) model { - return model{ - lines: lines, - cursor: cursor{ - x: pos.Col, - y: pos.Line, - }, - s_gutter: 5, - mode: NormalMode, - command: "", - input: NewInputHandler(), - } -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m *model) clampCursorX() { - lineLen := len(m.lines[m.cursor.y]) - if lineLen == 0 { - m.cursor.x = 0 - } else if m.cursor.x >= lineLen { - m.cursor.x = lineLen - } -} - -func (m model) getCursorPosition() Position { - return Position{Line: m.cursor.y, Col: m.cursor.x} -} - -func (m *model) replayInsert() { - // Replay (count - 1) more times - for i := 1; i < m.insertCount; i++ { - // For 'o' and 'O', we need to create a new line first - switch m.insertAction.(type) { - case OpenLineBelow: - pos := m.cursor.y - m.lines = append(m.lines[:pos+1], append([]string{""}, m.lines[pos+1:]...)...) - m.cursor.y++ - m.cursor.x = 0 - case OpenLineAbove: - pos := m.cursor.y - m.lines = append(m.lines[:pos], append([]string{""}, m.lines[pos:]...)...) - m.cursor.x = 0 - // 'i' and 'a' don't need setup - just replay keys - } - - // Replay each recorded keystroke - for _, key := range m.insertKeys { - m.processInsertKey(key) - } - } -} - -func (m *model) processInsertKey(key string) { - switch key { - case "enter": - y := m.cursor.y - x := m.cursor.x - - // Simple case, at end, just create a line - if x == len(m.lines[y]) { - m.lines = append(m.lines[:y+1], append([]string{""}, m.lines[y+1:]...)...) - - // otherwise, splice - } else { - l := m.lines[y] - m.lines[y] = l[:x] - m.lines = append(m.lines[:y+1], append([]string{l[x:]}, m.lines[y+1:]...)...) - } - - m.cursor.y++ - m.cursor.x = 0 - - case "backspace": - x := m.cursor.x - y := m.cursor.y - l := m.lines[y] - if x > 0 { - m.lines[y] = l[:x-1] + l[x:] - m.cursor.x-- - } else if y > 0 { - newX := len(m.lines[y-1]) - m.lines[y-1] = m.lines[y-1] + l - m.lines = append(m.lines[:y], m.lines[y+1:]...) - m.cursor.y-- - m.cursor.x = newX - } - - default: - // Regular character - x := m.cursor.x - y := m.cursor.y - l := m.lines[y] - if x < len(l) { - m.lines[y] = l[:x] + key + l[x:] - } else { - m.lines[y] = l + key - } - m.cursor.x += len(key) - } -} diff --git a/motion.go b/motion.go deleted file mode 100644 index 94d9e57..0000000 --- a/motion.go +++ /dev/null @@ -1,268 +0,0 @@ -package main - -import tea "github.com/charmbracelet/bubbletea" - -// MoveDown implements Motion -type MoveDown struct { - count int -} - -func (a MoveDown) Execute(m *model) tea.Cmd { - for i := 0; i < a.count && m.cursor.y < len(m.lines)-1; i++ { - m.cursor.y++ - } - m.clampCursorX() - return nil -} - -func (a MoveDown) WithCount(n int) Action { - return MoveDown{count: n} -} - -// MoveUp implements Motion -type MoveUp struct { - count int -} - -func (a MoveUp) Execute(m *model) tea.Cmd { - for i := 0; i < a.count && m.cursor.y > 0; i++ { - m.cursor.y-- - } - m.clampCursorX() - return nil -} - -func (a MoveUp) WithCount(n int) Action { - return MoveUp{count: n} -} - -type MoveLeft struct { - count int -} - -func (a MoveLeft) Execute(m *model) tea.Cmd { - for i := 0; i < a.count && m.cursor.x > 0; i++ { - m.cursor.x-- - } - m.clampCursorX() - return nil -} - -func (a MoveLeft) WithCount(n int) Action { - return MoveLeft{count: n} -} - -type MoveRight struct { - count int -} - -func (a MoveRight) Execute(m *model) tea.Cmd { - lineLen := len(m.lines[m.cursor.y]) - for i := 0; i < a.count && m.cursor.x <= lineLen; i++ { - m.cursor.x++ - } - m.clampCursorX() - return nil -} - -func (a MoveRight) WithCount(n int) Action { - return MoveRight{count: n} -} - -type EnterInsert struct { - count int -} - -func (a EnterInsert) Execute(m *model) tea.Cmd { - // Start recording - m.insertCount = a.count - m.insertKeys = []string{} - m.insertAction = a - - m.mode = InsertMode - return nil -} - -func (a EnterInsert) WithCount(n int) Action { - return EnterInsert{count: n} -} - -type EnterInsertAfter struct { - count int -} - -func (a EnterInsertAfter) Execute(m *model) tea.Cmd { - m.cursor.x++ - m.clampCursorX() - - // Start recording - m.insertCount = a.count - m.insertKeys = []string{} - m.insertAction = a - - m.mode = InsertMode - return nil -} - -func (a EnterInsertAfter) WithCount(n int) Action { - return EnterInsertAfter{count: n} -} - -type Quit struct{} - -func (a Quit) Execute(m *model) tea.Cmd { - return tea.Quit -} - -// MoveToTop implements Motion (gg) -type MoveToTop struct{} - -func (a MoveToTop) Execute(m *model) tea.Cmd { - m.cursor.y = 0 - m.clampCursorX() - return nil -} - -// MoveToBottom implements Motion (G) -type MoveToBottom struct{} - -func (a MoveToBottom) Execute(m *model) tea.Cmd { - m.cursor.y = len(m.lines) - 1 - m.clampCursorX() - return nil -} - -type EnterInsertLineStart struct { - count int -} - -func (a EnterInsertLineStart) Execute(m *model) tea.Cmd { - m.cursor.x = 0 - m.clampCursorX() - - // Start recording - m.insertCount = a.count - m.insertKeys = []string{} - m.insertAction = a - - m.mode = InsertMode - return nil -} - -func (a EnterInsertLineStart) WithCount(n int) Action { - return EnterInsertLineStart{count: n} -} - -type EnterInsertLineEnd struct { - count int -} - -func (a EnterInsertLineEnd) Execute(m *model) tea.Cmd { - m.cursor.x = len(m.lines[m.cursor.y]) - m.clampCursorX() - - // Start recording - m.insertCount = a.count - m.insertKeys = []string{} - m.insertAction = a - - m.mode = InsertMode - return nil -} - -func (a EnterInsertLineEnd) WithCount(n int) Action { - return EnterInsertLineEnd{count: n} -} - -type MoveToLineStart struct{} - -func (a MoveToLineStart) Execute(m *model) tea.Cmd { - m.cursor.x = 0 - m.clampCursorX() - return nil -} - -type MoveToLineEnd struct{} - -func (a MoveToLineEnd) Execute(m *model) tea.Cmd { - m.cursor.x = len(m.lines[m.cursor.y]) - m.clampCursorX() - return nil -} - -// TODO: Count -type OpenLineBelow struct { - count int -} - -func (a OpenLineBelow) Execute(m *model) tea.Cmd { - pos := m.cursor.y - - if pos >= len(m.lines) { - m.lines = append(m.lines, "") - } else { - m.lines = append(m.lines[:pos+1], append([]string{""}, m.lines[pos+1:]...)...) - } - - m.cursor.y++ - m.cursor.x = 0 - - // Start recording - m.insertCount = a.count - m.insertKeys = []string{} - m.insertAction = a - - m.mode = InsertMode - return nil -} - -func (a OpenLineBelow) WithCount(n int) Action { - return OpenLineBelow{count: n} -} - -type OpenLineAbove struct { - count int -} - -func (a OpenLineAbove) Execute(m *model) tea.Cmd { - pos := m.cursor.y - - if pos <= 0 { - m.lines = append([]string{""}, m.lines...) - } else { - m.lines = append(m.lines[:pos], append([]string{""}, m.lines[pos:]...)...) - } - - m.cursor.x = 0 - - // Start recording - m.insertCount = a.count - m.insertKeys = []string{} - m.insertAction = a - - m.mode = InsertMode - return nil -} - -func (a OpenLineAbove) WithCount(n int) Action { - return OpenLineAbove{count: n} -} - -// TODO: Visual mode -type DeleteChar struct { - count int -} - -func (a DeleteChar) Execute(m *model) tea.Cmd { - pos := m.cursor.x - for i := 0; i < a.count && m.cursor.x < len(m.lines[m.cursor.y]); i++ { - line := m.lines[m.cursor.y] - m.lines[m.cursor.y] = line[:pos] + line[pos+1:] - } - - return nil -} - -func (a DeleteChar) WithCount(n int) Action { - return DeleteChar{count: n} -} diff --git a/motion_test.go b/motion_test.go deleted file mode 100644 index 6807efd..0000000 --- a/motion_test.go +++ /dev/null @@ -1,1017 +0,0 @@ -package main - -import ( - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/exp/teatest" -) - -// sendKeys sends a sequence of keys to the test model -func sendKeys(tm *teatest.TestModel, keys ...string) { - for _, key := range keys { - switch key { - case "esc": - tm.Send(tea.KeyMsg{Type: tea.KeyEscape}) - case "enter": - tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) - case "backspace": - tm.Send(tea.KeyMsg{Type: tea.KeyBackspace}) - case "ctrl+c": - tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) - case "ctrl+d": - tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD}) - default: - tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) - } - } -} - -// newTestModel creates a test model with default content -func newTestModel(t *testing.T) *teatest.TestModel { - lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"} - return teatest.NewTestModel(t, newModel(lines), teatest.WithInitialTermSize(80, 24)) -} - -func newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel { - return teatest.NewTestModel(t, newModel(lines), teatest.WithInitialTermSize(80, 24)) -} - -func newTestModelWithCursorPos(t *testing.T, pos Position) *teatest.TestModel { - lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"} - return teatest.NewTestModel(t, newModelWithPos(lines, pos), teatest.WithInitialTermSize(80, 24)) -} - -func newTestModelWithCursorPosAndLines(t *testing.T, lines []string, pos Position) *teatest.TestModel { - return teatest.NewTestModel(t, newModelWithPos(lines, pos), teatest.WithInitialTermSize(80, 24)) -} - -// getFinalModel extracts the final model state (sends ctrl+c to quit first) -func getFinalModel(t *testing.T, tm *teatest.TestModel) model { - tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) - fm := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) - return fm.(model) -} - -func TestMoveDown(t *testing.T) { - t.Run("test 'j'", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "j") - - m := getFinalModel(t, tm) - if m.cursor.y != 1 { - t.Errorf("cursor.y = %d, want 1", m.cursor.y) - } - }) - - t.Run("test 'jjjj'", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "j", "j", "j", "j") - - m := getFinalModel(t, tm) - if m.cursor.y != 4 { - t.Errorf("cursor.y = %d, want 4", m.cursor.y) - } - }) - - t.Run("test 'jjjjjjjjj's with overflow", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "j", "j", "j", "j", "j", "j", "j", "j", "j") - - m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) - } - }) -} - -func TestMoveDownWithCount(t *testing.T) { - t.Run("test '3j'", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "3", "j") - - m := getFinalModel(t, tm) - if m.cursor.y != 3 { - t.Errorf("cursor.y = %d, want 3", m.cursor.y) - } - }) - - t.Run("test '10j' with overflow", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "1", "0", "j") - - m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) - } - }) -} - -func TestMoveDownWithOverflow(t *testing.T) { - lines := []string{"long line", "small"} - - t.Run("test 'j' with overflow", func(t *testing.T) { - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 8, Line: 0}) - sendKeys(tm, "j") - - m := getFinalModel(t, tm) - want := len(lines[1]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) - - t.Run("test 'j' without overflow", func(t *testing.T) { - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 3, Line: 0}) - sendKeys(tm, "j") - - m := getFinalModel(t, tm) - if m.cursor.x != 3 { - t.Errorf("cursor.x = %d, want 3", m.cursor.x) - } - }) -} - -func TestMoveUp(t *testing.T) { - t.Run("test 'k'", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 2}) - sendKeys(tm, "k") - - m := getFinalModel(t, tm) - if m.cursor.y != 1 { - t.Errorf("cursor.y = %d, want 1", m.cursor.y) - } - }) - - t.Run("test 'kkkk'", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 4}) - sendKeys(tm, "k", "k", "k", "k") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - }) - - t.Run("test 'k' at top (no movement)", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "k", "k", "k") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - }) -} - -func TestMoveUpWithCount(t *testing.T) { - t.Run("test '3k'", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 5}) - sendKeys(tm, "3", "k") - - m := getFinalModel(t, tm) - if m.cursor.y != 2 { - t.Errorf("cursor.y = %d, want 2", m.cursor.y) - } - }) - - t.Run("test '10k' with overflow", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 3}) - sendKeys(tm, "1", "0", "k") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - }) -} - -func TestMoveUpWithOverflow(t *testing.T) { - lines := []string{"small", "long line"} - - t.Run("test 'k' with overflow", func(t *testing.T) { - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 10, Line: 1}) - sendKeys(tm, "k") - - m := getFinalModel(t, tm) - want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) - - t.Run("test 'k' without overflow", func(t *testing.T) { - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 3, Line: 1}) - sendKeys(tm, "k") - - m := getFinalModel(t, tm) - if m.cursor.x != 3 { - t.Errorf("cursor.x = %d, want 3", m.cursor.x) - } - }) -} - -func TestMoveRight(t *testing.T) { - t.Run("test 'l'", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "l") - - m := getFinalModel(t, tm) - if m.cursor.x != 1 { - t.Errorf("cursor.x = %d, want 1", m.cursor.x) - } - }) - - t.Run("test 'llll'", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "l", "l", "l", "l") - - m := getFinalModel(t, tm) - if m.cursor.x != 4 { - t.Errorf("cursor.x = %d, want 4", m.cursor.x) - } - }) - - t.Run("test 'l' at end of line (no movement past end)", func(t *testing.T) { - lines := []string{"abc"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "l", "l", "l", "l", "l", "l") - - m := getFinalModel(t, tm) - want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) -} - -func TestMoveRightWithCount(t *testing.T) { - t.Run("test '3l'", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "3", "l") - - m := getFinalModel(t, tm) - if m.cursor.x != 3 { - t.Errorf("cursor.x = %d, want 3", m.cursor.x) - } - }) - - t.Run("test '10l' with overflow", func(t *testing.T) { - lines := []string{"short"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "1", "0", "l") - - m := getFinalModel(t, tm) - want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) -} - -func TestMoveLeft(t *testing.T) { - t.Run("test 'h'", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 3, Line: 0}) - sendKeys(tm, "h") - - m := getFinalModel(t, tm) - if m.cursor.x != 2 { - t.Errorf("cursor.x = %d, want 2", m.cursor.x) - } - }) - - t.Run("test 'hhhh'", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 4, Line: 0}) - sendKeys(tm, "h", "h", "h", "h") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) - - t.Run("test 'h' at start (no movement)", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "h", "h", "h") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) -} - -func TestMoveLeftWithCount(t *testing.T) { - t.Run("test '3h'", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 5, Line: 0}) - sendKeys(tm, "3", "h") - - m := getFinalModel(t, tm) - if m.cursor.x != 2 { - t.Errorf("cursor.x = %d, want 2", m.cursor.x) - } - }) - - t.Run("test '10h' with overflow", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 3, Line: 0}) - sendKeys(tm, "1", "0", "h") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) -} - -// --- G and gg Tests --- - -func TestMoveToBottom(t *testing.T) { - t.Run("test 'G' from top", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "G") - - m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) - } - }) - - t.Run("test 'G' from middle", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 2}) - sendKeys(tm, "G") - - m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) - } - }) - - t.Run("test 'G' already at bottom", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 5}) - sendKeys(tm, "G") - - m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) - } - }) - - t.Run("test 'G' clamps cursor.x", func(t *testing.T) { - lines := []string{"long line here", "short"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 10, Line: 0}) - sendKeys(tm, "G") - - m := getFinalModel(t, tm) - if m.cursor.y != 1 { - t.Errorf("cursor.y = %d, want 1", m.cursor.y) - } - want := len(lines[1]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) - - t.Run("test 'G' on single line file", func(t *testing.T) { - lines := []string{"only line"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "G") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - }) -} - -func TestMoveToTop(t *testing.T) { - t.Run("test 'gg' from bottom", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 5}) - sendKeys(tm, "g", "g") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - }) - - t.Run("test 'gg' from middle", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 3}) - sendKeys(tm, "g", "g") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - }) - - t.Run("test 'gg' already at top", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "g", "g") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - }) - - t.Run("test 'gg' clamps cursor.x", func(t *testing.T) { - lines := []string{"short", "long line here"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 10, Line: 1}) - sendKeys(tm, "g", "g") - - m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) - } - want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) -} - -// --- 0 and $ Tests --- - -func TestMoveToLineStart(t *testing.T) { - t.Run("test '0' from middle of line", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 3, Line: 0}) - sendKeys(tm, "0") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) - - t.Run("test '0' from end of line", func(t *testing.T) { - lines := []string{"hello world"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: len(lines[0]), Line: 0}) - sendKeys(tm, "0") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) - - t.Run("test '0' already at start", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "0") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) - - t.Run("test '0' on empty line", func(t *testing.T) { - lines := []string{""} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "0") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) - - t.Run("test '0' preserves line", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 3, Line: 2}) - sendKeys(tm, "0") - - m := getFinalModel(t, tm) - if m.cursor.y != 2 { - t.Errorf("cursor.y = %d, want 2", m.cursor.y) - } - }) -} - -func TestMoveToLineEnd(t *testing.T) { - t.Run("test '$' from start of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "$") - - m := getFinalModel(t, tm) - want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) - - t.Run("test '$' from middle of line", func(t *testing.T) { - lines := []string{"hello world"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 3, Line: 0}) - sendKeys(tm, "$") - - m := getFinalModel(t, tm) - want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) - - t.Run("test '$' already at end", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: len(lines[0]), Line: 0}) - sendKeys(tm, "$") - - m := getFinalModel(t, tm) - want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) - } - }) - - t.Run("test '$' on empty line", func(t *testing.T) { - lines := []string{""} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "$") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) - - t.Run("test '$' preserves line", func(t *testing.T) { - tm := newTestModelWithCursorPos(t, Position{Col: 0, Line: 2}) - sendKeys(tm, "$") - - m := getFinalModel(t, tm) - if m.cursor.y != 2 { - t.Errorf("cursor.y = %d, want 2", m.cursor.y) - } - }) -} - -// --- Delete Char (x) Tests --- - -func TestDeleteChar(t *testing.T) { - t.Run("test 'x' deletes character under cursor", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "x") - - m := getFinalModel(t, tm) - if m.lines[0] != "ello" { - t.Errorf("lines[0] = %q, want 'ello'", m.lines[0]) - } - }) - - t.Run("test 'x' in middle of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 2, Line: 0}) - sendKeys(tm, "x") - - m := getFinalModel(t, tm) - if m.lines[0] != "helo" { - t.Errorf("lines[0] = %q, want 'helo'", m.lines[0]) - } - }) - - t.Run("test 'x' at end of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 4, Line: 0}) - sendKeys(tm, "x") - - m := getFinalModel(t, tm) - if m.lines[0] != "hell" { - t.Errorf("lines[0] = %q, want 'hell'", m.lines[0]) - } - }) - - t.Run("test 'xx' deletes two characters", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "x", "x") - - m := getFinalModel(t, tm) - if m.lines[0] != "llo" { - t.Errorf("lines[0] = %q, want 'llo'", m.lines[0]) - } - }) -} - -func TestDeleteCharWithCount(t *testing.T) { - t.Run("test '3x' deletes three characters", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "3", "x") - - m := getFinalModel(t, tm) - if m.lines[0] != "lo" { - t.Errorf("lines[0] = %q, want 'lo'", m.lines[0]) - } - }) - - t.Run("test '10x' with overflow", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "1", "0", "x") - - m := getFinalModel(t, tm) - if m.lines[0] != "" { - t.Errorf("lines[0] = %q, want ''", m.lines[0]) - } - }) - - t.Run("test '2x' from middle", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 1, Line: 0}) - sendKeys(tm, "2", "x") - - m := getFinalModel(t, tm) - if m.lines[0] != "hlo" { - t.Errorf("lines[0] = %q, want 'hlo'", m.lines[0]) - } - }) -} - -// --- Insert Mode Tests --- - -func TestEnterInsert(t *testing.T) { - t.Run("test 'i' enters insert mode", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "i") - - m := getFinalModel(t, tm) - if m.mode != InsertMode { - t.Errorf("mode = %d, want InsertMode (%d)", m.mode, InsertMode) - } - }) - - t.Run("test 'i' insert at beginning", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "i", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "Xhello" { - t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) - } - }) - - t.Run("test 'i' insert in middle", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 2, Line: 0}) - sendKeys(tm, "i", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "heXllo" { - t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) - } - }) - - t.Run("test 'i' cursor moves back on esc", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 2, Line: 0}) - sendKeys(tm, "i", "X", "esc") - - m := getFinalModel(t, tm) - if m.cursor.x != 2 { - t.Errorf("cursor.x = %d, want 2", m.cursor.x) - } - }) -} - -func TestEnterInsertAfter(t *testing.T) { - t.Run("test 'a' enters insert mode", func(t *testing.T) { - tm := newTestModel(t) - sendKeys(tm, "a") - - m := getFinalModel(t, tm) - if m.mode != InsertMode { - t.Errorf("mode = %d, want InsertMode (%d)", m.mode, InsertMode) - } - }) - - t.Run("test 'a' inserts after cursor", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "a", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "hXello" { - t.Errorf("lines[0] = %q, want 'hXello'", m.lines[0]) - } - }) - - t.Run("test 'a' from middle of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 2, Line: 0}) - sendKeys(tm, "a", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "helXlo" { - t.Errorf("lines[0] = %q, want 'helXlo'", m.lines[0]) - } - }) -} - -func TestEnterInsertLineStart(t *testing.T) { - t.Run("test 'I' enters insert mode at line start", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 3, Line: 0}) - sendKeys(tm, "I", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "Xhello" { - t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) - } - }) - - t.Run("test 'I' from end of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 5, Line: 0}) - sendKeys(tm, "I", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "Xhello" { - t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) - } - }) -} - -func TestEnterInsertLineEnd(t *testing.T) { - t.Run("test 'A' enters insert mode at line end", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "A", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "helloX" { - t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0]) - } - }) - - t.Run("test 'A' from middle of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 2, Line: 0}) - sendKeys(tm, "A", "X", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "helloX" { - t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0]) - } - }) -} - -// --- Open Line Tests --- - -func TestOpenLineBelow(t *testing.T) { - t.Run("test 'o' creates line below", func(t *testing.T) { - lines := []string{"line 1", "line 2"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "o", "n", "e", "w", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 3 { - t.Errorf("len(lines) = %d, want 3", len(m.lines)) - } - if m.lines[1] != "new" { - t.Errorf("lines[1] = %q, want 'new'", m.lines[1]) - } - }) - - t.Run("test 'o' from middle of file", func(t *testing.T) { - lines := []string{"line 1", "line 2", "line 3"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 0, Line: 1}) - sendKeys(tm, "o", "n", "e", "w", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 4 { - t.Errorf("len(lines) = %d, want 4", len(m.lines)) - } - if m.lines[2] != "new" { - t.Errorf("lines[2] = %q, want 'new'", m.lines[2]) - } - }) - - t.Run("test 'o' at end of file", func(t *testing.T) { - lines := []string{"line 1", "line 2"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 0, Line: 1}) - sendKeys(tm, "o", "n", "e", "w", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 3 { - t.Errorf("len(lines) = %d, want 3", len(m.lines)) - } - if m.lines[2] != "new" { - t.Errorf("lines[2] = %q, want 'new'", m.lines[2]) - } - }) - - t.Run("test 'o' cursor moves to new line", func(t *testing.T) { - lines := []string{"line 1", "line 2"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "o", "esc") - - m := getFinalModel(t, tm) - if m.cursor.y != 1 { - t.Errorf("cursor.y = %d, want 1", m.cursor.y) - } - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) -} - -func TestOpenLineBelowWithCount(t *testing.T) { - t.Run("test '3o' creates 3 lines", func(t *testing.T) { - lines := []string{"line 1"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "3", "o", "x", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 4 { - t.Errorf("len(lines) = %d, want 4", len(m.lines)) - } - for i := 1; i <= 3; i++ { - if m.lines[i] != "x" { - t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i]) - } - } - }) - - t.Run("test '2o' with multiple chars", func(t *testing.T) { - lines := []string{"line 1"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "2", "o", "a", "b", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 3 { - t.Errorf("len(lines) = %d, want 3", len(m.lines)) - } - if m.lines[1] != "ab" { - t.Errorf("lines[1] = %q, want 'ab'", m.lines[1]) - } - if m.lines[2] != "ab" { - t.Errorf("lines[2] = %q, want 'ab'", m.lines[2]) - } - }) -} - -func TestOpenLineAbove(t *testing.T) { - t.Run("test 'O' creates line above", func(t *testing.T) { - lines := []string{"line 1", "line 2"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 0, Line: 1}) - sendKeys(tm, "O", "n", "e", "w", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 3 { - t.Errorf("len(lines) = %d, want 3", len(m.lines)) - } - if m.lines[1] != "new" { - t.Errorf("lines[1] = %q, want 'new'", m.lines[1]) - } - }) - - t.Run("test 'O' at top of file", func(t *testing.T) { - lines := []string{"line 1", "line 2"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "O", "n", "e", "w", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 3 { - t.Errorf("len(lines) = %d, want 3", len(m.lines)) - } - if m.lines[0] != "new" { - t.Errorf("lines[0] = %q, want 'new'", m.lines[0]) - } - }) - - t.Run("test 'O' cursor at start of new line", func(t *testing.T) { - lines := []string{"line 1", "line 2"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 3, Line: 1}) - sendKeys(tm, "O", "esc") - - m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) - } - }) -} - -func TestOpenLineAboveWithCount(t *testing.T) { - t.Run("test '3O' creates 3 lines above", func(t *testing.T) { - lines := []string{"line 1"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 0, Line: 0}) - sendKeys(tm, "3", "O", "x", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 4 { - t.Errorf("len(lines) = %d, want 4", len(m.lines)) - } - for i := 0; i < 3; i++ { - if m.lines[i] != "x" { - t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i]) - } - } - }) -} - -// --- Insert Mode Special Keys --- - -func TestInsertModeEnter(t *testing.T) { - t.Run("test enter splits line", func(t *testing.T) { - lines := []string{"hello world"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 5, Line: 0}) - sendKeys(tm, "i", "enter", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 2 { - t.Errorf("len(lines) = %d, want 2", len(m.lines)) - } - if m.lines[0] != "hello" { - t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) - } - if m.lines[1] != " world" { - t.Errorf("lines[1] = %q, want ' world'", m.lines[1]) - } - }) - - t.Run("test enter at end of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 5, Line: 0}) - sendKeys(tm, "i", "enter", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 2 { - t.Errorf("len(lines) = %d, want 2", len(m.lines)) - } - if m.lines[0] != "hello" { - t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) - } - if m.lines[1] != "" { - t.Errorf("lines[1] = %q, want ''", m.lines[1]) - } - }) - - t.Run("test enter at start of line", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "i", "enter", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 2 { - t.Errorf("len(lines) = %d, want 2", len(m.lines)) - } - if m.lines[0] != "" { - t.Errorf("lines[0] = %q, want ''", m.lines[0]) - } - if m.lines[1] != "hello" { - t.Errorf("lines[1] = %q, want 'hello'", m.lines[1]) - } - }) -} - -func TestInsertModeBackspace(t *testing.T) { - t.Run("test backspace deletes character", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 3, Line: 0}) - sendKeys(tm, "i", "backspace", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "helo" { - t.Errorf("lines[0] = %q, want 'helo'", m.lines[0]) - } - }) - - t.Run("test backspace at start of line joins lines", func(t *testing.T) { - lines := []string{"hello", "world"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 0, Line: 1}) - sendKeys(tm, "i", "backspace", "esc") - - m := getFinalModel(t, tm) - if len(m.lines) != 1 { - t.Errorf("len(lines) = %d, want 1", len(m.lines)) - } - if m.lines[0] != "helloworld" { - t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0]) - } - }) - - t.Run("test backspace at start of first line does nothing", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithLines(t, lines) - sendKeys(tm, "i", "backspace", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "hello" { - t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) - } - }) - - t.Run("test multiple backspaces", func(t *testing.T) { - lines := []string{"hello"} - tm := newTestModelWithCursorPosAndLines(t, lines, Position{Col: 5, Line: 0}) - sendKeys(tm, "i", "backspace", "backspace", "backspace", "esc") - - m := getFinalModel(t, tm) - if m.lines[0] != "he" { - t.Errorf("lines[0] = %q, want 'he'", m.lines[0]) - } - }) -}