feat: implemented replace mode, tested!
Looking great, maybe I will actually use this lol
This commit is contained in:
parent
6033e58d0e
commit
43b3992522
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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().
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user