Compare commits
3 Commits
ee7bf9354b
...
ca5a0a99a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5a0a99a5 | ||
|
|
774d0d0071 | ||
|
|
0e8ca21f7f |
@ -61,6 +61,9 @@ type Model interface {
|
|||||||
// Window
|
// Window
|
||||||
ScrollY() int
|
ScrollY() int
|
||||||
SetScrollY(y int)
|
SetScrollY(y int)
|
||||||
|
WinH() int
|
||||||
|
WinW() int
|
||||||
|
ViewPortH() int
|
||||||
|
|
||||||
// Anchor
|
// Anchor
|
||||||
AnchorX() int
|
AnchorX() int
|
||||||
@ -86,6 +89,12 @@ type Model interface {
|
|||||||
Settings() Settings
|
Settings() Settings
|
||||||
SetSettings(s 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() Mode
|
Mode() Mode
|
||||||
SetMode(mode Mode)
|
SetMode(mode Mode)
|
||||||
@ -106,10 +115,16 @@ type Position struct {
|
|||||||
type MotionType int
|
type MotionType int
|
||||||
|
|
||||||
const (
|
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
|
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
|
// Action is the base interface - anything executable
|
||||||
type Action interface {
|
type Action interface {
|
||||||
Execute(m Model) tea.Cmd
|
Execute(m Model) tea.Cmd
|
||||||
@ -124,7 +139,10 @@ type Motion interface {
|
|||||||
// Operator acts on a range (delete, yank, change)
|
// Operator acts on a range (delete, yank, change)
|
||||||
type Operator interface {
|
type Operator interface {
|
||||||
Operate(m Model, start, end Position, mtype MotionType) tea.Cmd
|
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
|
DoublePress(m Model, count int) tea.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,3 +21,56 @@ func (a DeleteChar) Execute(m Model) tea.Cmd {
|
|||||||
func (a DeleteChar) WithCount(n int) Action {
|
func (a DeleteChar) WithCount(n int) Action {
|
||||||
return DeleteChar{Count: n}
|
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
149
internal/action/paste.go
Normal 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}
|
||||||
|
}
|
||||||
75
internal/action/register.go
Normal file
75
internal/action/register.go
Normal 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
2
internal/action/tmp
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
hello
|
||||||
@ -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]
|
// cmdSet handles :set option[=value]
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
|
|||||||
@ -139,4 +139,11 @@ func (r *Registry) registerDefaults() {
|
|||||||
ShortForm: "se",
|
ShortForm: "se",
|
||||||
Handler: cmdSet,
|
Handler: cmdSet,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register commands
|
||||||
|
r.Register(Command{
|
||||||
|
Name: "register",
|
||||||
|
ShortForm: "reg",
|
||||||
|
Handler: cmdRegisters,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
124
internal/editor/TEST_HELPERS.md
Normal file
124
internal/editor/TEST_HELPERS.md
Normal 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)
|
||||||
|
}
|
||||||
|
```
|
||||||
118
internal/editor/helpers_example_test.go
Normal file
118
internal/editor/helpers_example_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -23,6 +23,8 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
|
|||||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
|
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
|
||||||
case "ctrl+d":
|
case "ctrl+d":
|
||||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD})
|
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||||
|
case "ctrl+u":
|
||||||
|
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU})
|
||||||
case "ctrl+v":
|
case "ctrl+v":
|
||||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV})
|
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV})
|
||||||
case "ctrl+w":
|
case "ctrl+w":
|
||||||
@ -33,27 +35,91 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestModel creates a test model with default content
|
// TestModelOption is a functional option for configuring test models
|
||||||
func newTestModel(t *testing.T) *teatest.TestModel {
|
type TestModelOption func(*testModelConfig)
|
||||||
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))
|
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 {
|
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 {
|
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 newTestModel(t, WithCursorPos(pos))
|
||||||
return teatest.NewTestModel(t, NewModel(lines, pos), teatest.WithInitialTermSize(80, 24))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos action.Position) *teatest.TestModel {
|
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 {
|
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)
|
// getFinalModel extracts the final model state (sends ctrl+c to quit first)
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -615,9 +615,9 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
|
|||||||
sendKeys(tm, "d", "e")
|
sendKeys(tm, "d", "e")
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
m := getFinalModel(t, tm)
|
||||||
// 'e' is inclusive - deletes "hello" (cols 0-4 inclusive)
|
// 'e' is inclusive - deletes "hello" (cols 0-4 inclusive), leaves " world"
|
||||||
if m.Line(0) != "o world" {
|
if m.Line(0) != " world" {
|
||||||
t.Errorf("Line(0) = %q, want \"o world\"", m.Line(0))
|
t.Errorf("Line(0) = %q, want \" world\"", m.Line(0))
|
||||||
}
|
}
|
||||||
if m.CursorX() != 0 {
|
if m.CursorX() != 0 {
|
||||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||||
@ -631,8 +631,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
|
|||||||
|
|
||||||
m := getFinalModel(t, tm)
|
m := getFinalModel(t, tm)
|
||||||
// From 'l' (col 2) to 'o' (col 4) inclusive → deletes "llo"
|
// From 'l' (col 2) to 'o' (col 4) inclusive → deletes "llo"
|
||||||
if m.Line(0) != "heo world" {
|
if m.Line(0) != "he world" {
|
||||||
t.Errorf("Line(0) = %q, want \"heo world\"", m.Line(0))
|
t.Errorf("Line(0) = %q, want \"he world\"", m.Line(0))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -643,8 +643,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
|
|||||||
|
|
||||||
m := getFinalModel(t, tm)
|
m := getFinalModel(t, tm)
|
||||||
// From 'o' of "hello" (col 4) to 'd' of "world" (col 10) inclusive
|
// From 'o' of "hello" (col 4) to 'd' of "world" (col 10) inclusive
|
||||||
if m.Line(0) != "helld" {
|
if m.Line(0) != "hell" {
|
||||||
t.Errorf("Line(0) = %q, want \"helld\"", m.Line(0))
|
t.Errorf("Line(0) = %q, want \"hell\"", m.Line(0))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -654,9 +654,9 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
|
|||||||
sendKeys(tm, "2", "d", "e")
|
sendKeys(tm, "2", "d", "e")
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
m := getFinalModel(t, tm)
|
||||||
// Deletes "one" and "two" (to end of second word inclusive)
|
// Deletes "one" and " two" (to end of second word inclusive)
|
||||||
if m.Line(0) != "o three four" {
|
if m.Line(0) != " three four" {
|
||||||
t.Errorf("Line(0) = %q, want \"o three four\"", m.Line(0))
|
t.Errorf("Line(0) = %q, want \" three four\"", m.Line(0))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
576
internal/editor/integration_paste_test.go
Normal file
576
internal/editor/integration_paste_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -87,11 +87,10 @@ func TestScrollBasic(t *testing.T) {
|
|||||||
if m.CursorY() != 99 {
|
if m.CursorY() != 99 {
|
||||||
t.Errorf("CursorY() = %d, want 99", m.CursorY())
|
t.Errorf("CursorY() = %d, want 99", m.CursorY())
|
||||||
}
|
}
|
||||||
// With 100 lines and viewport 19, max scrollY = 100 - 19 = 81
|
// With 100 lines and viewport 18 (height - 2 for status + command bar),
|
||||||
// Cursor at 99 with scrollOff=8 means cursor at position 10 from top
|
// max scrollY = 100 - 18 = 82
|
||||||
// scrollY = 99 - 10 = 89, but clamped to maxScroll = 81
|
if m.ScrollY() != 82 {
|
||||||
if m.ScrollY() != 81 {
|
t.Errorf("ScrollY() = %d, want 82", m.ScrollY())
|
||||||
t.Errorf("ScrollY() = %d, want 81", m.ScrollY())
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -132,8 +131,8 @@ func TestScrollEdgeCases(t *testing.T) {
|
|||||||
sendKeys(tm, "G")
|
sendKeys(tm, "G")
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
m := getFinalModel(t, tm)
|
||||||
// 30 lines, viewport 19 -> maxScroll = 30 - 19 = 11
|
// 30 lines, viewport 18 (height - 2) -> maxScroll = 30 - 18 = 12
|
||||||
maxScroll := 30 - 19
|
maxScroll := 30 - 18
|
||||||
if m.ScrollY() > maxScroll {
|
if m.ScrollY() > maxScroll {
|
||||||
t.Errorf("ScrollY() = %d, want <= %d", 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) {
|
func TestScrollWithCount(t *testing.T) {
|
||||||
t.Run("5j scrolls appropriately", func(t *testing.T) {
|
t.Run("5j scrolls appropriately", func(t *testing.T) {
|
||||||
lines := generateLines(50)
|
lines := generateLines(50)
|
||||||
|
|||||||
1040
internal/editor/integration_yank_test.go
Normal file
1040
internal/editor/integration_yank_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
|||||||
package editor
|
package editor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||||
@ -36,6 +37,9 @@ type Model struct {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings action.Settings
|
settings action.Settings
|
||||||
|
|
||||||
|
// Registers
|
||||||
|
registers map[rune]action.Register // name -> register
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(lines []string, pos action.Position) Model {
|
func NewModel(lines []string, pos action.Position) Model {
|
||||||
@ -50,6 +54,7 @@ func NewModel(lines []string, pos action.Position) Model {
|
|||||||
command: "",
|
command: "",
|
||||||
input: input.NewHandler(),
|
input: input.NewHandler(),
|
||||||
settings: action.NewDefaultSettings(),
|
settings: action.NewDefaultSettings(),
|
||||||
|
registers: action.DefaultRegisters(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,6 +191,40 @@ func (m *Model) SetSettings(s action.Settings) {
|
|||||||
m.settings = s
|
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
|
// Window
|
||||||
func (m *Model) ScrollY() int {
|
func (m *Model) ScrollY() int {
|
||||||
return m.scrollY
|
return m.scrollY
|
||||||
@ -195,6 +234,18 @@ func (m *Model) SetScrollY(y int) {
|
|||||||
m.scrollY = y
|
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() {
|
func (m *Model) ClampCursorX() {
|
||||||
lineLen := len(m.lines[m.cursor.y])
|
lineLen := len(m.lines[m.cursor.y])
|
||||||
if lineLen == 0 {
|
if lineLen == 0 {
|
||||||
@ -207,7 +258,7 @@ func (m *Model) ClampCursorX() {
|
|||||||
// AdjustScroll ensures the cursor stays within the viewport with scrollOff margins.
|
// AdjustScroll ensures the cursor stays within the viewport with scrollOff margins.
|
||||||
// Call this after any cursor movement.
|
// Call this after any cursor movement.
|
||||||
func (m *Model) AdjustScroll() {
|
func (m *Model) AdjustScroll() {
|
||||||
viewportHeight := m.win_h - 1 // -1 for status bar
|
viewportHeight := m.ViewPortH()
|
||||||
if viewportHeight <= 0 {
|
if viewportHeight <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.win_w = msg.Width
|
m.win_w = msg.Width
|
||||||
|
|
||||||
case tea.KeyMsg:
|
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())
|
cmd = m.input.Handle(&m, msg.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@ func posIsAnchor(m Model, col, line int) bool {
|
|||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
var view strings.Builder
|
var view strings.Builder
|
||||||
|
|
||||||
viewportHeight := m.win_h - 2 // -2 for status bar and command bar
|
viewportHeight := m.ViewPortH()
|
||||||
start := m.ScrollY()
|
start := m.ScrollY()
|
||||||
end := m.ScrollY() + viewportHeight
|
end := m.ScrollY() + viewportHeight
|
||||||
|
|
||||||
|
|||||||
@ -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
|
// In visual mode, the selection is already defined — operate immediately
|
||||||
if m.Mode().IsVisualMode() {
|
if m.Mode().IsVisualMode() {
|
||||||
start, end := normalizeVisualSelection(m)
|
start, end := normalizeVisualSelection(m)
|
||||||
// Visual line mode is linewise, others are charwise
|
// Visual line mode is linewise, others are charwise inclusive
|
||||||
mtype := action.Charwise
|
mtype := action.CharwiseInclusive
|
||||||
if m.Mode() == action.VisualLineMode {
|
if m.Mode() == action.VisualLineMode {
|
||||||
mtype = action.Linewise
|
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
|
// dd, yy, cc - same operator key pressed twice
|
||||||
if kind == "operator" && key == h.operatorKey {
|
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()
|
h.Reset()
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
h.Reset()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Motion after operator
|
// Motion after operator
|
||||||
if kind == "motion" {
|
if kind == "motion" {
|
||||||
|
|||||||
@ -28,12 +28,13 @@ func NewNormalKeymap() *Keymap {
|
|||||||
"w": motion.MoveForwardWord{Count: 1},
|
"w": motion.MoveForwardWord{Count: 1},
|
||||||
"e": motion.MoveForwardWordEnd{Count: 1},
|
"e": motion.MoveForwardWordEnd{Count: 1},
|
||||||
"b": motion.MoveBackwardWord{Count: 1},
|
"b": motion.MoveBackwardWord{Count: 1},
|
||||||
|
"ctrl+u": motion.ScrollUpHalfPage{},
|
||||||
|
"ctrl+d": motion.ScrollDownHalfPage{},
|
||||||
},
|
},
|
||||||
operators: map[string]action.Operator{
|
operators: map[string]action.Operator{
|
||||||
"d": operator.DeleteOperator{},
|
"d": operator.DeleteOperator{},
|
||||||
|
"y": operator.YankOperator{},
|
||||||
// "c": ChangeOp{},
|
// "c": ChangeOp{},
|
||||||
// "y": YankOp{},
|
|
||||||
// "p": PasteOp{},
|
|
||||||
// "s": SubstitueOp{},
|
// "s": SubstitueOp{},
|
||||||
// "~": SwapCaseOp{},
|
// "~": SwapCaseOp{},
|
||||||
},
|
},
|
||||||
@ -45,11 +46,13 @@ func NewNormalKeymap() *Keymap {
|
|||||||
"o": action.OpenLineBelow{},
|
"o": action.OpenLineBelow{},
|
||||||
"O": action.OpenLineAbove{},
|
"O": action.OpenLineAbove{},
|
||||||
"x": action.DeleteChar{Count: 1},
|
"x": action.DeleteChar{Count: 1},
|
||||||
"ctrl+c": action.Quit{},
|
|
||||||
":": action.EnterComandMode{},
|
":": action.EnterComandMode{},
|
||||||
"v": action.EnterVisualMode{},
|
"v": action.EnterVisualMode{},
|
||||||
"V": action.EnterVisualLineMode{},
|
"V": action.EnterVisualLineMode{},
|
||||||
"ctrl+v": action.EnterVisualBlockMode{},
|
"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{
|
operators: map[string]action.Operator{
|
||||||
"d": operator.DeleteOperator{},
|
"d": operator.DeleteOperator{},
|
||||||
"x": operator.DeleteOperator{},
|
"x": operator.DeleteOperator{},
|
||||||
|
"y": operator.YankOperator{},
|
||||||
// "c": ChangeOp{},
|
// "c": ChangeOp{},
|
||||||
// "y": YankOp{},
|
// "y": YankOp{},
|
||||||
// "p": PasteOp{},
|
// "p": PasteOp{},
|
||||||
@ -80,7 +84,6 @@ func NewVisualKeymap() *Keymap {
|
|||||||
// "~": SwapCaseOp{},
|
// "~": SwapCaseOp{},
|
||||||
},
|
},
|
||||||
actions: map[string]action.Action{
|
actions: map[string]action.Action{
|
||||||
"ctrl+c": action.Quit{},
|
|
||||||
// ":": action.EnterComandMode{}, // Different OP
|
// ":": action.EnterComandMode{}, // Different OP
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -101,7 +104,6 @@ func NewInsertKeymap() *Keymap {
|
|||||||
"delete": action.InsertDelete{},
|
"delete": action.InsertDelete{},
|
||||||
"tab": action.InsertTab{},
|
"tab": action.InsertTab{},
|
||||||
"ctrl+w": action.InsertDeletePreviousWord{},
|
"ctrl+w": action.InsertDeletePreviousWord{},
|
||||||
"ctrl+c": action.Quit{},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,7 +56,7 @@ func (a MoveLeft) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
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 {
|
func (a MoveLeft) WithCount(n int) action.Action {
|
||||||
return MoveLeft{Count: n}
|
return MoveLeft{Count: n}
|
||||||
@ -76,7 +76,7 @@ func (a MoveRight) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
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 {
|
func (a MoveRight) WithCount(n int) action.Action {
|
||||||
return MoveRight{Count: n}
|
return MoveRight{Count: n}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ func (a MoveCommandLeft) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a MoveCommandLeft) Type() action.MotionType { return action.Charwise }
|
func (a MoveCommandLeft) Type() action.MotionType { return action.CharwiseExclusive }
|
||||||
|
|
||||||
type MoveCommandRight struct{}
|
type MoveCommandRight struct{}
|
||||||
|
|
||||||
@ -23,4 +23,4 @@ func (a MoveCommandRight) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a MoveCommandRight) Type() action.MotionType { return action.Charwise }
|
func (a MoveCommandRight) Type() action.MotionType { return action.CharwiseExclusive }
|
||||||
|
|||||||
@ -36,7 +36,7 @@ func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a MoveToLineStart) Type() action.MotionType { return action.Charwise }
|
func (a MoveToLineStart) Type() action.MotionType { return action.CharwiseExclusive }
|
||||||
|
|
||||||
// MoveToLineEnd implements Motion ($) - charwise
|
// MoveToLineEnd implements Motion ($) - charwise
|
||||||
type MoveToLineEnd struct{}
|
type MoveToLineEnd struct{}
|
||||||
@ -47,7 +47,7 @@ func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a MoveToLineEnd) Type() action.MotionType { return action.Charwise }
|
func (a MoveToLineEnd) Type() action.MotionType { return action.CharwiseInclusive }
|
||||||
|
|
||||||
// MoveToLineContentStart implements Motion (_) - charwise
|
// MoveToLineContentStart implements Motion (_) - charwise
|
||||||
type MoveToLineContentStart struct{}
|
type MoveToLineContentStart struct{}
|
||||||
@ -72,4 +72,82 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
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 }
|
||||||
|
|||||||
@ -187,7 +187,7 @@ func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
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 {
|
func (a MoveForwardWord) WithCount(n int) action.Action {
|
||||||
return MoveForwardWord{Count: n}
|
return MoveForwardWord{Count: n}
|
||||||
@ -209,7 +209,7 @@ func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
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 {
|
func (a MoveForwardWordEnd) WithCount(n int) action.Action {
|
||||||
return MoveForwardWordEnd{Count: n}
|
return MoveForwardWordEnd{Count: n}
|
||||||
@ -231,7 +231,7 @@ func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
|
|||||||
return nil
|
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 {
|
func (a MoveBackwardWord) WithCount(n int) action.Action {
|
||||||
return MoveBackwardWord{Count: n}
|
return MoveBackwardWord{Count: n}
|
||||||
|
|||||||
@ -22,13 +22,20 @@ func (o DeleteOperator) Operate(m action.Model, start, end action.Position, mtyp
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify DeleteOperator implements DoublePresser
|
||||||
|
var _ action.DoublePresser = DeleteOperator{}
|
||||||
|
|
||||||
// Double press handles dd - delete the entire line
|
// Double press handles dd - delete the entire line
|
||||||
func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd {
|
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
|
// If we have a higher value than lines remaining, we can only run so many times
|
||||||
opCount := min(count, m.LineCount()-m.CursorY())
|
opCount := min(count, m.LineCount()-m.CursorY())
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
for range opCount {
|
for range opCount {
|
||||||
y := m.CursorY()
|
y := m.CursorY()
|
||||||
|
lines = append(lines, m.Line(y))
|
||||||
|
|
||||||
m.DeleteLine(y)
|
m.DeleteLine(y)
|
||||||
|
|
||||||
if m.LineCount() == 0 {
|
if m.LineCount() == 0 {
|
||||||
@ -43,6 +50,10 @@ func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd {
|
|||||||
m.ClampCursorX()
|
m.ClampCursorX()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Put her in the register!
|
||||||
|
m.UpdateDefault(action.LinewiseRegister, lines)
|
||||||
|
// m.SetRegister('"', action.LinewiseRegister, lines)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,11 +72,13 @@ func deleteNormalMode(m action.Model, start, end action.Position, mtype action.M
|
|||||||
// Charwise motions on same line
|
// Charwise motions on same line
|
||||||
if start.Line == end.Line {
|
if start.Line == end.Line {
|
||||||
// No movement = nothing to delete
|
// No movement = nothing to delete
|
||||||
if start.Col == end.Col {
|
if start.Col == end.Col && mtype == action.CharwiseExclusive {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Exclusive motion: delete [start.Col, end.Col)
|
// Exclusive motion: end position not included, so back up one
|
||||||
|
if mtype == action.CharwiseExclusive {
|
||||||
end.Col--
|
end.Col--
|
||||||
|
}
|
||||||
if end.Col >= start.Col {
|
if end.Col >= start.Col {
|
||||||
deleteCharSelection(m, start, end)
|
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) {
|
func deleteLineSelection(m action.Model, start, end action.Position) {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
for i := end.Line; i >= start.Line; i-- {
|
for i := end.Line; i >= start.Line; i-- {
|
||||||
|
lines = append(lines, m.Line(i))
|
||||||
m.DeleteLine(i)
|
m.DeleteLine(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,6 +135,9 @@ func deleteLineSelection(m action.Model, start, end action.Position) {
|
|||||||
|
|
||||||
m.SetCursorY(y)
|
m.SetCursorY(y)
|
||||||
m.ClampCursorX()
|
m.ClampCursorX()
|
||||||
|
|
||||||
|
// Update registers
|
||||||
|
m.UpdateDefault(action.LinewiseRegister, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteBlockSelection(m action.Model, start, end action.Position) {
|
func deleteBlockSelection(m action.Model, start, end action.Position) {
|
||||||
|
|||||||
169
internal/operator/yank.go
Normal file
169
internal/operator/yank.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user