feat: yank operator implemented. tested

This commit is contained in:
Hayden Hargreaves 2026-02-21 21:31:31 -07:00
parent 774d0d0071
commit ca5a0a99a5
21 changed files with 2478 additions and 48 deletions

View File

@ -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
}

149
internal/action/paste.go Normal file
View File

@ -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}
}

View File

@ -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{},
}
}

2
internal/action/tmp Normal file
View File

@ -0,0 +1,2 @@
hello

View File

@ -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:
//

View File

@ -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,
})
}

View File

@ -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)
}
```

View File

@ -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")
}
})
}

View File

@ -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)

View File

@ -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))
}
})

View File

@ -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())
}
})
}

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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{},

View File

@ -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}

View File

@ -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 }

View File

@ -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?

View File

@ -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}

View File

@ -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) {

169
internal/operator/yank.go Normal file
View File

@ -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)
}