Implemented the replace (r and R) actions and replace mode #5

Merged
azpect merged 3 commits from feature/replace into master 2026-04-06 18:30:37 -07:00
13 changed files with 1289 additions and 83 deletions

View File

@ -133,8 +133,8 @@
- [ ] `U` - Undo all changes on line
### Other Normal Mode
- [ ] `r{char}` - Replace character
- [ ] `R` - Replace mode
- [x] `r{char}` - Replace character
- [x] `R` - Replace mode
- [ ] `~` - Swap case of character
- [ ] `ctrl+a` - Increment number
- [ ] `ctrl+x` - Decrement number

View File

@ -64,6 +64,18 @@ While the undo tree method that vim uses is powerful, I rarely find myself using
approach is more natural to "non-vim" users and much simpler to implement. Implementing a feature similar
to Vims undo tree would many times longer than a simple stack.
#### Vim-like Replace vs. Custom Replace
The way that vim's replace mode is implemented is quite complex, keeping track of the previous
line backspace can only delete newly replaced characters. This is a complex feature, one that
I rarely use, and even find a bit un-intuitive. Implementing replace mode in a way where all
actions function the same as insert mode (other than the actual character typing) allows for
a much simpler implementation, as well as a more intuitive user experience.
Replace mode implements and replaces (no pun intended) the last inserted keys of insert mode. Due to
the infrequent use of replace mode, and the '.' action for insert mode, this felt like a natural
trade off.
---
## TODO List

129
internal/action/replace.go Normal file
View File

@ -0,0 +1,129 @@
package action
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
type ReplaceChar struct {
Char string
Count int
}
func (m ReplaceChar) WithChar(char string) Motion {
m.Char = char
return m
}
func (m ReplaceChar) Type() core.MotionType {
return core.CharwiseInclusive
}
// WithCount sets the count (required by Repeatable interface)
func (m ReplaceChar) WithCount(n int) Action {
m.Count = n
return m
}
func (a ReplaceChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack != nil && !buf.UndoStack.Recording() {
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++
}
win.SetCursorCol(pos - 1)
m.SetMode(core.NormalMode)
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
return nil
}
type EnterReplace struct {
Count int
}
func (a EnterReplace) WithCount(n int) Action {
a.Count = n
return a
}
func (a EnterReplace) Execute(m Model) tea.Cmd {
m.SetMode(core.ReplaceMode)
return nil
}
type ReplaceModeChar struct {
Char string
}
// ReplaceModeChar.Execute: Inserts a single character at the cursor position, overwriting current
// character.
func (a ReplaceModeChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Line(y)
if x < len(l) {
buf.SetLine(y, l[:x]+a.Char+l[x+1:])
} else {
buf.SetLine(y, l+a.Char)
}
win.SetCursorCol(x + len(a.Char))
return nil
}
// ReplaceNewline splits the current line at the cursor (enter key)
type ReplaceNewline struct{}
// ReplaceNewline.Execute: Splits the current line at the cursor (Enter key).
func (a ReplaceNewline) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Line(y)
if x == len(l) {
buf.InsertLine(y+1, "")
} else {
buf.SetLine(y, l[:x])
buf.InsertLine(y+1, l[x+1:])
}
win.SetCursorPos(y+1, 0)
return nil
}
// ReplaceTab inserts spaces equal to the tab size
type ReplaceTab struct{}
// ReplaceTab.Execute: Inserts spaces equal to the tab size (Tab key).
func (a ReplaceTab) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Line(y)
tabs := strings.Repeat(" ", m.Settings().TabStop)
if x < len(l) {
buf.SetLine(y, l[:x]+tabs+l[x+1:])
} else {
buf.SetLine(y, l+tabs)
}
win.SetCursorCol(x + len(tabs))
return nil
}

View File

@ -11,13 +11,15 @@ const (
VisualMode
VisualLineMode
VisualBlockMode
ReplaceMode
WaitingMode // Same as NORMAL output, but cursor is the REPLACE cursor
)
// Mode.ToString: Returns a human-readable string representation of the mode
// for display in the status bar.
func (m Mode) ToString() string {
switch m {
case NormalMode:
case NormalMode, WaitingMode:
return "NORMAL"
case InsertMode:
return "INSERT"
@ -29,6 +31,8 @@ func (m Mode) ToString() string {
return "V-LINE"
case VisualBlockMode:
return "V-BLOCK"
case ReplaceMode:
return "REPLACE"
default:
return "-----"
}

View File

@ -34,6 +34,16 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR})
case "ctrl+w":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW})
case "tab":
tm.Send(tea.KeyMsg{Type: tea.KeyTab})
case "left":
tm.Send(tea.KeyMsg{Type: tea.KeyLeft})
case "right":
tm.Send(tea.KeyMsg{Type: tea.KeyRight})
case "up":
tm.Send(tea.KeyMsg{Type: tea.KeyUp})
case "down":
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
default:
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
}

