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
10 changed files with 603 additions and 86 deletions
Showing only changes of commit 43b3992522 - Show all commits

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,14 @@ 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.
---
## TODO List

View File

@ -1,6 +1,8 @@
package action
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
@ -61,12 +63,67 @@ func (a EnterReplace) WithCount(n int) Action {
}
func (a EnterReplace) Execute(m Model) tea.Cmd {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{"Replace mode (R) not implemented yet"},
Inline: true,
IsError: true,
})
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

@ -12,13 +12,14 @@ const (
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"

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

@ -6,6 +6,10 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// ==================================================
// Replace Char (r) Tests
// ==================================================
func TestReplaceChar(t *testing.T) {
t.Run("test 'rx' replaces character under cursor", func(t *testing.T) {
lines := []string{"hello"}
@ -564,3 +568,473 @@ func TestReplaceCharCombinations(t *testing.T) {
}
})
}
// ==================================================
// Replace Mode (R) Tests
// ==================================================
func TestReplaceModeEntry(t *testing.T) {
t.Run("test 'R' enters replace mode", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
m := getFinalModel(t, tm)
if m.Mode() != core.ReplaceMode {
t.Errorf("Mode() = %v, want ReplaceMode", m.Mode())
}
})
t.Run("test 'esc' exits replace mode to normal", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "x", "esc")
m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
}
})
}
func TestReplaceModeBasic(t *testing.T) {
t.Run("test single character overwrites", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "Xello" {
t.Errorf("lines[0] = %q, want 'Xello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple characters overwrite", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abclo" {
t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test typing past end of line appends", func(t *testing.T) {
lines := []string{"hi"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R")
sendKeyString(tm, "there")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hithere" {
t.Errorf("lines[0] = %q, want 'hithere'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode starting at end of line", func(t *testing.T) {
lines := []string{"hi"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R")
sendKeyString(tm, "!!")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hi!!" {
t.Errorf("lines[0] = %q, want 'hi!!'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode from middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R")
sendKeyString(tm, "XX")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "heXXo" {
t.Errorf("lines[0] = %q, want 'heXXo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test cursor position after exiting replace mode", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
// Cursor should be at last replaced character
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
}
func TestReplaceModeBackspace(t *testing.T) {
t.Run("test backspace deletes character", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ablo" {
t.Errorf("lines[0] = %q, want 'ablo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test backspace at start does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple backspaces", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "backspace", "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "alo" {
t.Errorf("lines[0] = %q, want 'alo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test backspace then type more", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "backspace")
sendKeyString(tm, "XY")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abXY" {
t.Errorf("lines[0] = %q, want 'abXY'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeNavigation(t *testing.T) {
t.Run("test right arrow moves cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "right", "right", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "heXlo" {
t.Errorf("lines[0] = %q, want 'heXlo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test left arrow moves cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "R", "left", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hXllo" {
t.Errorf("lines[0] = %q, want 'hXllo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test up/down arrows navigate between lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "down", "Y", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "Xello" {
t.Errorf("lines[0] = %q, want 'Xello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "wYrld" {
t.Errorf("lines[1] = %q, want 'wYrld'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test up arrow from first line stays on first line", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "up", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "Xello" {
t.Errorf("lines[0] = %q, want 'Xello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test down arrow from last line stays on last line", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "R", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1].String() != "Xorld" {
t.Errorf("lines[1] = %q, want 'Xorld'", m.ActiveBuffer().Lines[1].String())
}
})
}
func TestReplaceModeSpecialKeys(t *testing.T) {
t.Run("test enter splits line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "R", "enter", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test tab inserts tab character", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "tab", "esc")
m := getFinalModel(t, tm)
// Tab is expanded to spaces based on tabstop setting (default 2)
if m.ActiveBuffer().Lines[0].String() != " ello" {
t.Errorf("lines[0] = %q, want ' ello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test ctrl+w deletes previous word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "foo bar")
sendKeys(tm, "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "foo orld" {
t.Errorf("lines[0] = %q, want 'foo orld'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test ctrl+w at beginning does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test delete key removes character ahead", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "delete", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeUndo(t *testing.T) {
t.Run("test replace mode changes can be undone", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc", "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode changes can be redone", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc", "u", "ctrl+r")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abclo" {
t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple undo operations", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc")
sendKeys(tm, "l", "R")
sendKeyString(tm, "XY")
sendKeys(tm, "esc", "u")
m := getFinalModel(t, tm)
// Only the second replace should be undone
if m.ActiveBuffer().Lines[0].String() != "abclo" {
t.Errorf("lines[0] = %q, want 'abclo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeEdgeCases(t *testing.T) {
t.Run("test replace mode on empty line", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "test")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "test" {
t.Errorf("lines[0] = %q, want 'test'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode on different lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "R", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "Xorld" {
t.Errorf("lines[1] = %q, want 'Xorld'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test overwriting tab character", func(t *testing.T) {
lines := []string{"a\tb"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "R", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "aXb" {
t.Errorf("lines[0] = %q, want 'aXb'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode with whitespace characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "a b")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "a blo" {
t.Errorf("lines[0] = %q, want 'a blo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode on single character line", func(t *testing.T) {
lines := []string{"a"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "X" {
t.Errorf("lines[0] = %q, want 'X'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test replace mode preserves other lines", func(t *testing.T) {
lines := []string{"first", "second", "third"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "R")
sendKeyString(tm, "XXX")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "first" {
t.Errorf("lines[0] = %q, want 'first'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "XXXond" {
t.Errorf("lines[1] = %q, want 'XXXond'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2].String() != "third" {
t.Errorf("lines[2] = %q, want 'third'", m.ActiveBuffer().Lines[2].String())
}
})
t.Run("test replace mode with special characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "@#$")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "@#$lo" {
t.Errorf("lines[0] = %q, want '@#$lo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestReplaceModeRepeat(t *testing.T) {
t.Run("test replace mode can be repeated with '.'", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "XX")
sendKeys(tm, "esc", "j", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "XXllo" {
t.Errorf("lines[0] = %q, want 'XXllo'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "wXXld" {
t.Errorf("lines[1] = %q, want 'wXXld'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test dot repeats last replace operation", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R")
sendKeyString(tm, "abc")
sendKeys(tm, "esc", "w", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abclo abcld" {
t.Errorf("lines[0] = %q, want 'abclo abcld'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test dot after navigation", func(t *testing.T) {
lines := []string{"aaa", "bbb", "ccc"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "R", "X", "esc", "j", "j", ".")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "Xaa" {
t.Errorf("lines[0] = %q, want 'Xaa'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[2].String() != "Xcc" {
t.Errorf("lines[2] = %q, want 'Xcc'", m.ActiveBuffer().Lines[2].String())
}
})
}

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)
}
@ -173,7 +177,7 @@ func (h *Handler) dispatch(m action.Model, kind string, binding any, key string)
// Handle character motions (f/t/F/T) - transition to waiting state
if kind == "char_motion" {
if key == "r" {
m.SetMode(core.ReplaceMode)
m.SetMode(core.WaitingMode)
}
h.charMotionType = key
h.state = StateWaitingForChar
@ -570,6 +574,34 @@ 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)
}
// TODO: Handle differently
// 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

@ -148,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},
@ -206,7 +208,7 @@ func NewInsertKeymap() *Keymap {
}
}
// NewReplaceKeymap: Creates a keymap for replace mode with editing actions.
// NewReplaceKeymap: Creates a keymap for replace mode with editing actions. All actions
func NewReplaceKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
@ -217,10 +219,10 @@ func NewReplaceKeymap() *Keymap {
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
"enter": action.InsertNewline{},
"enter": action.ReplaceNewline{},
"backspace": action.InsertBackspace{},
"delete": action.InsertDelete{},
"tab": action.InsertTab{},
"tab": action.ReplaceTab{}, // TODO: This needs replacing
"ctrl+w": action.InsertDeletePreviousWord{},
},
}

View File

@ -186,8 +186,10 @@ func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style
return lipgloss.NewStyle().
Background(style.GetForeground()).
Foreground(style.GetBackground())
case core.ReplaceMode:
case core.ReplaceMode, core.WaitingMode:
return lipgloss.NewStyle().
Background(style.GetBackground()).
Foreground(style.GetForeground()).
Underline(true)
default:
return lipgloss.NewStyle().