fix: fixed replace action (r) in visual mode, tested
All checks were successful
Run Test Suite / test (push) Successful in 18s

This commit is contained in:
Hayden Hargreaves 2026-04-06 21:14:56 -07:00
parent 7a7472fd12
commit 32fe3f1edd
5 changed files with 212 additions and 9 deletions

View File

@ -35,6 +35,14 @@ func (a ReplaceChar) Execute(m Model) tea.Cmd {
buf.UndoStack.BeginBlock(win.Cursor)
}
switch m.Mode() {
case core.VisualMode:
replaceVisualCharSelection(m, a.Char)
case core.VisualLineMode:
replaceVisualLineSelection(m, a.Char)
case core.VisualBlockMode:
replaceVisualBlockSelection(m, a.Char)
default:
pos := win.Cursor.Col
line := buf.Line(win.Cursor.Line)
for i := 0; i < a.Count && pos < len(line); i++ {
@ -44,6 +52,8 @@ func (a ReplaceChar) Execute(m Model) tea.Cmd {
}
win.SetCursorCol(pos - 1)
}
m.SetMode(core.NormalMode)
if buf.UndoStack != nil {
@ -53,6 +63,80 @@ func (a ReplaceChar) Execute(m Model) tea.Cmd {
return nil
}
func replaceVisualCharSelection(m Model, char string) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := normalizeSelection(m)
for y := start.Line; y <= end.Line; y++ {
line := buf.Line(y)
if len(line) == 0 {
continue
}
from := 0
to := len(line) - 1
if y == start.Line {
from = start.Col
}
if y == end.Line {
to = min(end.Col, len(line)-1)
}
if from < 0 {
from = 0
}
if from >= len(line) || to < from {
continue
}
replaced := strings.Repeat(char, to-from+1)
buf.SetLine(y, line[:from]+replaced+line[to+1:])
}
win.SetCursorPos(start.Line, start.Col)
}
func replaceVisualLineSelection(m Model, char string) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := normalizeSelection(m)
for y := start.Line; y <= end.Line; y++ {
line := buf.Line(y)
if len(line) == 0 {
continue
}
buf.SetLine(y, strings.Repeat(char, len(line)))
}
win.SetCursorPos(start.Line, 0)
}
func replaceVisualBlockSelection(m Model, char string) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
start, end := normalizeSelection(m)
startCol := min(start.Col, end.Col)
endCol := max(start.Col, end.Col)
for y := start.Line; y <= end.Line; y++ {
line := buf.Line(y)
if startCol >= len(line) {
continue
}
ec := min(endCol, len(line)-1)
replaced := strings.Repeat(char, ec-startCol+1)
buf.SetLine(y, line[:startCol]+replaced+line[ec+1:])
}
win.SetCursorPos(start.Line, startCol)
}
type EnterReplace struct {
Count int
}

View File

@ -1038,3 +1038,121 @@ func TestReplaceModeRepeat(t *testing.T) {
}
})
}
// ==================================================
// Visual Replace Char (v/V/ctrl+v + r{char}) Tests
// ==================================================
func TestVisualReplaceChar(t *testing.T) {
t.Run("test 'vlllrx' replaces each selected char in characterwise visual mode", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "l", "r", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "xxxxo world" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "xxxxo world")
}
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
}
})
t.Run("test backward characterwise selection with 'r'", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "v", "h", "h", "r", "z")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "hezzz" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "hezzz")
}
})
t.Run("test 'VjrX' replaces all characters in selected lines", func(t *testing.T) {
lines := []string{"abc", "de", "fghi"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "j", "r", "X")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"XXX", "XX", "fghi"})
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
}
})
t.Run("test visual line backward selection with 'r'", func(t *testing.T) {
lines := []string{"one", "two", "three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "V", "k", "r", "_")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"one", "___", "_____"})
})
t.Run("test 'ctrl+vljrx' replaces each char in block selection", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v", "l", "j", "r", "x")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"xxllo", "xxrld"})
})
t.Run("test block replace with ragged line lengths replaces available chars", func(t *testing.T) {
lines := []string{"abcd", "xy", "1234"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "ctrl+v", "l", "j", "j", "r", "q")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"aqqd", "xq", "1qq4"})
})
}
// ==================================================
// Visual Replace Char Repeat (.) Tests
// ==================================================
func TestVisualReplaceCharRepeat(t *testing.T) {
t.Run("test dot repeats characterwise visual replace", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v", "l", "l", "r", "x", "w", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "xxxlo xxxld" {
t.Errorf("Line(0) = %q, want %q", m.ActiveBuffer().Line(0), "xxxlo xxxld")
}
})
t.Run("test dot repeats visual line replace on next line", func(t *testing.T) {
lines := []string{"abcde", "vwxyz"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V", "r", "_", "j", ".")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"_____", "_____"})
})
t.Run("test dot repeats visual block replace at new location", func(t *testing.T) {
lines := []string{"abcdef", "ghijkl", "mnopqr", "stuvwx"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v", "l", "j", "r", "*", "j", "j", ".")
m := getFinalModel(t, tm)
assertReplaceVisualLines(t, m, []string{"**cdef", "**ijkl", "**opqr", "**uvwx"})
})
}
func assertReplaceVisualLines(t *testing.T, m *Model, want []string) {
t.Helper()
if m.ActiveBuffer().LineCount() != len(want) {
t.Fatalf("LineCount() = %d, want %d", m.ActiveBuffer().LineCount(), len(want))
}
for i := range want {
if m.ActiveBuffer().Line(i) != want[i] {
t.Errorf("Line(%d) = %q, want %q", i, m.ActiveBuffer().Line(i), want[i])
}
}
}

View File

@ -176,7 +176,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd {
// Handle character motions (f/t/F/T) - transition to waiting state
if kind == "char_motion" {
if key == "r" {
if key == "r" && !m.Mode().IsVisualMode() {
m.SetMode(core.WaitingMode)
}
h.charMotionType = key

View File

@ -170,6 +170,7 @@ func NewVisualKeymap() *Keymap {
"F": action.FindChar{Forward: false, Inclusive: true},
"t": action.FindChar{Forward: true, Inclusive: false},
"T": action.FindChar{Forward: false, Inclusive: false},
"r": action.ReplaceChar{Count: 1},
},
modifiers: map[string]any{
"i": nil,

View File

@ -6,7 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
// ChangeOperator implements Operator (c) - changes (deletes and enters insert mode) text.
// ChangeOperator implements Operator (c, s, R) - changes (deletes and enters insert mode) text.
type ChangeOperator struct{}
// ChangeOperator.Operate: Changes text based on the current mode and motion type.