View File

@ -176,7 +176,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
want := len(lines[0]) - 1
if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
}
@ -188,7 +188,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
want := len(lines[0]) - 1
if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
}
@ -200,7 +200,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
want := len(lines[0]) - 1
if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
}

File diff suppressed because it is too large Load Diff

View File

@ -398,9 +398,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
}
// $ moves past end of line
if m.ActiveWindow().Cursor.Col != 11 {
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})

View File

@ -11,76 +11,7 @@ type ModelBuilder struct {
model Model
}
// RPGLE
// abap
// algol
// algol_nu
// arduino
// ashen
// aura-theme-dark
// aura-theme-dark-soft
// autumn
// average
// base16-snazzy
// borland
// bw
// catppuccin-frappe
// catppuccin-latte
// catppuccin-macchiato
// catppuccin-mocha
// colorful
// doom-one
// doom-one2
// dracula
// emacs
// evergarden
// friendly
// fruity
// github
// github-dark
// gruvbox
// gruvbox-light
// hr_high_contrast
// hrdark
// igor
// lovelace
// manni
// modus-operandi
// modus-vivendi
// monokai
// monokailight
// murphy
// native
// nord
// nordic
// onedark
// onesenterprise
// paraiso-dark
// paraiso-light
// pastie
// perldoc
// pygments
// rainbow_dash
// rose-pine
// rose-pine-dawn
// rose-pine-moon
// rrt
// solarized-dark
// solarized-dark256
// solarized-light
// swapoff
// tango
// tokyonight-day
// tokyonight-moon
// tokyonight-night
// tokyonight-storm
// trac
// vim
// vs
// vulcan
// witchhazel
// xcode
// xcode-dark
// NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave).
func NewModelBuilder() *ModelBuilder {
chromaStyle := styles.Get("kanagawa-wave")

View File

@ -41,6 +41,7 @@ type Handler struct {
normalKeymap *Keymap
visualKeymap *Keymap
insertKeymap *Keymap
replaceKeymap *Keymap
commandKeymap *Keymap
currentKeymap *Keymap
@ -53,6 +54,7 @@ func NewHandler() *Handler {
normalKeymap: NewNormalKeymap(),
visualKeymap: NewVisualKeymap(),
insertKeymap: NewInsertKeymap(),
replaceKeymap: NewReplaceKeymap(),
commandKeymap: NewCommandKeymap(),
currentKeymap: nil,
}
@ -77,7 +79,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
h.recordingKeys = []string{} // Clear recording on ESC
h.Reset()
if m.Mode() == core.InsertMode {
if m.Mode() == core.InsertMode || m.Mode() == core.ReplaceMode {
// Before exiting insert mode, end the block in the undo stack
win := m.ActiveWindow()
buf := m.ActiveBuffer()
@ -95,6 +97,8 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
switch m.Mode() {
case core.InsertMode:
return h.handleInsertKey(m, key)
case core.ReplaceMode:
return h.handleReplaceKey(m, key)
case core.CommandMode:
return h.handleCommandKey(m, key)
}
@ -172,6 +176,9 @@ 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" {
m.SetMode(core.WaitingMode)
}
h.charMotionType = key
h.state = StateWaitingForChar
return nil
@ -362,7 +369,11 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
// Apply count if supported
if r, ok := mot.(action.Repeatable); ok {
mot = r.WithCount(count).(action.Motion)
result := r.WithCount(count)
// WithCount returns Action, but char motions still implement Motion
if m, ok := result.(action.Motion); ok {
mot = m
}
}
// If operator pending (e.g., "df{char}"), get range and operate
@ -378,7 +389,14 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
// Otherwise just execute the motion
cmd := h.executeMotion(m, mot)
// ReplaceChar modifies the buffer, so it should be repeatable with '.'
// (unlike f/t/F/T which are pure motions)
if _, isReplace := mot.(action.ReplaceChar); isReplace {
h.RecordAndReset(m)
} else {
h.Reset()
}
return cmd
}
@ -556,6 +574,32 @@ func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
return action.InsertChar{Char: key}.Execute(m)
}
func (h *Handler) handleReplaceKey(m action.Model, key string) tea.Cmd {
buf := m.ActiveBuffer()
win := m.ActiveWindow()
// Start undo block on first insert key
if buf.UndoStack != nil && !buf.UndoStack.Recording() {
buf.UndoStack.BeginBlock(win.Cursor)
}
// Record the key for count replay (e.g. 5i...)
m.SetInsertKeys(append(m.InsertKeys(), key))
m.SetLastChangeKeys(append(m.LastChangeKeys(), key))
// Check the insert keymap first
kind, binding := h.replaceKeymap.Lookup(key)
switch kind {
case "action":
return binding.(action.Action).Execute(m)
case "motion":
return binding.(action.Motion).Execute(m)
}
// Fallback: treat as a regular character to "insert"
return action.ReplaceModeChar{Char: key}.Execute(m)
}
// Handler.handleCommandKey: Processes a keypress in command mode, executing
// it as an action or inserting it into the command line. This does not record
// anything into the undo stack.

View File

@ -77,12 +77,14 @@ func NewNormalKeymap() *Keymap {
"u": action.Undo{},
"ctrl+r": action.Redo{},
".": action.Repeat{Count: 1},
"R": action.EnterReplace{},
},
charMotions: map[string]action.Motion{
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
"F": action.FindChar{Forward: false, Inclusive: true, Repeated: false},
"t": action.FindChar{Forward: true, Inclusive: false, Repeated: false},
"T": action.FindChar{Forward: false, Inclusive: false, Repeated: false},
"r": action.ReplaceChar{Count: 1},
},
modifiers: map[string]any{
"i": nil,
@ -146,6 +148,8 @@ func NewVisualKeymap() *Keymap {
"X": operator.DeleteOperator{},
"y": operator.YankOperator{},
"c": operator.ChangeOperator{},
"s": operator.ChangeOperator{}, // Same as c in visual mode
"R": operator.ChangeOperator{}, // Seems to do the same thing
},
actions: map[string]action.Action{
"p": action.VisualPaste{Count: 1, Replace: true},
@ -202,7 +206,26 @@ func NewInsertKeymap() *Keymap {
"ctrl+w": action.InsertDeletePreviousWord{},
},
}
}
// NewReplaceKeymap: Creates a keymap for replace mode with editing actions. All actions
func NewReplaceKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"down": motion.MoveDown{Count: 1},
"up": motion.MoveUp{Count: 1},
"left": motion.MoveLeft{Count: 1},
"right": motion.MoveRight{Count: 1},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
"enter": action.ReplaceNewline{},
"backspace": action.InsertBackspace{},
"delete": action.InsertDelete{},
"tab": action.ReplaceTab{}, // TODO: This needs replacing
"ctrl+w": action.InsertDeletePreviousWord{},
},
}
}
// NewCommandKeymap: Creates a keymap for command mode with command line editing.

