feat: implemented replace mode, tested!
All checks were successful
Run Test Suite / test (push) Successful in 15s
Run Test Suite / test (pull_request) Successful in 15s

Looking great, maybe I will actually use this lol
This commit is contained in:
Hayden Hargreaves 2026-04-05 23:53:24 -07:00
parent 6033e58d0e
commit 43b3992522
10 changed files with 603 additions and 86 deletions

View File

@ -133,8 +133,8 @@
- [ ] `U` - Undo all changes on line - [ ] `U` - Undo all changes on line
### Other Normal Mode ### Other Normal Mode
- [ ] `r{char}` - Replace character - [x] `r{char}` - Replace character
- [ ] `R` - Replace mode - [x] `R` - Replace mode
- [ ] `~` - Swap case of character - [ ] `~` - Swap case of character
- [ ] `ctrl+a` - Increment number - [ ] `ctrl+a` - Increment number
- [ ] `ctrl+x` - Decrement 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 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. 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 ## TODO List

View File

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

View File

@ -12,13 +12,14 @@ const (
VisualLineMode VisualLineMode
VisualBlockMode VisualBlockMode
ReplaceMode ReplaceMode
WaitingMode // Same as NORMAL output, but cursor is the REPLACE cursor
) )
// Mode.ToString: Returns a human-readable string representation of the mode // Mode.ToString: Returns a human-readable string representation of the mode
// for display in the status bar. // for display in the status bar.
func (m Mode) ToString() string { func (m Mode) ToString() string {
switch m { switch m {
case NormalMode: case NormalMode, WaitingMode:
return "NORMAL" return "NORMAL"
case InsertMode: case InsertMode:
return "INSERT" return "INSERT"

View File

@ -34,6 +34,16 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR}) tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR})
case "ctrl+w": case "ctrl+w":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW}) 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: default:
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
} }

View File

@ -6,6 +6,10 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// ==================================================
// Replace Char (r) Tests
// ==================================================
func TestReplaceChar(t *testing.T) { func TestReplaceChar(t *testing.T) {
t.Run("test 'rx' replaces character under cursor", func(t *testing.T) { t.Run("test 'rx' replaces character under cursor", func(t *testing.T) {
lines := []string{"hello"} 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 model Model
} }
// RPGLE // NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave).
// 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
func NewModelBuilder() *ModelBuilder { func NewModelBuilder() *ModelBuilder {
chromaStyle := styles.Get("kanagawa-wave") chromaStyle := styles.Get("kanagawa-wave")

View File

@ -41,6 +41,7 @@ type Handler struct {
normalKeymap *Keymap normalKeymap *Keymap
visualKeymap *Keymap visualKeymap *Keymap
insertKeymap *Keymap insertKeymap *Keymap
replaceKeymap *Keymap
commandKeymap *Keymap commandKeymap *Keymap
currentKeymap *Keymap currentKeymap *Keymap
@ -53,6 +54,7 @@ func NewHandler() *Handler {
normalKeymap: NewNormalKeymap(), normalKeymap: NewNormalKeymap(),
visualKeymap: NewVisualKeymap(), visualKeymap: NewVisualKeymap(),
insertKeymap: NewInsertKeymap(), insertKeymap: NewInsertKeymap(),
replaceKeymap: NewReplaceKeymap(),
commandKeymap: NewCommandKeymap(), commandKeymap: NewCommandKeymap(),
currentKeymap: nil, 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.recordingKeys = []string{} // Clear recording on ESC
h.Reset() 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 // Before exiting insert mode, end the block in the undo stack
win := m.ActiveWindow() win := m.ActiveWindow()
buf := m.ActiveBuffer() buf := m.ActiveBuffer()
@ -95,6 +97,8 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
switch m.Mode() { switch m.Mode() {
case core.InsertMode: case core.InsertMode:
return h.handleInsertKey(m, key) return h.handleInsertKey(m, key)
case core.ReplaceMode:
return h.handleReplaceKey(m, key)
case core.CommandMode: case core.CommandMode:
return h.handleCommandKey(m, key) 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 // Handle character motions (f/t/F/T) - transition to waiting state
if kind == "char_motion" { if kind == "char_motion" {
if key == "r" { if key == "r" {
m.SetMode(core.ReplaceMode) m.SetMode(core.WaitingMode)
} }
h.charMotionType = key h.charMotionType = key
h.state = StateWaitingForChar 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) 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 // Handler.handleCommandKey: Processes a keypress in command mode, executing
// it as an action or inserting it into the command line. This does not record // it as an action or inserting it into the command line. This does not record
// anything into the undo stack. // anything into the undo stack.

View File

@ -148,6 +148,8 @@ func NewVisualKeymap() *Keymap {
"X": operator.DeleteOperator{}, "X": operator.DeleteOperator{},
"y": operator.YankOperator{}, "y": operator.YankOperator{},
"c": operator.ChangeOperator{}, "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{ actions: map[string]action.Action{
"p": action.VisualPaste{Count: 1, Replace: true}, "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 { func NewReplaceKeymap() *Keymap {
return &Keymap{ return &Keymap{
motions: map[string]action.Motion{ motions: map[string]action.Motion{
@ -217,10 +219,10 @@ func NewReplaceKeymap() *Keymap {
}, },
operators: map[string]action.Operator{}, // this will likely be empty operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{ actions: map[string]action.Action{
"enter": action.InsertNewline{}, "enter": action.ReplaceNewline{},
"backspace": action.InsertBackspace{}, "backspace": action.InsertBackspace{},
"delete": action.InsertDelete{}, "delete": action.InsertDelete{},
"tab": action.InsertTab{}, "tab": action.ReplaceTab{}, // TODO: This needs replacing
"ctrl+w": action.InsertDeletePreviousWord{}, "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(). return lipgloss.NewStyle().
Background(style.GetForeground()). Background(style.GetForeground()).
Foreground(style.GetBackground()) Foreground(style.GetBackground())
case core.ReplaceMode: case core.ReplaceMode, core.WaitingMode:
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Background(style.GetBackground()).
Foreground(style.GetForeground()).
Underline(true) Underline(true)
default: default:
return lipgloss.NewStyle(). return lipgloss.NewStyle().