diff --git a/FEATURES.md b/FEATURES.md index 61e7de5..261c4b2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/README.md b/README.md index a3df185..e2bb01b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/action/replace.go b/internal/action/replace.go index fd81e43..8fdd216 100644 --- a/internal/action/replace.go +++ b/internal/action/replace.go @@ -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 } diff --git a/internal/core/mode.go b/internal/core/mode.go index 24f8761..e679c28 100644 --- a/internal/core/mode.go +++ b/internal/core/mode.go @@ -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" diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index 187632d..873e6fa 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -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)}) } diff --git a/internal/editor/integration_replace_test.go b/internal/editor/integration_replace_test.go index 661d652..1b5d4d2 100644 --- a/internal/editor/integration_replace_test.go +++ b/internal/editor/integration_replace_test.go @@ -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()) + } + }) +} diff --git a/internal/editor/model_builder.go b/internal/editor/model_builder.go index ea7c435..d70c048 100644 --- a/internal/editor/model_builder.go +++ b/internal/editor/model_builder.go @@ -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") diff --git a/internal/input/handler.go b/internal/input/handler.go index 38e7071..5c574e9 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -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. diff --git a/internal/input/keymap.go b/internal/input/keymap.go index c0d76bc..a0569a7 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -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{}, }, } diff --git a/internal/style/style.go b/internal/style/style.go index f69725e..e5d026e 100755 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -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().