Compare commits
No commits in common. "db52b63db1bbd173ff318490e0ce4fcbb0ba0db9" and "37ad0ecb2bcc5be355512ee6009d7a3bac12f864" have entirely different histories.
db52b63db1
...
37ad0ecb2b
@ -1,109 +0,0 @@
|
||||
package action
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
// ChangeToEndOfLine implements Action (C) - changes from cursor to end of line
|
||||
type ChangeToEndOfLine struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
func (a ChangeToEndOfLine) Execute(m Model) tea.Cmd {
|
||||
pos := m.CursorX()
|
||||
line := m.Line(m.CursorY())
|
||||
|
||||
// Save deleted text to register
|
||||
if pos < len(line) {
|
||||
m.UpdateDefaultRegister(CharwiseRegister, []string{line[pos:]})
|
||||
}
|
||||
|
||||
// Delete to end of line
|
||||
m.SetLine(m.CursorY(), line[:pos])
|
||||
|
||||
// Enter insert mode
|
||||
m.SetMode(InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure ChangeToEndOfLine implements Repeatable
|
||||
var _ Repeatable = ChangeToEndOfLine{}
|
||||
|
||||
func (a ChangeToEndOfLine) WithCount(n int) Action {
|
||||
return ChangeToEndOfLine{Count: n}
|
||||
}
|
||||
|
||||
// SubstituteChar implements Action (s) - deletes character and enters insert mode
|
||||
type SubstituteChar struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
func (a SubstituteChar) Execute(m Model) tea.Cmd {
|
||||
pos := m.CursorX()
|
||||
line := m.Line(m.CursorY())
|
||||
|
||||
// Calculate how many chars to delete (limited by line length)
|
||||
count := min(a.Count, len(line)-pos)
|
||||
|
||||
if count > 0 {
|
||||
// Save deleted text to register
|
||||
m.UpdateDefaultRegister(CharwiseRegister, []string{line[pos : pos+count]})
|
||||
|
||||
// Delete the characters
|
||||
m.SetLine(m.CursorY(), line[:pos]+line[pos+count:])
|
||||
}
|
||||
|
||||
// Enter insert mode
|
||||
m.SetMode(InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure SubstituteChar implements Repeatable
|
||||
var _ Repeatable = SubstituteChar{}
|
||||
|
||||
func (a SubstituteChar) WithCount(n int) Action {
|
||||
return SubstituteChar{Count: n}
|
||||
}
|
||||
|
||||
// SubstituteLine implements Action (S) - clears line and enters insert mode
|
||||
type SubstituteLine struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
func (a SubstituteLine) Execute(m Model) tea.Cmd {
|
||||
y := m.CursorY()
|
||||
|
||||
// Calculate how many lines to substitute
|
||||
count := min(a.Count, m.LineCount()-y)
|
||||
|
||||
var lines []string
|
||||
|
||||
// Collect and delete lines
|
||||
for range count {
|
||||
lines = append(lines, m.Line(y))
|
||||
m.DeleteLine(y)
|
||||
}
|
||||
|
||||
// Save deleted lines to register
|
||||
m.UpdateDefaultRegister(LinewiseRegister, lines)
|
||||
|
||||
// Insert empty line at original position
|
||||
insertY := min(y, m.LineCount())
|
||||
m.InsertLine(insertY, "")
|
||||
|
||||
// Position cursor
|
||||
m.SetCursorY(insertY)
|
||||
m.SetCursorX(0)
|
||||
|
||||
// Enter insert mode
|
||||
m.SetMode(InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure SubstituteLine implements Repeatable
|
||||
var _ Repeatable = SubstituteLine{}
|
||||
|
||||
func (a SubstituteLine) WithCount(n int) Action {
|
||||
return SubstituteLine{Count: n}
|
||||
}
|
||||
@ -147,281 +147,3 @@ 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}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1046,430 +1046,3 @@ 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -152,10 +152,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
|
||||
mtype = action.Linewise
|
||||
}
|
||||
cmd := op.Operate(m, start, end, mtype)
|
||||
// Only reset to normal mode if operator didn't enter insert mode
|
||||
if m.Mode() != action.InsertMode {
|
||||
m.SetMode(action.NormalMode)
|
||||
}
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ func NewNormalKeymap() *Keymap {
|
||||
operators: map[string]action.Operator{
|
||||
"d": operator.DeleteOperator{},
|
||||
"y": operator.YankOperator{},
|
||||
"c": operator.ChangeOperator{}, // TODO: Finish implementing
|
||||
// "c": ChangeOp{},
|
||||
// "s": SubstitueOp{},
|
||||
// "~": SwapCaseOp{},
|
||||
},
|
||||
@ -55,9 +55,6 @@ func NewNormalKeymap() *Keymap {
|
||||
"V": action.EnterVisualLineMode{},
|
||||
"ctrl+v": action.EnterVisualBlockMode{},
|
||||
"D": action.DeleteToEndOfLine{Count: 1},
|
||||
"C": action.ChangeToEndOfLine{Count: 1},
|
||||
"s": action.SubstituteChar{Count: 1},
|
||||
"S": action.SubstituteLine{Count: 1},
|
||||
"p": action.Paste{Count: 1},
|
||||
"P": action.PasteBefore{Count: 1},
|
||||
},
|
||||
@ -88,10 +85,13 @@ func NewVisualKeymap() *Keymap {
|
||||
"d": operator.DeleteOperator{},
|
||||
"x": operator.DeleteOperator{},
|
||||
"y": operator.YankOperator{},
|
||||
"c": operator.ChangeOperator{},
|
||||
// "c": ChangeOp{},
|
||||
// "y": YankOp{},
|
||||
// "p": PasteOp{},
|
||||
// "s": SubstitueOp{},
|
||||
// "~": SwapCaseOp{},
|
||||
},
|
||||
actions: map[string]action.Action{
|
||||
"p": action.VisualPaste{Count: 1},
|
||||
// ":": action.EnterComandMode{}, // Different OP
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
package operator
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Implements Operator (c)
|
||||
type ChangeOperator struct{}
|
||||
|
||||
func (o ChangeOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd {
|
||||
switch m.Mode() {
|
||||
case action.VisualMode:
|
||||
changeCharSelection(m, start, end)
|
||||
case action.VisualLineMode:
|
||||
changeLineSelection(m, start, end)
|
||||
case action.VisualBlockMode:
|
||||
changeBlockSelection(m, start, end)
|
||||
case action.NormalMode:
|
||||
changeNormalMode(m, start, end, mtype)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func changeNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) {
|
||||
// Normalize so start is always before or equal to end
|
||||
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
|
||||
start, end = end, start
|
||||
}
|
||||
|
||||
// Linewise motions (j, k, G, gg) always operate on whole lines
|
||||
if mtype == action.Linewise {
|
||||
changeLineSelection(m, start, end)
|
||||
return
|
||||
}
|
||||
|
||||
// Charwise motions on same line
|
||||
if start.Line == end.Line {
|
||||
// No movement = nothing to change
|
||||
if start.Col == end.Col && mtype == action.CharwiseExclusive {
|
||||
m.SetMode(action.InsertMode)
|
||||
return
|
||||
}
|
||||
// Exclusive motion: end position not included, so back up one
|
||||
if mtype == action.CharwiseExclusive {
|
||||
end.Col--
|
||||
}
|
||||
if end.Col >= start.Col {
|
||||
changeCharSelection(m, start, end)
|
||||
} else {
|
||||
m.SetMode(action.InsertMode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Charwise motion spanning multiple lines
|
||||
changeCharSelection(m, start, end)
|
||||
}
|
||||
|
||||
func changeCharSelection(m action.Model, start, end action.Position) {
|
||||
var deletedText string
|
||||
|
||||
if start.Line == end.Line {
|
||||
line := m.Line(start.Line)
|
||||
endCol := min(end.Col+1, len(line))
|
||||
deletedText = line[start.Col:endCol]
|
||||
m.SetLine(start.Line, line[:start.Col]+line[endCol:])
|
||||
} else {
|
||||
startLine := m.Line(start.Line)
|
||||
endLine := m.Line(end.Line)
|
||||
|
||||
// Extract deleted text
|
||||
deletedText = startLine[start.Col:] + "\n"
|
||||
for y := start.Line + 1; y < end.Line; y++ {
|
||||
deletedText += m.Line(y) + "\n"
|
||||
}
|
||||
endCol := min(end.Col+1, len(endLine))
|
||||
deletedText += endLine[:endCol]
|
||||
|
||||
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)
|
||||
m.ClampCursorX()
|
||||
m.SetMode(action.InsertMode)
|
||||
|
||||
// Update register with deleted text
|
||||
m.UpdateDefaultRegister(action.CharwiseRegister, []string{deletedText})
|
||||
}
|
||||
|
||||
func changeLineSelection(m action.Model, start, end action.Position) {
|
||||
var lines []string
|
||||
|
||||
for i := end.Line; i >= start.Line; i-- {
|
||||
lines = append([]string{m.Line(i)}, lines...)
|
||||
m.DeleteLine(i)
|
||||
}
|
||||
|
||||
// Insert an empty line for editing
|
||||
insertY := min(start.Line, m.LineCount())
|
||||
m.InsertLine(insertY, "")
|
||||
|
||||
m.SetCursorY(insertY)
|
||||
m.SetCursorX(0)
|
||||
m.SetMode(action.InsertMode)
|
||||
|
||||
// Update registers
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, lines)
|
||||
}
|
||||
|
||||
func changeBlockSelection(m action.Model, start, end action.Position) {
|
||||
startCol := min(start.Col, end.Col)
|
||||
endCol := max(start.Col, end.Col)
|
||||
|
||||
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:])
|
||||
}
|
||||
|
||||
m.SetCursorY(start.Line)
|
||||
m.SetCursorX(startCol)
|
||||
m.ClampCursorX()
|
||||
m.SetMode(action.InsertMode)
|
||||
}
|
||||
|
||||
// Verify ChangeOperator implements DoublePresser
|
||||
var _ action.DoublePresser = ChangeOperator{}
|
||||
|
||||
// Double press handles cc - change the entire line
|
||||
func (o ChangeOperator) DoublePress(m action.Model, count int) tea.Cmd {
|
||||
startY := m.CursorY()
|
||||
|
||||
// If we have a higher value than lines remaining, we can only run so many times
|
||||
opCount := min(count, m.LineCount()-startY)
|
||||
|
||||
var lines []string
|
||||
|
||||
// Collect lines to delete (always delete at startY since lines shift up)
|
||||
for range opCount {
|
||||
lines = append(lines, m.Line(startY))
|
||||
m.DeleteLine(startY)
|
||||
}
|
||||
|
||||
// Put deleted lines in register
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, lines)
|
||||
|
||||
// Insert empty line at the original position for editing
|
||||
// If we deleted everything, startY might be past end, so clamp it
|
||||
insertY := min(startY, m.LineCount())
|
||||
m.InsertLine(insertY, "")
|
||||
|
||||
// Position cursor on the new empty line
|
||||
m.SetCursorY(insertY)
|
||||
m.SetCursorX(0)
|
||||
m.SetMode(action.InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -52,6 +52,7 @@ func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd {
|
||||
|
||||
// Put her in the register!
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, lines)
|
||||
// m.SetRegister('"', action.LinewiseRegister, lines)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user