feat: fixed and finished pasting in visual mode, testing

This commit is contained in:
Hayden Hargreaves 2026-02-23 22:55:41 -07:00
parent 37ad0ecb2b
commit 63fec65be4
3 changed files with 707 additions and 1 deletions

View File

@ -147,3 +147,281 @@ var _ Repeatable = PasteBefore{}
func (a PasteBefore) WithCount(n int) Action {
return PasteBefore{Count: n}
}
// VisualPaste implements Action (p in visual mode) - replaces selection with register content
type VisualPaste struct {
Count int
}
func (a VisualPaste) Execute(m Model) tea.Cmd {
// Get register content to paste
reg, found := m.GetRegister('"')
if !found {
m.SetCommandError(fmt.Errorf("\"\" register is broken. Uh oh."))
return nil
}
mode := m.Mode()
// Get selection bounds (normalize anchor and cursor)
start, end := normalizeSelection(m)
switch mode {
case VisualMode:
visualCharPaste(m, reg, start, end)
case VisualBlockMode:
visualBlockPaste(m, reg, start, end)
case VisualLineMode:
visualLinePaste(m, reg, start, end)
}
// Exit visual mode
m.SetMode(NormalMode)
return nil
}
// normalizeSelection returns start and end positions with start always before end
func normalizeSelection(m Model) (Position, Position) {
anchorX, anchorY := m.AnchorX(), m.AnchorY()
cursorX, cursorY := m.CursorX(), m.CursorY()
start := Position{Line: anchorY, Col: anchorX}
end := Position{Line: cursorY, Col: cursorX}
// Normalize so start is always before end
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
start, end = end, start
}
return start, end
}
// visualCharPaste handles paste in visual (character) mode
func visualCharPaste(m Model, reg Register, start, end Position) {
// First, extract the text that will be deleted (to save to register)
deletedText := extractCharSelection(m, start, end)
// Delete the selection
deleteCharSelectionForPaste(m, start, end)
// Insert the register content at start position
if len(reg.Content) == 0 {
// Empty register - just delete (already done)
} else if reg.Type == CharwiseRegister {
// Charwise paste: insert text at cursor position
if len(reg.Content) == 1 {
line := m.Line(start.Line)
insertAt := min(start.Col, len(line))
newLine := line[:insertAt] + reg.Content[0] + line[insertAt:]
m.SetLine(start.Line, newLine)
// Cursor at end of pasted text
m.SetCursorX(insertAt + len(reg.Content[0]) - 1)
m.SetCursorY(start.Line)
}
} else if reg.Type == LinewiseRegister {
// Linewise paste in visual char mode: replace selection with lines
// Insert each line from register
for i, content := range reg.Content {
if i == 0 {
// First line: insert at start position
line := m.Line(start.Line)
insertAt := min(start.Col, len(line))
newLine := line[:insertAt] + content
if len(reg.Content) == 1 {
// Single line register - append rest of line
newLine += line[insertAt:]
}
m.SetLine(start.Line, newLine)
} else {
// Subsequent lines: insert new lines
m.InsertLine(start.Line+i, content)
}
}
m.SetCursorY(start.Line)
m.SetCursorX(start.Col)
}
m.ClampCursorX()
// Update register with deleted text
m.UpdateDefaultRegister(CharwiseRegister, []string{deletedText})
}
// visualBlockPaste handles paste in visual block mode
func visualBlockPaste(m Model, reg Register, start, end Position) {
startCol := min(start.Col, end.Col)
endCol := max(start.Col, end.Col)
// Extract deleted text (for register)
var deletedLines []string
for y := start.Line; y <= end.Line; y++ {
line := m.Line(y)
if startCol < len(line) {
ec := min(endCol+1, len(line))
deletedLines = append(deletedLines, line[startCol:ec])
} else {
deletedLines = append(deletedLines, "")
}
}
// Delete the block selection
for y := start.Line; y <= end.Line; y++ {
line := m.Line(y)
if startCol >= len(line) {
continue
}
ec := min(endCol+1, len(line))
m.SetLine(y, line[:startCol]+line[ec:])
}
// Insert register content
if len(reg.Content) > 0 {
pasteContent := reg.Content[0]
if reg.Type == LinewiseRegister && len(reg.Content) > 0 {
pasteContent = reg.Content[0]
}
for y := start.Line; y <= end.Line; y++ {
line := m.Line(y)
insertAt := min(startCol, len(line))
// Pad with spaces if needed
for len(line) < insertAt {
line += " "
}
newLine := line[:insertAt] + pasteContent + line[insertAt:]
m.SetLine(y, newLine)
}
}
m.SetCursorY(start.Line)
m.SetCursorX(startCol)
m.ClampCursorX()
// Update register with deleted block text (joined)
m.UpdateDefaultRegister(CharwiseRegister, []string{strings.Join(deletedLines, "\n")})
}
// visualLinePaste handles paste in visual line mode
func visualLinePaste(m Model, reg Register, start, end Position) {
// Extract deleted lines (for register)
var deletedLines []string
for y := start.Line; y <= end.Line; y++ {
deletedLines = append(deletedLines, m.Line(y))
}
// Delete the selected lines (from end to start to preserve indices)
for y := end.Line; y >= start.Line; y-- {
m.DeleteLine(y)
}
// Insert register content
if len(reg.Content) == 0 {
// Empty register - ensure at least one empty line exists
if m.LineCount() == 0 {
m.InsertLine(0, "")
}
} else if reg.Type == LinewiseRegister {
// Linewise register: insert each line
insertPos := start.Line
for _, content := range reg.Content {
m.InsertLine(insertPos, content)
insertPos++
}
} else {
// Charwise register: insert as a single line
m.InsertLine(start.Line, reg.Content[0])
}
// Ensure we have at least one line
if m.LineCount() == 0 {
m.InsertLine(0, "")
}
// Position cursor at start of pasted content
y := start.Line
if y >= m.LineCount() {
y = m.LineCount() - 1
}
m.SetCursorY(y)
m.SetCursorX(0)
m.ClampCursorX()
// Update register with deleted lines
m.UpdateDefaultRegister(LinewiseRegister, deletedLines)
}
// extractCharSelection extracts text from a character selection
func extractCharSelection(m Model, start, end Position) string {
if start.Line == end.Line {
line := m.Line(start.Line)
endCol := min(end.Col+1, len(line))
startCol := min(start.Col, len(line))
if startCol >= endCol {
return ""
}
return line[startCol:endCol]
}
// Multi-line selection
var result strings.Builder
// First line: from start.Col to end
firstLine := m.Line(start.Line)
if start.Col < len(firstLine) {
result.WriteString(firstLine[start.Col:])
}
result.WriteString("\n")
// Middle lines: entire lines
for y := start.Line + 1; y < end.Line; y++ {
result.WriteString(m.Line(y))
result.WriteString("\n")
}
// Last line: from beginning to end.Col
lastLine := m.Line(end.Line)
endCol := min(end.Col+1, len(lastLine))
result.WriteString(lastLine[:endCol])
return result.String()
}
// deleteCharSelectionForPaste deletes a character selection (similar to operator/delete.go)
func deleteCharSelectionForPaste(m Model, start, end Position) {
if start.Line == end.Line {
line := m.Line(start.Line)
endCol := min(end.Col+1, len(line))
m.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else {
startLine := m.Line(start.Line)
endLine := m.Line(end.Line)
prefix := ""
if start.Col < len(startLine) {
prefix = startLine[:start.Col]
}
suffix := ""
if end.Col+1 < len(endLine) {
suffix = endLine[end.Col+1:]
}
// Delete from end back to start to preserve indices
for i := end.Line; i >= start.Line; i-- {
m.DeleteLine(i)
}
m.InsertLine(start.Line, prefix+suffix)
}
m.SetCursorY(start.Line)
m.SetCursorX(start.Col)
}
// Ensure VisualPaste implements Repeatable
var _ Repeatable = VisualPaste{}
func (a VisualPaste) WithCount(n int) Action {
return VisualPaste{Count: n}
}

View File

@ -1046,3 +1046,430 @@ func TestYankThenPasteCharwise(t *testing.T) {
}
})
}
// =============================================================================
// Visual Mode Paste Tests
// =============================================================================
// In visual mode, 'p' should replace the selected text with register content.
// This is different from normal mode where 'p' inserts after cursor.
func TestVisualModePasteCharwise(t *testing.T) {
// -------------------------------------------------------------------------
// Basic Visual Mode Paste with Charwise Register
// -------------------------------------------------------------------------
t.Run("vp replaces selection with charwise register", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"REPLACED"}),
)
sendKeys(tm, "v", "l", "l", "l", "l", "p") // select "hello", paste
m := getFinalModel(t, tm)
if m.Line(0) != "REPLACED world" {
t.Errorf("Line(0) = %q, want 'REPLACED world'", m.Line(0))
}
})
t.Run("vp replaces single character", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"X"}),
)
sendKeys(tm, "v", "p") // select "h", paste
m := getFinalModel(t, tm)
if m.Line(0) != "Xello world" {
t.Errorf("Line(0) = %q, want 'Xello world'", m.Line(0))
}
})
t.Run("vp replaces word in middle of line", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world goodbye"}),
WithCursorPos(action.Position{Line: 0, Col: 6}),
WithRegister('"', action.CharwiseRegister, []string{"EARTH"}),
)
sendKeys(tm, "v", "e", "p") // select "world", paste
m := getFinalModel(t, tm)
if m.Line(0) != "hello EARTH goodbye" {
t.Errorf("Line(0) = %q, want 'hello EARTH goodbye'", m.Line(0))
}
})
t.Run("vp replaces to end of line", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 6}),
WithRegister('"', action.CharwiseRegister, []string{"universe"}),
)
sendKeys(tm, "v", "$", "p") // select "world", paste
m := getFinalModel(t, tm)
if m.Line(0) != "hello universe" {
t.Errorf("Line(0) = %q, want 'hello universe'", m.Line(0))
}
})
t.Run("vp with empty register deletes selection", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{""}),
)
sendKeys(tm, "v", "l", "l", "l", "l", "p") // select "hello", paste empty
m := getFinalModel(t, tm)
if m.Line(0) != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.Line(0))
}
})
// -------------------------------------------------------------------------
// Visual Mode Paste Spanning Multiple Lines
// -------------------------------------------------------------------------
t.Run("vp replaces selection spanning multiple lines", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two", "line three"}),
WithCursorPos(action.Position{Line: 0, Col: 5}),
WithRegister('"', action.CharwiseRegister, []string{"REPLACED"}),
)
// v at (0,5), j goes to (1,5), h goes to (1,4) = space after "line"
// Selection: "one\nline " (from 'o' to space)
sendKeys(tm, "v", "j", "h", "p")
m := getFinalModel(t, tm)
// Should join: "line " + "REPLACED" + "two"
if m.Line(0) != "line REPLACEDtwo" {
t.Errorf("Line(0) = %q, want 'line REPLACEDtwo'", m.Line(0))
}
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
})
// -------------------------------------------------------------------------
// Cursor Position After Visual Paste
// -------------------------------------------------------------------------
t.Run("vp cursor at start of pasted content", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 6}),
WithRegister('"', action.CharwiseRegister, []string{"EARTH"}),
)
sendKeys(tm, "v", "e", "p") // select "world", paste "EARTH"
m := getFinalModel(t, tm)
// Cursor should be at start of pasted content (or end, depending on impl)
// In Vim, cursor goes to last char of pasted text
if m.CursorX() != 10 {
t.Errorf("CursorX() = %d, want 10", m.CursorX())
}
})
t.Run("vp exits visual mode", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"X"}),
)
sendKeys(tm, "v", "l", "p")
m := getFinalModel(t, tm)
if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
})
// -------------------------------------------------------------------------
// Visual Mode Paste with Linewise Register
// -------------------------------------------------------------------------
t.Run("vp with linewise register replaces selection", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"NEW LINE"}),
)
sendKeys(tm, "v", "l", "l", "l", "l", "p") // select "hello", paste linewise content
m := getFinalModel(t, tm)
// Linewise register content is pasted inline replacing the selection
if m.Line(0) != "NEW LINE world" {
t.Errorf("Line(0) = %q, want 'NEW LINE world'", m.Line(0))
}
})
}
func TestVisualLinePaste(t *testing.T) {
// -------------------------------------------------------------------------
// Visual Line Mode Paste
// -------------------------------------------------------------------------
t.Run("Vp replaces line with charwise register", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two", "line three"}),
WithCursorPos(action.Position{Line: 1, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"REPLACED"}),
)
sendKeys(tm, "V", "p") // select line two, paste
m := getFinalModel(t, tm)
if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount())
}
if m.Line(1) != "REPLACED" {
t.Errorf("Line(1) = %q, want 'REPLACED'", m.Line(1))
}
})
t.Run("Vp replaces line with linewise register", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two", "line three"}),
WithCursorPos(action.Position{Line: 1, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"NEW LINE"}),
)
sendKeys(tm, "V", "p") // select line two, paste
m := getFinalModel(t, tm)
if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount())
}
if m.Line(1) != "NEW LINE" {
t.Errorf("Line(1) = %q, want 'NEW LINE'", m.Line(1))
}
})
t.Run("Vp replaces multiple lines with linewise register", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two", "line three", "line four"}),
WithCursorPos(action.Position{Line: 1, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"REPLACED"}),
)
sendKeys(tm, "V", "j", "p") // select lines two and three, paste
m := getFinalModel(t, tm)
if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount())
}
if m.Line(0) != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.Line(0))
}
if m.Line(1) != "REPLACED" {
t.Errorf("Line(1) = %q, want 'REPLACED'", m.Line(1))
}
if m.Line(2) != "line four" {
t.Errorf("Line(2) = %q, want 'line four'", m.Line(2))
}
})
t.Run("Vp replaces multiple lines with multiple linewise register lines", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two", "line three", "line four"}),
WithCursorPos(action.Position{Line: 1, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"NEW A", "NEW B"}),
)
sendKeys(tm, "V", "j", "p") // select lines two and three, paste two lines
m := getFinalModel(t, tm)
if m.LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.LineCount())
}
if m.Line(0) != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.Line(0))
}
if m.Line(1) != "NEW A" {
t.Errorf("Line(1) = %q, want 'NEW A'", m.Line(1))
}
if m.Line(2) != "NEW B" {
t.Errorf("Line(2) = %q, want 'NEW B'", m.Line(2))
}
if m.Line(3) != "line four" {
t.Errorf("Line(3) = %q, want 'line four'", m.Line(3))
}
})
t.Run("Vp on first line", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"FIRST"}),
)
sendKeys(tm, "V", "p")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "FIRST" {
t.Errorf("Line(0) = %q, want 'FIRST'", m.Line(0))
}
if m.Line(1) != "line two" {
t.Errorf("Line(1) = %q, want 'line two'", m.Line(1))
}
})
t.Run("Vp on last line", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two"}),
WithCursorPos(action.Position{Line: 1, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"LAST"}),
)
sendKeys(tm, "V", "p")
m := getFinalModel(t, tm)
if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount())
}
if m.Line(0) != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.Line(0))
}
if m.Line(1) != "LAST" {
t.Errorf("Line(1) = %q, want 'LAST'", m.Line(1))
}
})
t.Run("Vp exits visual line mode", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"REPLACED"}),
)
sendKeys(tm, "V", "p")
m := getFinalModel(t, tm)
if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
})
}
func TestVisualPasteRegisterBehavior(t *testing.T) {
// -------------------------------------------------------------------------
// Register Behavior - deleted text should go to register
// -------------------------------------------------------------------------
t.Run("vp puts deleted text into register", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"NEW"}),
)
sendKeys(tm, "v", "l", "l", "l", "l", "p") // select "hello", paste "NEW"
m := getFinalModel(t, tm)
// After visual paste, the deleted "hello" should be in the register
reg, ok := m.GetRegister('"')
if !ok {
t.Fatal("unnamed register not found")
}
if len(reg.Content) != 1 || reg.Content[0] != "hello" {
t.Errorf("register content = %q, want 'hello'", reg.Content)
}
})
t.Run("Vp puts deleted line into register", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"line one", "line two"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"REPLACED"}),
)
sendKeys(tm, "V", "p")
m := getFinalModel(t, tm)
reg, ok := m.GetRegister('"')
if !ok {
t.Fatal("unnamed register not found")
}
if len(reg.Content) != 1 || reg.Content[0] != "line one" {
t.Errorf("register content = %q, want 'line one'", reg.Content)
}
if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
}
})
}
func TestVisualPasteEdgeCases(t *testing.T) {
// -------------------------------------------------------------------------
// Edge Cases
// -------------------------------------------------------------------------
t.Run("vp on empty line", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"", "line two"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"NEW"}),
)
sendKeys(tm, "v", "p")
m := getFinalModel(t, tm)
if m.Line(0) != "NEW" {
t.Errorf("Line(0) = %q, want 'NEW'", m.Line(0))
}
})
t.Run("vp selecting entire line content", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello", "world"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"REPLACED"}),
)
sendKeys(tm, "v", "$", "p") // select entire "hello"
m := getFinalModel(t, tm)
if m.Line(0) != "REPLACED" {
t.Errorf("Line(0) = %q, want 'REPLACED'", m.Line(0))
}
})
t.Run("vp with backwards selection", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"hello world"}),
WithCursorPos(action.Position{Line: 0, Col: 5}),
WithRegister('"', action.CharwiseRegister, []string{"X"}),
)
sendKeys(tm, "v", "h", "h", "h", "h", "h", "p") // select backwards "hello ", paste
m := getFinalModel(t, tm)
if m.Line(0) != "Xworld" {
t.Errorf("Line(0) = %q, want 'Xworld'", m.Line(0))
}
})
t.Run("vp in single character file", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"a"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.CharwiseRegister, []string{"XYZ"}),
)
sendKeys(tm, "v", "p")
m := getFinalModel(t, tm)
if m.Line(0) != "XYZ" {
t.Errorf("Line(0) = %q, want 'XYZ'", m.Line(0))
}
})
t.Run("Vp on single line file", func(t *testing.T) {
tm := newTestModel(t,
WithLines([]string{"only line"}),
WithCursorPos(action.Position{Line: 0, Col: 0}),
WithRegister('"', action.LinewiseRegister, []string{"REPLACED"}),
)
sendKeys(tm, "V", "p")
m := getFinalModel(t, tm)
if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount())
}
if m.Line(0) != "REPLACED" {
t.Errorf("Line(0) = %q, want 'REPLACED'", m.Line(0))
}
})
}

View File

@ -38,7 +38,7 @@ func NewNormalKeymap() *Keymap {
operators: map[string]action.Operator{
"d": operator.DeleteOperator{},
"y": operator.YankOperator{},
// "c": ChangeOp{},
"c": operator.ChangeOperator{}, // TODO: Finish implementing
// "s": SubstitueOp{},
// "~": SwapCaseOp{},
},
@ -92,6 +92,7 @@ func NewVisualKeymap() *Keymap {
// "~": SwapCaseOp{},
},
actions: map[string]action.Action{
"p": action.VisualPaste{Count: 1},
// ":": action.EnterComandMode{}, // Different OP
},
}