Compare commits

..

3 Commits

Author SHA1 Message Date
Hayden Hargreaves
ca5a0a99a5 feat: yank operator implemented. tested 2026-02-21 21:31:31 -07:00
Hayden Hargreaves
774d0d0071 feat: implement scroll actions, tested
This is control+d and control+u
2026-02-18 18:09:19 -07:00
Hayden Hargreaves
0e8ca21f7f feat: implemented 'D' action, tested 2026-02-18 17:25:38 -07:00
26 changed files with 3000 additions and 72 deletions

View File

@ -61,6 +61,9 @@ type Model interface {
// Window
ScrollY() int
SetScrollY(y int)
WinH() int
WinW() int
ViewPortH() int
// Anchor
AnchorX() int
@ -86,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)
@ -106,10 +115,16 @@ type Position struct {
type MotionType int
const (
Charwise MotionType = iota // h, l, w, e, f, t - operates on characters
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
@ -124,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
}

View File

@ -21,3 +21,56 @@ func (a DeleteChar) Execute(m Model) tea.Cmd {
func (a DeleteChar) WithCount(n int) Action {
return DeleteChar{Count: n}
}
type DeleteToEndOfLine struct {
Count int
}
func (a DeleteToEndOfLine) Execute(m Model) tea.Cmd {
// Delete to end of line
pos := m.CursorX()
line := m.Line(m.CursorY())
m.SetLine(m.CursorY(), line[:pos])
m.SetCursorX(pos - 1)
// If count is greater, than we will delete the next N - 1 lines below
initY := m.CursorY()
if a.Count > 1 {
// Copied from `internal/operator/delete.go`
opCount := min(a.Count-1, m.LineCount()-m.CursorY())
// Down one
m.SetCursorY(initY + 1)
for range opCount {
y := m.CursorY() // Changed from the copied code
// Stop if were on the starting line
if y == initY {
break
}
m.DeleteLine(y)
if m.LineCount() == 0 {
m.InsertLine(0, "")
}
if y >= m.LineCount() {
y = m.LineCount() - 1
}
m.SetCursorY(y)
m.ClampCursorX()
}
}
m.SetCursorY(initY)
m.ClampCursorX()
return nil
}
func (a DeleteToEndOfLine) WithCount(n int) Action {
return DeleteToEndOfLine{Count: n}
}

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

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

@ -23,6 +23,8 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
case "ctrl+d":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD})
case "ctrl+u":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU})
case "ctrl+v":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV})
case "ctrl+w":
@ -33,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

@ -86,3 +86,92 @@ func TestDeleteCharWithCount(t *testing.T) {
}
})
}
func TestDeleteToEndOfLine(t *testing.T) {
t.Run("test 'D' deletes to end of line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.Line(0) != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.Line(0))
}
})
t.Run("test 'D' from start of line deletes entire content", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0))
}
})
t.Run("test 'D' at last character", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.Line(0) != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.Line(0))
}
})
t.Run("test 'D' cursor position after delete", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "D")
m := getFinalModel(t, tm)
// Cursor should move to last character of remaining text
if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX())
}
})
t.Run("test 'D' deletes nothing on blank line", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0))
}
})
t.Run("test 'D' with count deletes following lines", func(t *testing.T) {
lines := []string{"hello", "world", "hi", "mom"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "2", "D")
m := getFinalModel(t, tm)
if m.LineCount() != 3 {
t.Errorf("LineCount() = %q, want '3'", m.LineCount())
}
if m.Line(0) != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
}
if m.Line(1) != "hi" {
t.Errorf("Line(1) = %q, want 'hi'", m.Line(1))
}
})
t.Run("test 'D' with count deletes following lines with overflow", func(t *testing.T) {
lines := []string{"hello", "world", "hi", "mom"}
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "8", "D")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %q, want '1'", m.LineCount())
}
if m.Line(0) != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
}
})
}

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

View File

