diff --git a/internal/action/replace.go b/internal/action/replace.go index 8fdd216..31697c5 100644 --- a/internal/action/replace.go +++ b/internal/action/replace.go @@ -35,15 +35,25 @@ func (a ReplaceChar) Execute(m Model) tea.Cmd { buf.UndoStack.BeginBlock(win.Cursor) } - pos := win.Cursor.Col - line := buf.Line(win.Cursor.Line) - for i := 0; i < a.Count && pos < len(line); i++ { - line = line[:pos] + a.Char + line[pos+1:] - buf.SetLine(win.Cursor.Line, line) - pos++ + 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++ { + line = line[:pos] + a.Char + line[pos+1:] + buf.SetLine(win.Cursor.Line, line) + pos++ + } + + win.SetCursorCol(pos - 1) } - 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 } diff --git a/internal/editor/integration_replace_test.go b/internal/editor/integration_replace_test.go index 1b5d4d2..77fabf1 100644 --- a/internal/editor/integration_replace_test.go +++ b/internal/editor/integration_replace_test.go @@ -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]) + } + } +} diff --git a/internal/input/handler.go b/internal/input/handler.go index 25e0bd3..ae3202a 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -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 diff --git a/internal/input/keymap.go b/internal/input/keymap.go index a1577e1..cc3a2ba 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -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, diff --git a/internal/operator/change.go b/internal/operator/change.go index 8566d67..92fcc96 100644 --- a/internal/operator/change.go +++ b/internal/operator/change.go @@ -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.