View File

@ -50,7 +50,7 @@ type MoveToLineEnd struct{}
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len())
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len() - 1)
return nil
}

View File

@ -15,6 +15,7 @@ type Styles struct {
CursorNormal lipgloss.Style
CursorInsert lipgloss.Style
CursorCommand lipgloss.Style
CursorReplace lipgloss.Style
// Gutter (line numbers)
Gutter lipgloss.Style
@ -47,6 +48,7 @@ func DefaultStyles() Styles {
CursorNormal: lipgloss.NewStyle().Reverse(true),
CursorInsert: lipgloss.NewStyle().Underline(true),
CursorCommand: lipgloss.NewStyle().Reverse(true),
CursorReplace: lipgloss.NewStyle().Underline(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
@ -95,12 +97,17 @@ func ChromaStyles(chromaStyle *chroma.Style) Styles {
CursorInsert: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Bold(true).
Underline(true),
CursorCommand: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Reverse(true),
CursorReplace: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Underline(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color(
darkenColor(lineNumbers.Background, 0.9).String()),
@ -163,6 +170,8 @@ func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
return s.CursorInsert
case core.CommandMode:
return s.CursorCommand
case core.ReplaceMode:
return s.CursorReplace
default:
return s.CursorNormal
}
@ -177,6 +186,11 @@ func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style
return lipgloss.NewStyle().
Background(style.GetForeground()).
Foreground(style.GetBackground())
case core.ReplaceMode, core.WaitingMode:
return lipgloss.NewStyle().
Background(style.GetBackground()).
Foreground(style.GetForeground()).
Underline(true)
default:
return lipgloss.NewStyle().
Background(s.BackgroundStyle.GetBackground()).