@ -87,11 +87,10 @@ func TestScrollBasic(t *testing.T) {
if m.CursorY() != 99 {
t.Errorf("CursorY() = %d, want 99", m.CursorY())
}
// With 100 lines and viewport 19, max scrollY = 100 - 19 = 81
// Cursor at 99 with scrollOff=8 means cursor at position 10 from top
// scrollY = 99 - 10 = 89, but clamped to maxScroll = 81
if m.ScrollY() != 81 {
t.Errorf("ScrollY() = %d, want 81", m.ScrollY())
// With 100 lines and viewport 18 (height - 2 for status + command bar),
// max scrollY = 100 - 18 = 82
if m.ScrollY() != 82 {
t.Errorf("ScrollY() = %d, want 82", m.ScrollY())
}
})
@ -132,8 +131,8 @@ func TestScrollEdgeCases(t *testing.T) {
sendKeys(tm, "G")
m := getFinalModel(t, tm)
// 30 lines, viewport 19 -> maxScroll = 30 - 19 = 11
maxScroll := 30 - 19
// 30 lines, viewport 18 (height - 2) -> maxScroll = 30 - 18 = 12
maxScroll := 30 - 18
if m.ScrollY() > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll)
}
@ -156,6 +155,263 @@ func TestScrollEdgeCases(t *testing.T) {
})
}
// Tests use terminal 80x30: viewportH=28, half-scroll=14, scrollOff=8, safe zone relY 8-19.
func TestHalfPageScrollDown(t *testing.T) {
t.Run("ctrl+d scrolls viewport down by half", func(t *testing.T) {
// cursor at line 15 (relY=15, in safe zone), scrollY starts at 0
// After ctrl+d: newScrollY=14, newCursorY=14+15=29
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm)
if m.ScrollY() != 14 {
t.Errorf("ScrollY() = %d, want 14", m.ScrollY())
}
if m.CursorY() != 29 {
t.Errorf("CursorY() = %d, want 29", m.CursorY())
}
})
t.Run("ctrl+d preserves cursor relative position in viewport", func(t *testing.T) {
// relY=15 before and after
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm)
relY := m.CursorY() - m.ScrollY()
if relY != 15 {
t.Errorf("relative position = %d, want 15", relY)
}
})
t.Run("ctrl+d clamps cursor to scrollOff when cursor is near top", func(t *testing.T) {
// cursor at line 0 (relY=0 < scrollOff=8), clamp to scrollOff
// After ctrl+d: newScrollY=14, newCursorY=14+8=22
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 30)
sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm)
if m.ScrollY() != 14 {
t.Errorf("ScrollY() = %d, want 14", m.ScrollY())
}
if m.CursorY() != 22 {
t.Errorf("CursorY() = %d, want 22", m.CursorY())
}
})
t.Run("ctrl+d at end of file does not scroll past max", func(t *testing.T) {
// 40 lines, viewport 28: maxScroll=12
// AdjustScroll puts cursor 35 at scrollY=12 (clamped), relY=23
// After ctrl+d: newScrollY clamped to 12, relY=23>19 clamped to 19, newCursorY=31
lines := generateLines(40)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 35}, 80, 30)
sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm)
maxScroll := 40 - 28
if m.ScrollY() > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll)
}
if m.CursorY() != 31 {
t.Errorf("CursorY() = %d, want 31", m.CursorY())
}
})
t.Run("ctrl+d on file smaller than viewport does not crash", func(t *testing.T) {
// 20 lines < viewport 28: maxScroll=0, scrollY stays 0
// relY=0 < scrollOff, clamp to 8; newCursorY=0+8=8
lines := generateLines(20)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 30)
sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm)
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
}
if m.CursorY() != 8 {
t.Errorf("CursorY() = %d, want 8", m.CursorY())
}
})
t.Run("ctrl+d clamps cursor x to new line length", func(t *testing.T) {
// Lines 0-14: "hello world" (11 chars), lines 15-99: "hi" (2 chars)
// cursor at line 5, cursorX=10
// ctrl+d: relY=5<8 clamp to 8; newScrollY=14; newCursorY=22 (a "hi" line)
// ClampCursorX: 10 >= 2, so cursorX=2
lines := make([]string, 100)
for i := range 15 {
lines[i] = "hello world"
}
for i := 15; i < 100; i++ {
lines[i] = "hi"
}
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 10, Line: 5}, 80, 30)
sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm)
if m.CursorY() != 22 {
t.Errorf("CursorY() = %d, want 22", m.CursorY())
}
if m.CursorX() > len(m.Line(m.CursorY())) {
t.Errorf("CursorX() = %d exceeds line length %d", m.CursorX(), len(m.Line(m.CursorY())))
}
})
t.Run("multiple ctrl+d presses scroll incrementally", func(t *testing.T) {
// cursor at line 15, scrollY=0, relY=15
// ctrl+d #1: scrollY=14, cursorY=29, relY=15
// ctrl+d #2: scrollY=28, cursorY=43, relY=15
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d", "ctrl+d")
m := getFinalModel(t, tm)
if m.ScrollY() != 28 {
t.Errorf("ScrollY() = %d, want 28", m.ScrollY())
}
if m.CursorY() != 43 {
t.Errorf("CursorY() = %d, want 43", m.CursorY())
}
})
}
func TestHalfPageScrollUp(t *testing.T) {
t.Run("ctrl+u scrolls viewport up by half", func(t *testing.T) {
// cursor at line 50: AdjustScroll -> scrollY=31, relY=19
// After ctrl+u: newScrollY=17, newCursorY=17+19=36
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm)
if m.ScrollY() != 17 {
t.Errorf("ScrollY() = %d, want 17", m.ScrollY())
}
if m.CursorY() != 36 {
t.Errorf("CursorY() = %d, want 36", m.CursorY())
}
})
t.Run("ctrl+u preserves cursor relative position in viewport", func(t *testing.T) {
// relY=19 before and after
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm)
relY := m.CursorY() - m.ScrollY()
if relY != 19 {
t.Errorf("relative position = %d, want 19", relY)
}
})
t.Run("ctrl+u at top of file does not make scrollY negative", func(t *testing.T) {
// cursor at line 10, scrollY=0, relY=10 (in safe zone)
// ctrl+u: newScrollY=max(0,-14)=0, relY=10 preserved, cursorY=10
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 10}, 80, 30)
sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm)
if m.ScrollY() < 0 {
t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY())
}
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
}
if m.CursorY() != 10 {
t.Errorf("CursorY() = %d, want 10", m.CursorY())
}
})
t.Run("ctrl+u clamps cursor to scrollOff when cursor is near top of viewport", func(t *testing.T) {
// cursor at line 5, scrollY=0, relY=5 < scrollOff=8
// ctrl+u: newScrollY=0; relY clamp to 8; newCursorY=8
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 5}, 80, 30)
sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm)
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
}
if m.CursorY() != 8 {
t.Errorf("CursorY() = %d, want 8", m.CursorY())
}
})
t.Run("multiple ctrl+u presses scroll incrementally", func(t *testing.T) {
// cursor at line 80: AdjustScroll -> scrollY=61, relY=19
// ctrl+u #1: newScrollY=47, cursorY=66
// ctrl+u #2: newScrollY=33, cursorY=52
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 80}, 80, 30)
sendKeys(tm, "ctrl+u", "ctrl+u")
m := getFinalModel(t, tm)
if m.ScrollY() != 33 {
t.Errorf("ScrollY() = %d, want 33", m.ScrollY())
}
if m.CursorY() != 52 {
t.Errorf("CursorY() = %d, want 52", m.CursorY())
}
})
}
func TestHalfPageScrollRoundTrip(t *testing.T) {
t.Run("ctrl+d then ctrl+u returns cursor to original position", func(t *testing.T) {
// cursor at line 15, scrollY=0, relY=15
// ctrl+d: scrollY=14, cursorY=29, relY=15
// ctrl+u: newScrollY=max(0,14-14)=0, cursorY=0+15=15
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d", "ctrl+u")
m := getFinalModel(t, tm)
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
}
if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15", m.CursorY())
}
})
t.Run("ctrl+u then ctrl+d returns cursor to original position", func(t *testing.T) {
// cursor at line 50: AdjustScroll -> scrollY=31, relY=19
// ctrl+u: scrollY=17, cursorY=36, relY=19
// ctrl+d: scrollY=31, cursorY=50, relY=19
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+u", "ctrl+d")
m := getFinalModel(t, tm)
if m.ScrollY() != 31 {
t.Errorf("ScrollY() = %d, want 31", m.ScrollY())
}
if m.CursorY() != 50 {
t.Errorf("CursorY() = %d, want 50", m.CursorY())
}
})
t.Run("alternating ctrl+d and ctrl+u maintains scroll stability", func(t *testing.T) {
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d", "ctrl+u", "ctrl+d", "ctrl+u")
m := getFinalModel(t, tm)
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ScrollY())
}
if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.CursorY())
}
})
}
func TestScrollWithCount(t *testing.T) {
t.Run("5j scrolls appropriately", func(t *testing.T) {
lines := generateLines(50)

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 {
@ -50,6 +54,7 @@ func NewModel(lines []string, pos action.Position) Model {
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
@ -195,6 +234,18 @@ func (m *Model) SetScrollY(y int) {
m.scrollY = y
}
func (m *Model) WinH() int {
return m.win_h
}
func (m *Model) WinW() int {
return m.win_w
}
func (m *Model) ViewPortH() int {
return m.win_h - 2 // -2 for status bar and commmand bar
}
func (m *Model) ClampCursorX() {
lineLen := len(m.lines[m.cursor.y])
if lineLen == 0 {
@ -207,7 +258,7 @@ func (m *Model) ClampCursorX() {
// AdjustScroll ensures the cursor stays within the viewport with scrollOff margins.
// Call this after any cursor movement.
func (m *Model) AdjustScroll() {
viewportHeight := m.win_h - 1 // -1 for status bar
viewportHeight := m.ViewPortH()
if viewportHeight <= 0 {
return
}

View File

@ -14,6 +14,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.win_w = msg.Width
case tea.KeyMsg:
// TODO: This needs to be removed, but for now its required for the tests.
// Ctrl+C always quits regardless of mode
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
cmd = m.input.Handle(&m, msg.String())
}

View File

@ -59,7 +59,7 @@ func posIsAnchor(m Model, col, line int) bool {
func (m Model) View() string {
var view strings.Builder
viewportHeight := m.win_h - 2 // -2 for status bar and command bar
viewportHeight := m.ViewPortH()
start := m.ScrollY()
end := m.ScrollY() + viewportHeight

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,10 +181,15 @@ 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 nil
}
// Motion after operator
if kind == "motion" {

View File

@ -28,12 +28,13 @@ func NewNormalKeymap() *Keymap {
"w": motion.MoveForwardWord{Count: 1},
"e": motion.MoveForwardWordEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
"ctrl+u": motion.ScrollUpHalfPage{},
"ctrl+d": motion.ScrollDownHalfPage{},
},
operators: map[string]action.Operator{
"d": operator.DeleteOperator{},
"y": operator.YankOperator{},
// "c": ChangeOp{},
// "y": YankOp{},
// "p": PasteOp{},
// "s": SubstitueOp{},
// "~": SwapCaseOp{},
},
@ -45,11 +46,13 @@ func NewNormalKeymap() *Keymap {
"o": action.OpenLineBelow{},
"O": action.OpenLineAbove{},
"x": action.DeleteChar{Count: 1},
"ctrl+c": action.Quit{},
":": action.EnterComandMode{},
"v": action.EnterVisualMode{},
"V": action.EnterVisualLineMode{},
"ctrl+v": action.EnterVisualBlockMode{},
"D": action.DeleteToEndOfLine{Count: 1},
"p": action.Paste{Count: 1},
"P": action.PasteBefore{Count: 1},
},
}
}
@ -73,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{},
@ -80,7 +84,6 @@ func NewVisualKeymap() *Keymap {
// "~": SwapCaseOp{},
},
actions: map[string]action.Action{
"ctrl+c": action.Quit{},
// ":": action.EnterComandMode{}, // Different OP
},
}
@ -101,7 +104,6 @@ func NewInsertKeymap() *Keymap {
"delete": action.InsertDelete{},
"tab": action.InsertTab{},
"ctrl+w": action.InsertDeletePreviousWord{},
"ctrl+c": action.Quit{},
},
}

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,4 +72,82 @@ 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?
// ScrollDownHalfPage implements Motion (ctrl+d) - linewise
type ScrollDownHalfPage struct{}
func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
viewportHeight := m.ViewPortH()
if viewportHeight <= 0 {
return nil
}
scroll := viewportHeight / 2
scrollOff := m.Settings().ScrollOff
// Current relative position in viewport
relY := m.CursorY() - m.ScrollY()
// Scroll down, clamped to valid range
newScrollY := m.ScrollY() + scroll
maxScroll := max(0, m.LineCount()-viewportHeight)
newScrollY = min(newScrollY, maxScroll)
m.SetScrollY(newScrollY)
// Maintain relative position, respecting scrollOff
if relY < scrollOff {
relY = scrollOff
}
if relY > viewportHeight-1-scrollOff {
relY = viewportHeight - 1 - scrollOff
}
newCursorY := newScrollY + relY
newCursorY = max(0, min(newCursorY, m.LineCount()-1))
m.SetCursorY(newCursorY)
m.ClampCursorX()
return nil
}
func (a ScrollDownHalfPage) Type() action.MotionType { return action.Linewise }
// ScrollUpHalfPage implements Motion (ctrl+u) - linewise
type ScrollUpHalfPage struct{}
func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
viewportHeight := m.ViewPortH()
if viewportHeight <= 0 {
return nil
}
scroll := viewportHeight / 2
scrollOff := m.Settings().ScrollOff
// Current relative position in viewport
relY := m.CursorY() - m.ScrollY()
// Scroll up, clamped to valid range
newScrollY := m.ScrollY() - scroll
newScrollY = max(0, newScrollY)
m.SetScrollY(newScrollY)
// Maintain relative position, respecting scrollOff
if relY < scrollOff {
relY = scrollOff
}
if relY > viewportHeight-1-scrollOff {
relY = viewportHeight - 1 - scrollOff
}
newCursorY := newScrollY + relY
newCursorY = max(0, min(newCursorY, m.LineCount()-1))
m.SetCursorY(newCursorY)
m.ClampCursorX()
return nil
}
func (a ScrollUpHalfPage) Type() action.MotionType { return action.Linewise }

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