diff --git a/internal/action/action.go b/internal/action/action.go index c300c49..7a0efc5 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -89,6 +89,12 @@ type Model interface { Settings() Settings SetSettings(s Settings) + // Registers + Registers() map[rune]Register + GetRegister(name rune) (Register, bool) + SetRegister(name rune, t RegisterType, cnt []string) error + UpdateDefault(t RegisterType, cnt []string) + // Mode Mode() Mode SetMode(mode Mode) @@ -109,10 +115,16 @@ type Position struct { type MotionType int const ( - Charwise MotionType = iota // h, l, w, e, f, t - operates on characters - Linewise // j, k, G, gg, {, } - operates on whole lines + CharwiseExclusive MotionType = iota // w, b, h, l, 0, ^ - end position not included + CharwiseInclusive // e, $, f - end position is included + Linewise // j, k, G, gg, {, } - operates on whole lines ) +// IsCharwise returns true if the motion type is character-based (not linewise) +func (mt MotionType) IsCharwise() bool { + return mt == CharwiseExclusive || mt == CharwiseInclusive +} + // Action is the base interface - anything executable type Action interface { Execute(m Model) tea.Cmd @@ -127,7 +139,10 @@ type Motion interface { // Operator acts on a range (delete, yank, change) type Operator interface { Operate(m Model, start, end Position, mtype MotionType) tea.Cmd - // DoublePress handles dd, yy, cc (line-wise) +} + +// DoublePresser is an optional interface for operators that support double-press (dd, yy, cc) +type DoublePresser interface { DoublePress(m Model, count int) tea.Cmd } diff --git a/internal/action/paste.go b/internal/action/paste.go new file mode 100644 index 0000000..da4076b --- /dev/null +++ b/internal/action/paste.go @@ -0,0 +1,149 @@ +package action + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// Paste implements Action (p) - pastes after cursor +type Paste struct { + Count int +} + +func (a Paste) Execute(m Model) tea.Cmd { + // Get reg + reg, found := m.GetRegister('"') + if !found { + m.SetCommandError(fmt.Errorf("\"\" register is broken. Uh oh.")) + return nil + } + + // Exit if blank + if len(reg.Content) == 0 { + return nil + } + + switch reg.Type { + case LinewiseRegister: + { + initY := m.CursorY() + lines := reg.Content + insertPos := initY + 1 + + // Run count times + for range a.Count { + for _, line := range lines { + m.InsertLine(insertPos, line) + insertPos++ + } + } + + if m.LineCount() > 1 { + m.SetCursorY(initY + 1) + } + } + case CharwiseRegister: + { + lines := reg.Content + + // Shouldn't happen, just a check + if len(lines) != 1 { + m.SetCommandError(fmt.Errorf("Charwise register should only have a single line of content.")) + break + } + + x := m.CursorX() + y := m.CursorY() + + cnt := strings.Repeat(lines[0], max(1, a.Count)) + curLine := m.Line(y) + + // Catch edge cases, end of line, start of blank line + insertAt := min(x+1, len(curLine)) + newLine := curLine[:insertAt] + cnt + curLine[insertAt:] + m.SetLine(y, newLine) + + m.SetCursorX(x + len(cnt)) + m.ClampCursorX() + } + default: + m.SetCommandError(fmt.Errorf("Register type is not implemented.")) + } + + return nil +} + +// Ensure Paste implements Repeatable +var _ Repeatable = Paste{} + +func (a Paste) WithCount(n int) Action { + return Paste{Count: n} +} + +// PasteBefore implements Action (P) - pastes before cursor +type PasteBefore struct { + Count int +} + +func (a PasteBefore) Execute(m Model) tea.Cmd { + // Get reg + reg, found := m.GetRegister('"') + if !found { + m.SetCommandError(fmt.Errorf("\"\" register is broken. Uh oh.")) + return nil + } + + switch reg.Type { + case LinewiseRegister: + { + initY := m.CursorY() + lines := reg.Content + insertPos := initY // Leave here, this will effectively move the lines below + + // Run count times + for range a.Count { + for _, line := range lines { + m.InsertLine(insertPos, line) + insertPos++ + } + } + } + case CharwiseRegister: + { + lines := reg.Content + + // Shouldn't happen, just a check + if len(lines) != 1 { + m.SetCommandError(fmt.Errorf("Charwise register should only have a single line of content.")) + break + } + + x := m.CursorX() + y := m.CursorY() + + cnt := strings.Repeat(lines[0], max(1, a.Count)) + curLine := m.Line(y) + + // Catch edge cases, end of line, start of blank line + insertAt := min(x, len(curLine)) + newLine := curLine[:insertAt] + cnt + curLine[insertAt:] + m.SetLine(y, newLine) + + m.SetCursorX(x + len(cnt)) + m.ClampCursorX() + } + default: + m.SetCommandError(fmt.Errorf("Register type is not implemented.")) + } + + return nil +} + +// Ensure PasteBefore implements Repeatable +var _ Repeatable = PasteBefore{} + +func (a PasteBefore) WithCount(n int) Action { + return PasteBefore{Count: n} +} diff --git a/internal/action/register.go b/internal/action/register.go new file mode 100644 index 0000000..be2346c --- /dev/null +++ b/internal/action/register.go @@ -0,0 +1,75 @@ +package action + +type RegisterType int + +const ( + CharwiseRegister RegisterType = iota + LinewiseRegister + BlockwiseRegister +) + +type Register struct { + Type RegisterType + Content []string +} + +func DefaultRegisters() map[rune]Register { + reg := make(map[rune]Register) + + addSpecialRegisters(reg) + addNamedRegisters(reg) + addNumberedRegisters(reg) + + return reg +} + +func addNamedRegisters(reg map[rune]Register) { + name := 'a' + + for name <= 'z' { + reg[name] = emptyRegister() + name++ + } +} + +func addNumberedRegisters(reg map[rune]Register) { + name := '0' + + for name <= '9' { + reg[name] = emptyRegister() + name++ + } + +} + +func addSpecialRegisters(reg map[rune]Register) { + // Unnamed (default) + reg['"'] = emptyRegister() + + // Black hole (readonly) + reg['_'] = emptyRegister() + + // System clipboard + reg['*'] = emptyRegister() + + // Small delete? Expression? + + // Last inserted text (readonly) + reg['.'] = emptyRegister() + + // Current file name (readonly) + reg['%'] = emptyRegister() + + // Last executed command (readonly) + reg[':'] = emptyRegister() + + // Alternate (previous) file (readonly) + reg['#'] = emptyRegister() +} + +func emptyRegister() Register { + return Register{ + Type: CharwiseRegister, + Content: []string{}, + } +} diff --git a/internal/action/tmp b/internal/action/tmp new file mode 100644 index 0000000..3e23ae4 --- /dev/null +++ b/internal/action/tmp @@ -0,0 +1,2 @@ + +hello diff --git a/internal/command/handlers.go b/internal/command/handlers.go index 8afff45..a5b3bdf 100644 --- a/internal/command/handlers.go +++ b/internal/command/handlers.go @@ -53,6 +53,32 @@ func cmdWriteQuit(m action.Model, args []string) tea.Cmd { } } +// cmdRegisters handles :register +func cmdRegisters(m action.Model, args []string) tea.Cmd { + // TODO: This is temporary, for debugging + if len(args) < 1 { + m.SetCommandError(fmt.Errorf("Please provide a name. Register dump not yet implemented.")) + return nil + } + + if len(args[0]) != 1 { + m.SetCommandError(fmt.Errorf("Name should be a single character.")) + return nil + } + + name := rune(args[0][0]) + reg, found := m.GetRegister(name) + if !found { + m.SetCommandError(fmt.Errorf("Could not find register '%c'.", name)) + return nil + } + + content := strings.Join(reg.Content, "\\n") + t := reg.Type + m.SetCommandOutput(fmt.Sprintf("Type: %d Name: \"%c Content: %s", t, name, content)) + return nil +} + // cmdSet handles :set option[=value] // Examples: // diff --git a/internal/command/registry.go b/internal/command/registry.go index 057ef15..7944ca9 100644 --- a/internal/command/registry.go +++ b/internal/command/registry.go @@ -10,8 +10,8 @@ import ( // Command represents a command that can be executed from command mode type Command struct { - Name string // Full name: "quit" - ShortForm string // Minimum abbreviation: "q" + Name string // Full name: "quit" + ShortForm string // Minimum abbreviation: "q" Handler func(m action.Model, args []string) tea.Cmd // Handler function } @@ -139,4 +139,11 @@ func (r *Registry) registerDefaults() { ShortForm: "se", Handler: cmdSet, }) + + // Register commands + r.Register(Command{ + Name: "register", + ShortForm: "reg", + Handler: cmdRegisters, + }) } diff --git a/internal/editor/TEST_HELPERS.md b/internal/editor/TEST_HELPERS.md new file mode 100644 index 0000000..bfec18f --- /dev/null +++ b/internal/editor/TEST_HELPERS.md @@ -0,0 +1,124 @@ +# Test Helper Reference + +## Overview + +The test helpers use the **functional options pattern** to make test setup flexible and composable. + +## Basic Usage + +```go +// Default model (6 lines, cursor at 0,0, terminal 80x24) +tm := newTestModel(t) + +// With custom lines +tm := newTestModel(t, + WithLines([]string{"hello", "world"}), +) + +// With custom cursor position +tm := newTestModel(t, + WithCursorPos(action.Position{Line: 1, Col: 5}), +) + +// With custom terminal size +tm := newTestModel(t, + WithTermSize(120, 40), +) + +// With register content (useful for paste tests) +tm := newTestModel(t, + WithRegister('"', action.CharwiseRegister, []string{"yanked text"}), +) +``` + +## Combining Options + +You can combine multiple options in a single call: + +```go +tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithTermSize(100, 30), + WithRegister('"', action.LinewiseRegister, []string{"deleted line"}), +) +``` + +## Available Options + +| Option | Parameters | Description | +|--------|-----------|-------------| +| `WithLines` | `[]string` | Set buffer lines | +| `WithCursorPos` | `action.Position` | Set cursor position | +| `WithTermSize` | `width, height int` | Set terminal dimensions | +| `WithRegister` | `name rune, type RegisterType, content []string` | Set register content | + +## Backward Compatibility + +The old helper functions still work for existing tests: + +```go +newTestModelWithLines(t, []string{"a", "b"}) +newTestModelWithCursorPos(t, action.Position{Line: 1, Col: 2}) +newTestModelWithLinesAndCursorPos(t, lines, pos) +newTestModelWithTermSize(t, lines, pos, width, height) +``` + +## Example Test + +```go +func TestPasteCharwise(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 5}), + WithRegister('"', action.CharwiseRegister, []string{"PASTE"}), + ) + + sendKeys(tm, "p") + m := getFinalModel(t, tm) + + // Assert expected behavior + if m.Line(0) != "hello PASTEworld" { + t.Errorf("unexpected result: %s", m.Line(0)) + } +} +``` + +## Default Values + +When options are not specified: + +- **Lines**: `[]string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"}` +- **Cursor**: `{Line: 0, Col: 0}` +- **Terminal**: `80x24` +- **Register**: None set + +## Adding New Options + +To add a new option: + +1. Add field to `testModelConfig` +2. Create a `With*` function that returns `TestModelOption` +3. Apply the option in `newTestModel` after creating the model + +Example: + +```go +// In testModelConfig +type testModelConfig struct { + // ... existing fields + scrollY int +} + +// New option function +func WithScrollY(y int) TestModelOption { + return func(c *testModelConfig) { + c.scrollY = y + } +} + +// Apply in newTestModel +if cfg.scrollY > 0 { + m.SetScrollY(cfg.scrollY) +} +``` diff --git a/internal/editor/helpers_example_test.go b/internal/editor/helpers_example_test.go new file mode 100644 index 0000000..afc5a28 --- /dev/null +++ b/internal/editor/helpers_example_test.go @@ -0,0 +1,118 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// TestHelperExamples demonstrates the different ways to use the test helpers +func TestHelperExamples(t *testing.T) { + t.Run("basic model with defaults", func(t *testing.T) { + // Uses default: 6 lines, cursor at 0,0, terminal 80x24 + tm := newTestModel(t) + _ = getFinalModel(t, tm) + }) + + t.Run("custom lines only", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello", "world"}), + ) + m := getFinalModel(t, tm) + if len(m.lines) != 2 { + t.Errorf("expected 2 lines, got %d", len(m.lines)) + } + }) + + t.Run("custom cursor position", func(t *testing.T) { + tm := newTestModel(t, + WithCursorPos(action.Position{Line: 2, Col: 3}), + ) + m := getFinalModel(t, tm) + if m.CursorY() != 2 || m.CursorX() != 3 { + t.Errorf("expected cursor at (2,3), got (%d,%d)", m.CursorY(), m.CursorX()) + } + }) + + t.Run("custom terminal size", func(t *testing.T) { + tm := newTestModel(t, + WithTermSize(120, 40), + ) + m := getFinalModel(t, tm) + if m.WinW() != 120 || m.WinH() != 40 { + t.Errorf("expected size 120x40, got %dx%d", m.WinW(), m.WinH()) + } + }) + + t.Run("with register content for paste testing", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithRegister('"', action.CharwiseRegister, []string{"foo"}), + ) + m := getFinalModel(t, tm) + + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("expected register to be set") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("expected charwise register, got %v", reg.Type) + } + if len(reg.Content) != 1 || reg.Content[0] != "foo" { + t.Errorf("expected content ['foo'], got %v", reg.Content) + } + }) + + t.Run("combine multiple options", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line one", "line two", "line three"}), + WithCursorPos(action.Position{Line: 1, Col: 5}), + WithTermSize(100, 30), + WithRegister('"', action.LinewiseRegister, []string{"deleted line 1", "deleted line 2"}), + ) + m := getFinalModel(t, tm) + + // Verify all options were applied + if len(m.Lines()) != 3 { + t.Errorf("expected 3 lines, got %d", len(m.Lines())) + } + if m.CursorY() != 1 || m.CursorX() != 5 { + t.Errorf("expected cursor at (1,5), got (%d,%d)", m.CursorY(), m.CursorX()) + } + if m.WinW() != 100 || m.WinH() != 30 { + t.Errorf("expected size 100x30, got %dx%d", m.WinW(), m.WinH()) + } + + reg, ok := m.GetRegister('"') + if !ok || reg.Type != action.LinewiseRegister { + t.Error("register not set correctly") + } + }) + + t.Run("backward compatible helpers still work", func(t *testing.T) { + // Old style helpers still work for existing tests + tm1 := newTestModelWithLines(t, []string{"a", "b"}) + m1 := getFinalModel(t, tm1) + if len(m1.Lines()) != 2 { + t.Error("newTestModelWithLines failed") + } + + tm2 := newTestModelWithCursorPos(t, action.Position{Line: 1, Col: 2}) + m2 := getFinalModel(t, tm2) + if m2.CursorY() != 1 { + t.Error("newTestModelWithCursorPos failed") + } + + tm3 := newTestModelWithLinesAndCursorPos(t, []string{"x"}, action.Position{Line: 0, Col: 0}) + m3 := getFinalModel(t, tm3) + if len(m3.Lines()) != 1 { + t.Error("newTestModelWithLinesAndCursorPos failed") + } + + tm4 := newTestModelWithTermSize(t, []string{"y"}, action.Position{Line: 0, Col: 0}, 50, 20) + m4 := getFinalModel(t, tm4) + if m4.WinW() != 50 { + t.Error("newTestModelWithTermSize failed") + } + }) +} diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index 6be1e5d..3582d2f 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -35,27 +35,91 @@ func sendKeys(tm *teatest.TestModel, keys ...string) { } } -// 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, action.Position{Col: 0, Line: 0}), teatest.WithInitialTermSize(80, 24)) +// TestModelOption is a functional option for configuring test models +type TestModelOption func(*testModelConfig) + +type testModelConfig struct { + lines []string + pos action.Position + width int + height int + regName rune + regType action.RegisterType + regContent []string } +// WithLines sets the initial buffer lines +func WithLines(lines []string) TestModelOption { + return func(c *testModelConfig) { + c.lines = lines + } +} + +// WithCursorPos sets the initial cursor position +func WithCursorPos(pos action.Position) TestModelOption { + return func(c *testModelConfig) { + c.pos = pos + } +} + +// WithTermSize sets the terminal dimensions +func WithTermSize(width, height int) TestModelOption { + return func(c *testModelConfig) { + c.width = width + c.height = height + } +} + +// WithRegister sets a register's content +func WithRegister(name rune, regType action.RegisterType, content []string) TestModelOption { + return func(c *testModelConfig) { + c.regName = name + c.regType = regType + c.regContent = content + } +} + +// newTestModel creates a test model with optional configuration +func newTestModel(t *testing.T, opts ...TestModelOption) *teatest.TestModel { + // Default configuration + cfg := testModelConfig{ + lines: []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"}, + pos: action.Position{Col: 0, Line: 0}, + width: 80, + height: 24, + } + + // Apply options + for _, opt := range opts { + opt(&cfg) + } + + // Create model + m := NewModel(cfg.lines, cfg.pos) + + // Set register if provided + if cfg.regContent != nil { + m.SetRegister(cfg.regName, cfg.regType, cfg.regContent) + } + + return teatest.NewTestModel(t, m, teatest.WithInitialTermSize(cfg.width, cfg.height)) +} + +// Convenience functions for backward compatibility func newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel { - return teatest.NewTestModel(t, NewModel(lines, action.Position{Col: 0, Line: 0}), teatest.WithInitialTermSize(80, 24)) + return newTestModel(t, WithLines(lines)) } 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, NewModel(lines, pos), teatest.WithInitialTermSize(80, 24)) + return newTestModel(t, WithCursorPos(pos)) } func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos action.Position) *teatest.TestModel { - return teatest.NewTestModel(t, NewModel(lines, pos), teatest.WithInitialTermSize(80, 24)) + return newTestModel(t, WithLines(lines), WithCursorPos(pos)) } func newTestModelWithTermSize(t *testing.T, lines []string, pos action.Position, width, height int) *teatest.TestModel { - return teatest.NewTestModel(t, NewModel(lines, pos), teatest.WithInitialTermSize(width, height)) + return newTestModel(t, WithLines(lines), WithCursorPos(pos), WithTermSize(width, height)) } // getFinalModel extracts the final model state (sends ctrl+c to quit first) diff --git a/internal/editor/integration_operator_delete_test.go b/internal/editor/integration_operator_delete_test.go index 61b08fa..000ce7c 100644 --- a/internal/editor/integration_operator_delete_test.go +++ b/internal/editor/integration_operator_delete_test.go @@ -615,9 +615,9 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) { sendKeys(tm, "d", "e") m := getFinalModel(t, tm) - // 'e' is inclusive - deletes "hello" (cols 0-4 inclusive) - if m.Line(0) != "o world" { - t.Errorf("Line(0) = %q, want \"o world\"", m.Line(0)) + // 'e' is inclusive - deletes "hello" (cols 0-4 inclusive), leaves " world" + if m.Line(0) != " world" { + t.Errorf("Line(0) = %q, want \" world\"", m.Line(0)) } if m.CursorX() != 0 { t.Errorf("CursorX() = %d, want 0", m.CursorX()) @@ -631,8 +631,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) { m := getFinalModel(t, tm) // From 'l' (col 2) to 'o' (col 4) inclusive → deletes "llo" - if m.Line(0) != "heo world" { - t.Errorf("Line(0) = %q, want \"heo world\"", m.Line(0)) + if m.Line(0) != "he world" { + t.Errorf("Line(0) = %q, want \"he world\"", m.Line(0)) } }) @@ -643,8 +643,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) { m := getFinalModel(t, tm) // From 'o' of "hello" (col 4) to 'd' of "world" (col 10) inclusive - if m.Line(0) != "helld" { - t.Errorf("Line(0) = %q, want \"helld\"", m.Line(0)) + if m.Line(0) != "hell" { + t.Errorf("Line(0) = %q, want \"hell\"", m.Line(0)) } }) @@ -654,9 +654,9 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) { sendKeys(tm, "2", "d", "e") m := getFinalModel(t, tm) - // Deletes "one" and "two" (to end of second word inclusive) - if m.Line(0) != "o three four" { - t.Errorf("Line(0) = %q, want \"o three four\"", m.Line(0)) + // Deletes "one" and " two" (to end of second word inclusive) + if m.Line(0) != " three four" { + t.Errorf("Line(0) = %q, want \" three four\"", m.Line(0)) } }) diff --git a/internal/editor/integration_paste_test.go b/internal/editor/integration_paste_test.go new file mode 100644 index 0000000..518608d --- /dev/null +++ b/internal/editor/integration_paste_test.go @@ -0,0 +1,576 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +func TestPasteLinewiseBasic(t *testing.T) { + t.Run("p pastes single line after cursor line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "line 1" { + t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0)) + } + if m.Line(1) != "inserted" { + t.Errorf("Line(1) = %q, want 'inserted'", m.Line(1)) + } + if m.Line(2) != "line 2" { + t.Errorf("Line(2) = %q, want 'line 2'", m.Line(2)) + } + }) + + t.Run("p moves cursor to first pasted line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 0, Col: 5}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) + + t.Run("p from middle of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 4 { + t.Errorf("LineCount() = %d, want 4", m.LineCount()) + } + if m.Line(2) != "inserted" { + t.Errorf("Line(2) = %q, want 'inserted'", m.Line(2)) + } + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + }) + + t.Run("p at end of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(2) != "inserted" { + t.Errorf("Line(2) = %q, want 'inserted'", m.Line(2)) + } + }) +} + +func TestPasteLinewiseMultipleLines(t *testing.T) { + t.Run("p pastes multiple lines in correct order", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"first", "second", "third"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 5 { + t.Errorf("LineCount() = %d, want 5", m.LineCount()) + } + if m.Line(0) != "line 1" { + t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0)) + } + if m.Line(1) != "first" { + t.Errorf("Line(1) = %q, want 'first'", m.Line(1)) + } + if m.Line(2) != "second" { + t.Errorf("Line(2) = %q, want 'second'", m.Line(2)) + } + if m.Line(3) != "third" { + t.Errorf("Line(3) = %q, want 'third'", m.Line(3)) + } + if m.Line(4) != "line 2" { + t.Errorf("Line(4) = %q, want 'line 2'", m.Line(4)) + } + }) + + t.Run("p with multiple lines moves cursor to first pasted line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"first", "second"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) +} + +func TestPasteLinewiseWithCount(t *testing.T) { + t.Run("2p pastes content twice", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "2", "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 4 { + t.Errorf("LineCount() = %d, want 4", m.LineCount()) + } + // Both "inserted" lines should appear after line 1 + if m.Line(1) != "inserted" { + t.Errorf("Line(1) = %q, want 'inserted'", m.Line(1)) + } + if m.Line(2) != "inserted" { + t.Errorf("Line(2) = %q, want 'inserted'", m.Line(2)) + } + if m.Line(3) != "line 2" { + t.Errorf("Line(3) = %q, want 'line 2'", m.Line(3)) + } + }) + + t.Run("3p pastes content three times", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"original"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"pasted"}), + ) + sendKeys(tm, "3", "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 4 { + t.Errorf("LineCount() = %d, want 4", m.LineCount()) + } + for i := 1; i <= 3; i++ { + if m.Line(i) != "pasted" { + t.Errorf("Line(%d) = %q, want 'pasted'", i, m.Line(i)) + } + } + }) + + t.Run("2p with multiple lines pastes all lines twice in order", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"original"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"first", "second"}), + ) + sendKeys(tm, "2", "p") + + m := getFinalModel(t, tm) + // Should be: original, first, second, first, second + if m.LineCount() != 5 { + t.Errorf("LineCount() = %d, want 5", m.LineCount()) + } + if m.Line(0) != "original" { + t.Errorf("Line(0) = %q, want 'original'", m.Line(0)) + } + if m.Line(1) != "first" { + t.Errorf("Line(1) = %q, want 'first'", m.Line(1)) + } + if m.Line(2) != "second" { + t.Errorf("Line(2) = %q, want 'second'", m.Line(2)) + } + if m.Line(3) != "first" { + t.Errorf("Line(3) = %q, want 'first'", m.Line(3)) + } + if m.Line(4) != "second" { + t.Errorf("Line(4) = %q, want 'second'", m.Line(4)) + } + }) + + t.Run("count paste moves cursor to first pasted line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "3", "p") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) +} + +// Tests for P (paste before) + +func TestPasteBeforeLinewiseBasic(t *testing.T) { + t.Run("P pastes single line before cursor line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "line 1" { + t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0)) + } + if m.Line(1) != "inserted" { + t.Errorf("Line(1) = %q, want 'inserted'", m.Line(1)) + } + if m.Line(2) != "line 2" { + t.Errorf("Line(2) = %q, want 'line 2'", m.Line(2)) + } + }) + + t.Run("P moves cursor to first pasted line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 5}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) + + t.Run("P at first line pastes at very top", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "inserted" { + t.Errorf("Line(0) = %q, want 'inserted'", m.Line(0)) + } + if m.Line(1) != "line 1" { + t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1)) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("P from middle of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 4 { + t.Errorf("LineCount() = %d, want 4", m.LineCount()) + } + if m.Line(1) != "inserted" { + t.Errorf("Line(1) = %q, want 'inserted'", m.Line(1)) + } + if m.Line(2) != "line 2" { + t.Errorf("Line(2) = %q, want 'line 2'", m.Line(2)) + } + }) +} + +func TestPasteBeforeLinewiseMultipleLines(t *testing.T) { + t.Run("P pastes multiple lines in correct order", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"first", "second", "third"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 5 { + t.Errorf("LineCount() = %d, want 5", m.LineCount()) + } + if m.Line(0) != "line 1" { + t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0)) + } + if m.Line(1) != "first" { + t.Errorf("Line(1) = %q, want 'first'", m.Line(1)) + } + if m.Line(2) != "second" { + t.Errorf("Line(2) = %q, want 'second'", m.Line(2)) + } + if m.Line(3) != "third" { + t.Errorf("Line(3) = %q, want 'third'", m.Line(3)) + } + if m.Line(4) != "line 2" { + t.Errorf("Line(4) = %q, want 'line 2'", m.Line(4)) + } + }) + + t.Run("P with multiple lines moves cursor to first pasted line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"first", "second"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) +} + +func TestPasteBeforeLinewiseWithCount(t *testing.T) { + t.Run("2P pastes content twice", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "2", "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 4 { + t.Errorf("LineCount() = %d, want 4", m.LineCount()) + } + // Both "inserted" lines should appear before line 2 + if m.Line(1) != "inserted" { + t.Errorf("Line(1) = %q, want 'inserted'", m.Line(1)) + } + if m.Line(2) != "inserted" { + t.Errorf("Line(2) = %q, want 'inserted'", m.Line(2)) + } + if m.Line(3) != "line 2" { + t.Errorf("Line(3) = %q, want 'line 2'", m.Line(3)) + } + }) + + t.Run("3P pastes content three times", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"original"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"pasted"}), + ) + sendKeys(tm, "3", "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 4 { + t.Errorf("LineCount() = %d, want 4", m.LineCount()) + } + for i := 0; i < 3; i++ { + if m.Line(i) != "pasted" { + t.Errorf("Line(%d) = %q, want 'pasted'", i, m.Line(i)) + } + } + if m.Line(3) != "original" { + t.Errorf("Line(3) = %q, want 'original'", m.Line(3)) + } + }) + + t.Run("2P with multiple lines pastes all lines twice in order", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"original"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"first", "second"}), + ) + sendKeys(tm, "2", "P") + + m := getFinalModel(t, tm) + // Should be: first, second, first, second, original + if m.LineCount() != 5 { + t.Errorf("LineCount() = %d, want 5", m.LineCount()) + } + if m.Line(0) != "first" { + t.Errorf("Line(0) = %q, want 'first'", m.Line(0)) + } + if m.Line(1) != "second" { + t.Errorf("Line(1) = %q, want 'second'", m.Line(1)) + } + if m.Line(2) != "first" { + t.Errorf("Line(2) = %q, want 'first'", m.Line(2)) + } + if m.Line(3) != "second" { + t.Errorf("Line(3) = %q, want 'second'", m.Line(3)) + } + if m.Line(4) != "original" { + t.Errorf("Line(4) = %q, want 'original'", m.Line(4)) + } + }) + + t.Run("count paste before moves cursor to first pasted line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "3", "P") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) +} + +func TestPasteBeforeLinewiseEdgeCases(t *testing.T) { + t.Run("P on single line buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"only line"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + if m.Line(0) != "inserted" { + t.Errorf("Line(0) = %q, want 'inserted'", m.Line(0)) + } + if m.Line(1) != "only line" { + t.Errorf("Line(1) = %q, want 'only line'", m.Line(1)) + } + }) + + t.Run("P with empty register content does nothing", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + }) + + t.Run("P preserves indentation in pasted lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{" indented", "\ttabbed"}), + ) + sendKeys(tm, "P") + + m := getFinalModel(t, tm) + if m.Line(0) != " indented" { + t.Errorf("Line(0) = %q, want ' indented'", m.Line(0)) + } + if m.Line(1) != "\ttabbed" { + t.Errorf("Line(1) = %q, want '\\ttabbed'", m.Line(1)) + } + }) + + t.Run("P with large count", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"original"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"x"}), + ) + sendKeys(tm, "1", "0", "P") // 10P + + m := getFinalModel(t, tm) + if m.LineCount() != 11 { + t.Errorf("LineCount() = %d, want 11", m.LineCount()) + } + // Original should be at the end + if m.Line(10) != "original" { + t.Errorf("Line(10) = %q, want 'original'", m.Line(10)) + } + }) +} + +func TestPasteLinewiseEdgeCases(t *testing.T) { + t.Run("p on single line buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"only line"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"inserted"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + if m.Line(0) != "only line" { + t.Errorf("Line(0) = %q, want 'only line'", m.Line(0)) + } + if m.Line(1) != "inserted" { + t.Errorf("Line(1) = %q, want 'inserted'", m.Line(1)) + } + }) + + t.Run("p with empty register content does nothing", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.LineCount() != 2 { + t.Errorf("LineCount() = %d, want 2", m.LineCount()) + } + }) + + t.Run("p preserves indentation in pasted lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{" indented", "\ttabbed"}), + ) + sendKeys(tm, "p") + + m := getFinalModel(t, tm) + if m.Line(1) != " indented" { + t.Errorf("Line(1) = %q, want ' indented'", m.Line(1)) + } + if m.Line(2) != "\ttabbed" { + t.Errorf("Line(2) = %q, want '\\ttabbed'", m.Line(2)) + } + }) + + t.Run("p with large count", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"original"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + WithRegister('"', action.LinewiseRegister, []string{"x"}), + ) + sendKeys(tm, "1", "0", "p") // 10p + + m := getFinalModel(t, tm) + if m.LineCount() != 11 { + t.Errorf("LineCount() = %d, want 11", m.LineCount()) + } + }) +} diff --git a/internal/editor/integration_yank_test.go b/internal/editor/integration_yank_test.go new file mode 100644 index 0000000..bd5491c --- /dev/null +++ b/internal/editor/integration_yank_test.go @@ -0,0 +1,1040 @@ +package editor + +import ( + "strings" + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// ============================================================================= +// yy (Yank Line / DoublePress) Tests +// ============================================================================= + +func TestYankLineBasic(t *testing.T) { + t.Run("yy yanks current line to register", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 1 { + t.Errorf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "line 1" { + t.Errorf("register content[0] = %q, want 'line 1'", reg.Content[0]) + } + }) + + t.Run("yy does not modify buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(0) != "line 1" { + t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0)) + } + if m.Line(1) != "line 2" { + t.Errorf("Line(1) = %q, want 'line 2'", m.Line(1)) + } + if m.Line(2) != "line 3" { + t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2)) + } + }) + + t.Run("yy does not move cursor", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 3}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + if m.CursorX() != 3 { + t.Errorf("CursorX() = %d, want 3", m.CursorX()) + } + }) + + t.Run("yy from middle of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"first", "second", "third", "fourth"}), + WithCursorPos(action.Position{Line: 2, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if reg.Content[0] != "third" { + t.Errorf("register content[0] = %q, want 'third'", reg.Content[0]) + } + }) + + t.Run("yy at last line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "last line"}), + WithCursorPos(action.Position{Line: 2, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if reg.Content[0] != "last line" { + t.Errorf("register content[0] = %q, want 'last line'", reg.Content[0]) + } + }) +} + +func TestYankLineWithCount(t *testing.T) { + t.Run("2yy yanks two lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3", "line 4"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "2", "y", "y") + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if len(reg.Content) != 2 { + t.Errorf("register content length = %d, want 2", len(reg.Content)) + } + if reg.Content[0] != "line 1" { + t.Errorf("register content[0] = %q, want 'line 1'", reg.Content[0]) + } + if reg.Content[1] != "line 2" { + t.Errorf("register content[1] = %q, want 'line 2'", reg.Content[1]) + } + }) + + t.Run("3yy yanks three lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"a", "b", "c", "d", "e"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "3", "y", "y") + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if len(reg.Content) != 3 { + t.Errorf("register content length = %d, want 3", len(reg.Content)) + } + if reg.Content[0] != "b" { + t.Errorf("register content[0] = %q, want 'b'", reg.Content[0]) + } + if reg.Content[1] != "c" { + t.Errorf("register content[1] = %q, want 'c'", reg.Content[1]) + } + if reg.Content[2] != "d" { + t.Errorf("register content[2] = %q, want 'd'", reg.Content[2]) + } + }) + + t.Run("yy with count overflow clamps to available lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "1", "0", "y", "y") // 10yy but only 2 lines available + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if len(reg.Content) != 2 { + t.Errorf("register content length = %d, want 2 (clamped)", len(reg.Content)) + } + if reg.Content[0] != "line 2" { + t.Errorf("register content[0] = %q, want 'line 2'", reg.Content[0]) + } + if reg.Content[1] != "line 3" { + t.Errorf("register content[1] = %q, want 'line 3'", reg.Content[1]) + } + }) + + t.Run("yy with count does not modify buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "3", "y", "y") + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + }) +} + +func TestYankLineEdgeCases(t *testing.T) { + t.Run("yy on empty line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if len(reg.Content) != 1 { + t.Errorf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "" { + t.Errorf("register content[0] = %q, want ''", reg.Content[0]) + } + }) + + t.Run("yy on single line buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"only line"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if reg.Content[0] != "only line" { + t.Errorf("register content[0] = %q, want 'only line'", reg.Content[0]) + } + }) + + t.Run("yy preserves whitespace", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{" indented", "\ttabbed", " spaces "}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "3", "y", "y") + + m := getFinalModel(t, tm) + reg, _ := m.GetRegister('"') + if reg.Content[0] != " indented" { + t.Errorf("register content[0] = %q, want ' indented'", reg.Content[0]) + } + if reg.Content[1] != "\ttabbed" { + t.Errorf("register content[1] = %q, want '\\ttabbed'", reg.Content[1]) + } + if reg.Content[2] != " spaces " { + t.Errorf("register content[2] = %q, want ' spaces '", reg.Content[2]) + } + }) +} + +// ============================================================================= +// Yank with Linewise Motions (yj, yk, yG, ygg) - TDD Tests +// ============================================================================= + +func TestYankWithLinewiseMotions(t *testing.T) { + t.Run("yj yanks current line and line below", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "j") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 2 { + t.Fatalf("register content length = %d, want 2", len(reg.Content)) + } + if reg.Content[0] != "line 1" { + t.Errorf("register content[0] = %q, want 'line 1'", reg.Content[0]) + } + if reg.Content[1] != "line 2" { + t.Errorf("register content[1] = %q, want 'line 2'", reg.Content[1]) + } + }) + + t.Run("yk yanks current line and line above", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "y", "k") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 2 { + t.Fatalf("register content length = %d, want 2", len(reg.Content)) + } + if reg.Content[0] != "line 1" { + t.Errorf("register content[0] = %q, want 'line 1'", reg.Content[0]) + } + if reg.Content[1] != "line 2" { + t.Errorf("register content[1] = %q, want 'line 2'", reg.Content[1]) + } + }) + + t.Run("yG yanks from cursor to end of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3", "line 4"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "y", "G") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 3 { + t.Fatalf("register content length = %d, want 3", len(reg.Content)) + } + if reg.Content[0] != "line 2" { + t.Errorf("register content[0] = %q, want 'line 2'", reg.Content[0]) + } + if reg.Content[2] != "line 4" { + t.Errorf("register content[2] = %q, want 'line 4'", reg.Content[2]) + } + }) + + t.Run("ygg yanks from cursor to beginning of file", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3", "line 4"}), + WithCursorPos(action.Position{Line: 2, Col: 0}), + ) + sendKeys(tm, "y", "g", "g") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 3 { + t.Fatalf("register content length = %d, want 3", len(reg.Content)) + } + if reg.Content[0] != "line 1" { + t.Errorf("register content[0] = %q, want 'line 1'", reg.Content[0]) + } + if reg.Content[2] != "line 3" { + t.Errorf("register content[2] = %q, want 'line 3'", reg.Content[2]) + } + }) + + t.Run("y2j yanks current and next two lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"a", "b", "c", "d", "e"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "y", "2", "j") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 3 { + t.Fatalf("register content length = %d, want 3", len(reg.Content)) + } + if reg.Content[0] != "b" { + t.Errorf("register content[0] = %q, want 'b'", reg.Content[0]) + } + if reg.Content[1] != "c" { + t.Errorf("register content[1] = %q, want 'c'", reg.Content[1]) + } + if reg.Content[2] != "d" { + t.Errorf("register content[2] = %q, want 'd'", reg.Content[2]) + } + }) + + t.Run("yj does not move cursor", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 0, Col: 3}), + ) + sendKeys(tm, "y", "j") + + m := getFinalModel(t, tm) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("yG does not modify buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "G") + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + }) +} + +// ============================================================================= +// Yank with Charwise Motions (yw, ye, yb, y$, y0) - TDD Tests +// ============================================================================= + +func TestYankWithCharwiseMotions(t *testing.T) { + t.Run("yw yanks word under cursor", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "w") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + // yw includes trailing space + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "hello " { + t.Errorf("register content = %q, want 'hello '", reg.Content[0]) + } + }) + + t.Run("ye yanks to end of word (exclusive)", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "e") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + // ye is inclusive of last char + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "hello" { + t.Errorf("register content = %q, want 'hello'", reg.Content[0]) + } + }) + + t.Run("yb yanks backward word", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w' + ) + sendKeys(tm, "y", "b") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + // yb from 'w' back to start of 'hello' + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "hello " { + t.Errorf("register content = %q, want 'hello '", reg.Content[0]) + } + }) + + t.Run("y$ yanks to end of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w' + ) + sendKeys(tm, "y", "$") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "world" { + t.Errorf("register content = %q, want 'world'", reg.Content[0]) + } + }) + + t.Run("y0 yanks to beginning of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w' + ) + sendKeys(tm, "y", "0") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "hello " { + t.Errorf("register content = %q, want 'hello '", reg.Content[0]) + } + }) + + t.Run("y_ yanks to first non-whitespace", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{" hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 10}), // on 'w' + ) + sendKeys(tm, "y", "_") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + // From 'w' back to 'h' (first non-ws) + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "hello " { + t.Errorf("register content = %q, want 'hello '", reg.Content[0]) + } + }) + + t.Run("y2w yanks two words", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"one two three four"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "2", "w") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "one two " { + t.Errorf("register content = %q, want 'one two '", reg.Content[0]) + } + }) + + t.Run("yw does not move cursor", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "w") + + m := getFinalModel(t, tm) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("yw does not modify buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "w") + + m := getFinalModel(t, tm) + if m.Line(0) != "hello world" { + t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0)) + } + }) +} + +// ============================================================================= +// Visual Mode Yank Tests - TDD Tests +// ============================================================================= + +func TestYankVisualCharwise(t *testing.T) { + t.Run("v selection then y yanks selected text", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "v", "l", "l", "l", "l", "y") // select "hello" + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "hello" { + t.Errorf("register content = %q, want 'hello'", reg.Content[0]) + } + }) + + t.Run("v selection across lines yanks with newlines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 3}), + ) + sendKeys(tm, "v", "j", "l", "l", "y") // select "e 1\nlin" + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.CharwiseRegister { + t.Errorf("register type = %v, want CharwiseRegister", reg.Type) + } + // Multi-line charwise yank + if len(reg.Content) < 1 { + t.Fatal("register content empty, expected multi-line selection") + } + }) + + t.Run("visual yank exits visual mode", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "v", "l", "l", "y") + + m := getFinalModel(t, tm) + if m.Mode() != action.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) + + t.Run("visual yank does not modify buffer", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "v", "l", "l", "l", "l", "y") + + m := getFinalModel(t, tm) + if m.Line(0) != "hello world" { + t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0)) + } + }) +} + +func TestYankVisualLinewise(t *testing.T) { + t.Run("V selection then y yanks entire lines", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "V", "j", "y") // select lines 1 and 2 + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 2 { + t.Fatalf("register content length = %d, want 2", len(reg.Content)) + } + if reg.Content[0] != "line 1" { + t.Errorf("register content[0] = %q, want 'line 1'", reg.Content[0]) + } + if reg.Content[1] != "line 2" { + t.Errorf("register content[1] = %q, want 'line 2'", reg.Content[1]) + } + }) + + t.Run("V on single line then y yanks that line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "V", "y") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.LinewiseRegister { + t.Errorf("register type = %v, want LinewiseRegister", reg.Type) + } + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "line 1" { + t.Errorf("register content[0] = %q, want 'line 1'", reg.Content[0]) + } + }) + + t.Run("V selection upward yanks in correct order", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2", "line 3"}), + WithCursorPos(action.Position{Line: 2, Col: 0}), + ) + sendKeys(tm, "V", "k", "y") // select from line 3 upward to line 2 + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 2 { + t.Fatalf("register content length = %d, want 2", len(reg.Content)) + } + // Order should be top-to-bottom regardless of selection direction + if reg.Content[0] != "line 2" { + t.Errorf("register content[0] = %q, want 'line 2'", reg.Content[0]) + } + if reg.Content[1] != "line 3" { + t.Errorf("register content[1] = %q, want 'line 3'", reg.Content[1]) + } + }) + + t.Run("visual line yank exits visual mode", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "V", "y") + + m := getFinalModel(t, tm) + if m.Mode() != action.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) +} + +func TestYankVisualBlock(t *testing.T) { + t.Run("ctrl+v selection then y yanks block", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"abcdef", "ghijkl", "mnopqr"}), + WithCursorPos(action.Position{Line: 0, Col: 1}), + ) + sendKeys(tm, "ctrl+v", "j", "j", "l", "l", "y") // select 3x3 block starting at col 1 + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if reg.Type != action.BlockwiseRegister { + t.Errorf("register type = %v, want BlockwiseRegister", reg.Type) + } + // Block should contain "bcd", "hij", "nop" + if len(reg.Content) != 3 { + t.Fatalf("register content length = %d, want 3", len(reg.Content)) + } + if reg.Content[0] != "bcd" { + t.Errorf("register content[0] = %q, want 'bcd'", reg.Content[0]) + } + if reg.Content[1] != "hij" { + t.Errorf("register content[1] = %q, want 'hij'", reg.Content[1]) + } + if reg.Content[2] != "nop" { + t.Errorf("register content[2] = %q, want 'nop'", reg.Content[2]) + } + }) + + t.Run("visual block yank exits visual mode", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"abcd", "efgh"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "ctrl+v", "j", "l", "y") + + m := getFinalModel(t, tm) + if m.Mode() != action.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) + + t.Run("visual block yank with uneven line lengths", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"abcdefgh", "ij", "klmnop"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "ctrl+v", "j", "j", "l", "l", "l", "y") // 4-wide block + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // Short line should be padded or truncated based on implementation + if len(reg.Content) != 3 { + t.Fatalf("register content length = %d, want 3", len(reg.Content)) + } + }) +} + +// ============================================================================= +// Register Behavior Tests +// ============================================================================= + +func TestYankRegisterBehavior(t *testing.T) { + t.Run("yy updates register 0 and unnamed register", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", "line 2"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + + // Check unnamed register + unnamed, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(unnamed.Content) == 0 { + t.Fatal("unnamed register is empty") + } + if unnamed.Content[0] != "line 1" { + t.Errorf("unnamed register = %q, want 'line 1'", unnamed.Content[0]) + } + + // Check register 0 + reg0, ok := m.GetRegister('0') + if !ok { + t.Fatal("register 0 not found") + } + if len(reg0.Content) == 0 { + t.Fatal("register 0 is empty") + } + if reg0.Content[0] != "line 1" { + t.Errorf("register 0 = %q, want 'line 1'", reg0.Content[0]) + } + }) + + t.Run("multiple yanks shift numbered registers", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"first", "second", "third"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "y") // yank "first" + sendKeys(tm, "j") + sendKeys(tm, "y", "y") // yank "second" + + m := getFinalModel(t, tm) + + // Most recent yank should be in 0 and unnamed + reg0, ok := m.GetRegister('0') + if !ok { + t.Fatal("register 0 not found") + } + if len(reg0.Content) == 0 { + t.Fatal("register 0 is empty") + } + if reg0.Content[0] != "second" { + t.Errorf("register 0 = %q, want 'second'", reg0.Content[0]) + } + + // Previous yank should shift to register 1 + reg1, ok := m.GetRegister('1') + if !ok { + t.Fatal("register 1 not found") + } + if len(reg1.Content) == 0 { + t.Fatal("register 1 is empty") + } + if reg1.Content[0] != "first" { + t.Errorf("register 1 = %q, want 'first'", reg1.Content[0]) + } + }) + + t.Run("yank then paste uses correct content", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"original", "to copy"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "y", "y") // yank "to copy" + sendKeys(tm, "k") // move up + sendKeys(tm, "p") // paste + + m := getFinalModel(t, tm) + if m.LineCount() != 3 { + t.Errorf("LineCount() = %d, want 3", m.LineCount()) + } + if m.Line(1) != "to copy" { + t.Errorf("Line(1) = %q, want 'to copy'", m.Line(1)) + } + }) +} + +// ============================================================================= +// Edge Cases and Special Scenarios +// ============================================================================= + +func TestYankEdgeCases(t *testing.T) { + t.Run("yy on whitespace-only line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"line 1", " ", "line 3"}), + WithCursorPos(action.Position{Line: 1, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) == 0 { + t.Fatal("register is empty") + } + if reg.Content[0] != " " { + t.Errorf("register content = %q, want ' '", reg.Content[0]) + } + }) + + t.Run("yw at end of line", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello"}), + WithCursorPos(action.Position{Line: 0, Col: 4}), // on 'o' + ) + sendKeys(tm, "y", "w") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // At end of line, yw should yank just the last character + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "o" { + t.Errorf("register content = %q, want 'o'", reg.Content[0]) + } + }) + + t.Run("y$ at beginning of line yanks entire line content", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello world"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "$") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "hello world" { + t.Errorf("register content = %q, want 'hello world'", reg.Content[0]) + } + }) + + t.Run("y0 at beginning of line yanks nothing", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello"}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "0") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // At col 0, y0 should yank empty string + if len(reg.Content) != 1 { + t.Fatalf("register content length = %d, want 1", len(reg.Content)) + } + if reg.Content[0] != "" { + t.Errorf("register content = %q, want ''", reg.Content[0]) + } + }) + + t.Run("yy on very long line", func(t *testing.T) { + longLine := strings.Repeat("a", 1000) + tm := newTestModel(t, + WithLines([]string{longLine}), + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) == 0 { + t.Fatal("register is empty") + } + if len(reg.Content[0]) != 1000 { + t.Errorf("register content length = %d, want 1000", len(reg.Content[0])) + } + }) + + t.Run("yy with special characters", func(t *testing.T) { + tm := newTestModel(t, + WithLines([]string{"hello\tworld", "foo\nbar"}), // tab and embedded newline + WithCursorPos(action.Position{Line: 0, Col: 0}), + ) + sendKeys(tm, "y", "y") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + if len(reg.Content) == 0 { + t.Fatal("register is empty") + } + if reg.Content[0] != "hello\tworld" { + t.Errorf("register content = %q, want 'hello\\tworld'", reg.Content[0]) + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go index 5275e1a..3113e71 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -1,6 +1,7 @@ package editor import ( + "fmt" "strings" "git.gophernest.net/azpect/TextEditor/internal/action" @@ -36,6 +37,9 @@ type Model struct { // Settings settings action.Settings + + // Registers + registers map[rune]action.Register // name -> register } func NewModel(lines []string, pos action.Position) Model { @@ -45,11 +49,12 @@ func NewModel(lines []string, pos action.Position) Model { x: pos.Col, y: pos.Line, }, - scrollY: 0, - mode: action.NormalMode, - command: "", - input: input.NewHandler(), - settings: action.NewDefaultSettings(), + scrollY: 0, + mode: action.NormalMode, + command: "", + input: input.NewHandler(), + settings: action.NewDefaultSettings(), + registers: action.DefaultRegisters(), } } @@ -186,6 +191,40 @@ func (m *Model) SetSettings(s action.Settings) { m.settings = s } +// Registers +func (m *Model) Registers() map[rune]action.Register { + return m.registers +} + +func (m *Model) GetRegister(name rune) (action.Register, bool) { + reg, found := m.registers[name] + return reg, found +} + +func (m *Model) SetRegister(name rune, t action.RegisterType, cnt []string) error { + if _, found := m.GetRegister(name); !found { + return fmt.Errorf("Register '%c' does not exist.", name) + } + + // TODO: This might be slow, pointers maybe? + reg := action.Register{Type: t, Content: cnt} + m.registers[name] = reg + + return nil +} + +// TODO: Errors? +func (m *Model) UpdateDefault(t action.RegisterType, cnt []string) { + // Shift numbered registers: 0 -> 1 -> 2 -> ... -> 9 -> _ (discarded) + for i := rune('9'); i > '0'; i-- { + m.registers[i] = m.registers[i-1] + } + + // 0 and " both hold the new content independently + m.SetRegister('0', t, cnt) + m.SetRegister('"', t, cnt) +} + // Window func (m *Model) ScrollY() int { return m.scrollY diff --git a/internal/input/handler.go b/internal/input/handler.go index 88b1902..749911b 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -146,8 +146,8 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st // In visual mode, the selection is already defined — operate immediately if m.Mode().IsVisualMode() { start, end := normalizeVisualSelection(m) - // Visual line mode is linewise, others are charwise - mtype := action.Charwise + // Visual line mode is linewise, others are charwise inclusive + mtype := action.CharwiseInclusive if m.Mode() == action.VisualLineMode { mtype = action.Linewise } @@ -181,9 +181,14 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, // dd, yy, cc - same operator key pressed twice if kind == "operator" && key == h.operatorKey { - cmd := h.operator.DoublePress(m, count) + // Only call DoublePress if the operator supports it + if dp, ok := h.operator.(action.DoublePresser); ok { + cmd := dp.DoublePress(m, count) + h.Reset() + return cmd + } h.Reset() - return cmd + return nil } // Motion after operator diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 669edd7..ae596d3 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -33,9 +33,8 @@ func NewNormalKeymap() *Keymap { }, operators: map[string]action.Operator{ "d": operator.DeleteOperator{}, + "y": operator.YankOperator{}, // "c": ChangeOp{}, - // "y": YankOp{}, - // "p": PasteOp{}, // "s": SubstitueOp{}, // "~": SwapCaseOp{}, }, @@ -52,6 +51,8 @@ func NewNormalKeymap() *Keymap { "V": action.EnterVisualLineMode{}, "ctrl+v": action.EnterVisualBlockMode{}, "D": action.DeleteToEndOfLine{Count: 1}, + "p": action.Paste{Count: 1}, + "P": action.PasteBefore{Count: 1}, }, } } @@ -75,6 +76,7 @@ func NewVisualKeymap() *Keymap { operators: map[string]action.Operator{ "d": operator.DeleteOperator{}, "x": operator.DeleteOperator{}, + "y": operator.YankOperator{}, // "c": ChangeOp{}, // "y": YankOp{}, // "p": PasteOp{}, diff --git a/internal/motion/basic.go b/internal/motion/basic.go index 00b1297..ee22e4c 100644 --- a/internal/motion/basic.go +++ b/internal/motion/basic.go @@ -56,7 +56,7 @@ func (a MoveLeft) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveLeft) Type() action.MotionType { return action.Charwise } +func (a MoveLeft) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveLeft) WithCount(n int) action.Action { return MoveLeft{Count: n} @@ -76,7 +76,7 @@ func (a MoveRight) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveRight) Type() action.MotionType { return action.Charwise } +func (a MoveRight) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveRight) WithCount(n int) action.Action { return MoveRight{Count: n} diff --git a/internal/motion/command.go b/internal/motion/command.go index 1710b63..48e0594 100644 --- a/internal/motion/command.go +++ b/internal/motion/command.go @@ -13,7 +13,7 @@ func (a MoveCommandLeft) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveCommandLeft) Type() action.MotionType { return action.Charwise } +func (a MoveCommandLeft) Type() action.MotionType { return action.CharwiseExclusive } type MoveCommandRight struct{} @@ -23,4 +23,4 @@ func (a MoveCommandRight) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveCommandRight) Type() action.MotionType { return action.Charwise } +func (a MoveCommandRight) Type() action.MotionType { return action.CharwiseExclusive } diff --git a/internal/motion/jump.go b/internal/motion/jump.go index 08713fe..5fb1b59 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -36,7 +36,7 @@ func (a MoveToLineStart) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveToLineStart) Type() action.MotionType { return action.Charwise } +func (a MoveToLineStart) Type() action.MotionType { return action.CharwiseExclusive } // MoveToLineEnd implements Motion ($) - charwise type MoveToLineEnd struct{} @@ -47,7 +47,7 @@ func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveToLineEnd) Type() action.MotionType { return action.Charwise } +func (a MoveToLineEnd) Type() action.MotionType { return action.CharwiseInclusive } // MoveToLineContentStart implements Motion (_) - charwise type MoveToLineContentStart struct{} @@ -72,7 +72,7 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveToLineContentStart) Type() action.MotionType { return action.Charwise } +func (a MoveToLineContentStart) Type() action.MotionType { return action.CharwiseExclusive } // TODO: Count for these, maybe? diff --git a/internal/motion/word.go b/internal/motion/word.go index 762e97f..601d4de 100644 --- a/internal/motion/word.go +++ b/internal/motion/word.go @@ -187,7 +187,7 @@ func (a MoveForwardWord) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveForwardWord) Type() action.MotionType { return action.Charwise } +func (a MoveForwardWord) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveForwardWord) WithCount(n int) action.Action { return MoveForwardWord{Count: n} @@ -209,7 +209,7 @@ func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveForwardWordEnd) Type() action.MotionType { return action.Charwise } +func (a MoveForwardWordEnd) Type() action.MotionType { return action.CharwiseInclusive } func (a MoveForwardWordEnd) WithCount(n int) action.Action { return MoveForwardWordEnd{Count: n} @@ -231,7 +231,7 @@ func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd { return nil } -func (a MoveBackwardWord) Type() action.MotionType { return action.Charwise } +func (a MoveBackwardWord) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveBackwardWord) WithCount(n int) action.Action { return MoveBackwardWord{Count: n} diff --git a/internal/operator/delete.go b/internal/operator/delete.go index dd3b4fd..2271a08 100644 --- a/internal/operator/delete.go +++ b/internal/operator/delete.go @@ -22,13 +22,20 @@ func (o DeleteOperator) Operate(m action.Model, start, end action.Position, mtyp return nil } +// Verify DeleteOperator implements DoublePresser +var _ action.DoublePresser = DeleteOperator{} + // Double press handles dd - delete the entire line func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd { // If we have a higher value than lines remaining, we can only run so many times opCount := min(count, m.LineCount()-m.CursorY()) + var lines []string + for range opCount { y := m.CursorY() + lines = append(lines, m.Line(y)) + m.DeleteLine(y) if m.LineCount() == 0 { @@ -43,6 +50,10 @@ func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd { m.ClampCursorX() } + // Put her in the register! + m.UpdateDefault(action.LinewiseRegister, lines) + // m.SetRegister('"', action.LinewiseRegister, lines) + return nil } @@ -61,11 +72,13 @@ func deleteNormalMode(m action.Model, start, end action.Position, mtype action.M // Charwise motions on same line if start.Line == end.Line { // No movement = nothing to delete - if start.Col == end.Col { + if start.Col == end.Col && mtype == action.CharwiseExclusive { return } - // Exclusive motion: delete [start.Col, end.Col) - end.Col-- + // Exclusive motion: end position not included, so back up one + if mtype == action.CharwiseExclusive { + end.Col-- + } if end.Col >= start.Col { deleteCharSelection(m, start, end) } @@ -104,7 +117,10 @@ func deleteCharSelection(m action.Model, start, end action.Position) { } func deleteLineSelection(m action.Model, start, end action.Position) { + var lines []string + for i := end.Line; i >= start.Line; i-- { + lines = append(lines, m.Line(i)) m.DeleteLine(i) } @@ -119,6 +135,9 @@ func deleteLineSelection(m action.Model, start, end action.Position) { m.SetCursorY(y) m.ClampCursorX() + + // Update registers + m.UpdateDefault(action.LinewiseRegister, lines) } func deleteBlockSelection(m action.Model, start, end action.Position) { diff --git a/internal/operator/yank.go b/internal/operator/yank.go new file mode 100644 index 0000000..ac64569 --- /dev/null +++ b/internal/operator/yank.go @@ -0,0 +1,169 @@ +package operator + +import ( + "fmt" + + "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" +) + +// Implements Operator (y) +type YankOperator struct{} + +func (o YankOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd { + switch m.Mode() { + case action.VisualMode: + yankVisualMode(m, start, end) + case action.VisualLineMode: + yankVisualLineMode(m, start, end) + case action.VisualBlockMode: + yankVisualBlockMode(m, start, end) + case action.NormalMode: + yankNormalMode(m, start, end, mtype) + default: + m.SetCommandError(fmt.Errorf("'y' operator not yet implemented.")) + } + + m.SetCursorX(start.Col) + m.SetCursorY(start.Line) + return nil +} + +// Verify YankOperator implements DoublePresser +var _ action.DoublePresser = YankOperator{} + +func (o YankOperator) DoublePress(m action.Model, count int) tea.Cmd { + y := m.CursorY() + + // If we have a higher value than lines remaining, we can only run so many times + opCount := min(count, m.LineCount()-y) + + var lines []string + + for i := range opCount { + lines = append(lines, m.Line(y+i)) + } + + // Put her in the register! + m.UpdateDefault(action.LinewiseRegister, lines) + + return nil +} + +func yankNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) { + switch { + case mtype.IsCharwise(): + // This shouldn't happen + if start.Line != end.Line { + m.SetCommandError(fmt.Errorf("Start line and end line must match for charwise yank operations.")) + return + } + + line := m.Line(start.Line) + + startX := min(start.Col, end.Col) + endX := max(start.Col, end.Col) + + // Inclusive motions include the end character + if mtype == action.CharwiseInclusive { + endX++ + } + + endX = min(endX, len(line)) // Catch overflow + + cnt := line[startX:endX] + m.UpdateDefault(action.CharwiseRegister, []string{cnt}) + + case mtype == action.Linewise: + // This shouldn't happen + if start.Col != end.Col { + m.SetCommandError(fmt.Errorf("Start column and end column must match for linewise yank operations.")) + return + } + + // These don't need to be validated, they are validated before being passed into the function + startY := min(start.Line, end.Line) + endY := max(start.Line, end.Line) + + cnt := m.Lines()[startY : endY+1] + m.UpdateDefault(action.LinewiseRegister, cnt) + } +} + +func yankVisualMode(m action.Model, start, end action.Position) { + // Normalize so start is before end + if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { + start, end = end, start + } + + // Single line selection + if start.Line == end.Line { + line := m.Line(start.Line) + endCol := min(end.Col+1, len(line)) // +1 because visual selection is inclusive + startCol := min(start.Col, len(line)) + cnt := line[startCol:endCol] + m.UpdateDefault(action.CharwiseRegister, []string{cnt}) + return + } + + // Multi-line selection + var content []string + + // First line: from start.Col to end of line + firstLine := m.Line(start.Line) + startCol := min(start.Col, len(firstLine)) + content = append(content, firstLine[startCol:]) + + // Middle lines: entire lines + for y := start.Line + 1; y < end.Line; y++ { + content = append(content, m.Line(y)) + } + + // Last line: from beginning to end.Col (inclusive) + lastLine := m.Line(end.Line) + endCol := min(end.Col+1, len(lastLine)) + content = append(content, lastLine[:endCol]) + + m.UpdateDefault(action.CharwiseRegister, content) +} + +func yankVisualLineMode(m action.Model, start, end action.Position) { + // This shouldn't happen + if start.Col != end.Col { + m.SetCommandError(fmt.Errorf("Start column and end column must match for linewise yank operations.")) + return + } + + // These don't need to be validated, they are validated before being passed into the function + startY := min(start.Line, end.Line) + endY := max(start.Line, end.Line) + + cnt := m.Lines()[startY : endY+1] + m.UpdateDefault(action.LinewiseRegister, cnt) + +} + +func yankVisualBlockMode(m action.Model, start, end action.Position) { + // Normalize so startY <= endY and startX <= endX + startY := min(start.Line, end.Line) + endY := max(start.Line, end.Line) + startX := min(start.Col, end.Col) + endX := max(start.Col, end.Col) + 1 // +1 for inclusive + + var content []string + + for y := startY; y <= endY; y++ { + line := m.Line(y) + + // Handle lines shorter than the block selection + if startX >= len(line) { + content = append(content, "") + continue + } + + lineEndX := min(endX, len(line)) + content = append(content, line[startX:lineEndX]) + } + + m.UpdateDefault(action.BlockwiseRegister, content) +}