Merge pull request 'Implemented the replace (r and R) actions and replace mode' (#5) from feature/replace into master
All checks were successful
Run Test Suite / test (push) Successful in 16s
All checks were successful
Run Test Suite / test (push) Successful in 16s
Reviewed-on: #5
This commit is contained in:
commit
d6323be62b
@ -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
|
||||
|
||||
12
README.md
12
README.md
@ -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
129
internal/action/replace.go
Normal 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
|
||||
}
|
||||
@ -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 "-----"
|
||||
}
|
||||
|
||||
@ -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)})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
1040
internal/editor/integration_replace_test.go
Normal file
1040
internal/editor/integration_replace_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user