summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-26 22:16:12 +0200
committerPaul Buetow <paul@buetow.org>2026-03-26 22:16:12 +0200
commit40468f2a49a707859e5e71692e4e04b22f02e548 (patch)
tree476eb01c10ace8783d7f99762a11a484d8ba27bf
parent1be48a6d1603ad9c4d9612432688d978be012fca (diff)
902247e9-2355-4bc9-bf5e-dc41e101f6aa viinput delete/change and undo
-rw-r--r--internal/viinput/edit.go61
-rw-r--r--internal/viinput/model.go53
-rw-r--r--internal/viinput/model_test.go164
3 files changed, 258 insertions, 20 deletions
diff --git a/internal/viinput/edit.go b/internal/viinput/edit.go
index 1f89e62..98a6be1 100644
--- a/internal/viinput/edit.go
+++ b/internal/viinput/edit.go
@@ -40,20 +40,65 @@ func (m *Model) insertText(text string) {
}
func (m *Model) deleteBeforeCursor() {
- if m.cursor <= 0 || len(m.runes) == 0 {
- return
+ m.deleteRange(m.cursor-1, m.cursor)
+}
+
+func (m *Model) deleteAtCursor() {
+ m.deleteRange(m.cursor, m.cursor+1)
+}
+
+func (m *Model) deleteLine() {
+ m.deleteRange(0, len(m.runes))
+}
+
+func (m *Model) deleteToLineEnd() {
+ m.deleteRange(m.cursor, len(m.runes))
+}
+
+func (m *Model) deleteFromLineStart() {
+ m.deleteRange(0, m.cursor)
+}
+
+func (m *Model) deleteWordForward() {
+ m.deleteRange(m.cursor, wordForward(m.runes, m.cursor))
+}
+
+func (m *Model) deleteWordEnd() {
+ end := wordEnd(m.runes, m.cursor)
+ if end < len(m.runes) {
+ end++
}
+ m.deleteRange(m.cursor, end)
+}
- m.snapshot()
- m.runes = append(append([]rune(nil), m.runes[:m.cursor-1]...), m.runes[m.cursor:]...)
- m.cursor--
+func (m *Model) deleteWordBackward() {
+ m.deleteRange(wordBackward(m.runes, m.cursor), m.cursor)
}
-func (m *Model) deleteAtCursor() {
- if len(m.runes) == 0 || m.cursor >= len(m.runes) {
+func (m *Model) changeToLineEnd() {
+ m.deleteToLineEnd()
+ m.mode = ModeInsert
+ m.pending = 0
+}
+
+func (m *Model) deleteRange(start, end int) {
+ if len(m.runes) == 0 {
+ return
+ }
+
+ start = clampInt(start, 0, len(m.runes))
+ end = clampInt(end, 0, len(m.runes))
+ if start > end {
+ start, end = end, start
+ }
+ if start == end {
return
}
m.snapshot()
- m.runes = append(append([]rune(nil), m.runes[:m.cursor]...), m.runes[m.cursor+1:]...)
+ next := make([]rune, 0, len(m.runes)-(end-start))
+ next = append(next, m.runes[:start]...)
+ next = append(next, m.runes[end:]...)
+ m.runes = next
+ m.cursor = start
}
diff --git a/internal/viinput/model.go b/internal/viinput/model.go
index 6fd895a..c0c80ac 100644
--- a/internal/viinput/model.go
+++ b/internal/viinput/model.go
@@ -80,19 +80,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
if m.mode == ModeNormal && m.pending != 0 {
- if m.pending == 'g' {
- switch keyMsg.String() {
- case "g", "h":
- m.cursor = 0
- m.pending = 0
- return m, nil
- case "l":
- m.cursor = len(m.runes)
- m.pending = 0
- return m, nil
- }
- }
+ m.handlePendingNormal(keyMsg)
m.pending = 0
+ return m, nil
}
switch m.mode {
@@ -169,6 +159,8 @@ func (m Model) updateNormalMode(keyMsg tea.KeyPressMsg) (Model, tea.Cmd) {
m.cursor = len(m.runes)
case "g":
m.pending = 'g'
+ case "d":
+ m.pending = 'd'
case "i":
m.mode = ModeInsert
case "a":
@@ -180,6 +172,16 @@ func (m Model) updateNormalMode(keyMsg tea.KeyPressMsg) (Model, tea.Cmd) {
case "A":
m.cursor = len(m.runes)
m.mode = ModeInsert
+ case "x":
+ m.deleteAtCursor()
+ case "X":
+ m.deleteBeforeCursor()
+ case "D":
+ m.deleteToLineEnd()
+ case "C":
+ m.changeToLineEnd()
+ case "u":
+ m.undo()
default:
m.pending = 0
}
@@ -187,6 +189,33 @@ func (m Model) updateNormalMode(keyMsg tea.KeyPressMsg) (Model, tea.Cmd) {
return m, nil
}
+func (m *Model) handlePendingNormal(keyMsg tea.KeyPressMsg) {
+ switch m.pending {
+ case 'g':
+ switch keyMsg.String() {
+ case "g", "h":
+ m.cursor = 0
+ case "l":
+ m.cursor = len(m.runes)
+ }
+ case 'd':
+ switch keyMsg.String() {
+ case "d":
+ m.deleteLine()
+ case "w":
+ m.deleteWordForward()
+ case "e":
+ m.deleteWordEnd()
+ case "b":
+ m.deleteWordBackward()
+ case "0":
+ m.deleteFromLineStart()
+ case "$":
+ m.deleteToLineEnd()
+ }
+ }
+}
+
func insertedText(keyMsg tea.KeyPressMsg) (string, bool) {
text := keyMsg.Text
if text != "" {
diff --git a/internal/viinput/model_test.go b/internal/viinput/model_test.go
index 9289854..dbbfe4b 100644
--- a/internal/viinput/model_test.go
+++ b/internal/viinput/model_test.go
@@ -124,6 +124,170 @@ func TestModelInsertModeEditing(t *testing.T) {
}
}
+func TestModelNormalModeDeletes(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ value string
+ cursor int
+ steps []string
+ wantValue string
+ wantCursor int
+ wantMode Mode
+ }{
+ {
+ name: "x deletes character at cursor",
+ value: "alpha beta",
+ cursor: 0,
+ steps: []string{"x"},
+ wantValue: "lpha beta",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "X deletes character before cursor",
+ value: "alpha beta",
+ cursor: 1,
+ steps: []string{"X"},
+ wantValue: "lpha beta",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "D deletes to line end",
+ value: "alpha beta",
+ cursor: 6,
+ steps: []string{"D"},
+ wantValue: "alpha ",
+ wantCursor: 6,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "dd clears line",
+ value: "alpha beta",
+ cursor: 4,
+ steps: []string{"d", "d"},
+ wantValue: "",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "dw deletes forward by word",
+ value: "alpha beta",
+ cursor: 0,
+ steps: []string{"d", "w"},
+ wantValue: "beta",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "de deletes to word end",
+ value: "alpha beta",
+ cursor: 0,
+ steps: []string{"d", "e"},
+ wantValue: " beta",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "db deletes backward by word",
+ value: "alpha beta",
+ cursor: 6,
+ steps: []string{"d", "b"},
+ wantValue: "beta",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "d0 deletes from line start",
+ value: "alpha beta",
+ cursor: 6,
+ steps: []string{"d", "0"},
+ wantValue: "beta",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "d$ deletes from cursor to line end",
+ value: "alpha beta",
+ cursor: 0,
+ steps: []string{"d", "$"},
+ wantValue: "",
+ wantCursor: 0,
+ wantMode: ModeNormal,
+ },
+ {
+ name: "C deletes to line end and enters insert mode",
+ value: "alpha beta",
+ cursor: 6,
+ steps: []string{"C"},
+ wantValue: "alpha ",
+ wantCursor: 6,
+ wantMode: ModeInsert,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ model := New()
+ model.Focus()
+ model.SetValue(tt.value)
+ model.mode = ModeNormal
+ model.cursor = tt.cursor
+
+ for _, step := range tt.steps {
+ model, _ = model.Update(key(step))
+ }
+
+ if got := model.Value(); got != tt.wantValue {
+ t.Fatalf("value = %q, want %q", got, tt.wantValue)
+ }
+ if got := model.cursor; got != tt.wantCursor {
+ t.Fatalf("cursor = %d, want %d", got, tt.wantCursor)
+ }
+ if got := model.Mode(); got != tt.wantMode {
+ t.Fatalf("mode = %v, want %v", got, tt.wantMode)
+ }
+ })
+ }
+}
+
+func TestModelUndoRestoresPriorState(t *testing.T) {
+ t.Parallel()
+
+ model := New()
+ model.Focus()
+ model.SetValue("alpha beta")
+ model.mode = ModeNormal
+ model.cursor = 0
+
+ model, _ = model.Update(key("x"))
+ model, _ = model.Update(key("u"))
+ if got := model.Value(); got != "alpha beta" {
+ t.Fatalf("undo after x value = %q, want %q", got, "alpha beta")
+ }
+ if got := model.cursor; got != 0 {
+ t.Fatalf("undo after x cursor = %d, want 0", got)
+ }
+
+ model, _ = model.Update(key("d"))
+ model, _ = model.Update(key("w"))
+ if got := model.Value(); got != "beta" {
+ t.Fatalf("dw value = %q, want %q", got, "beta")
+ }
+
+ model, _ = model.Update(key("u"))
+ if got := model.Value(); got != "alpha beta" {
+ t.Fatalf("undo after dw value = %q, want %q", got, "alpha beta")
+ }
+ if got := model.cursor; got != 0 {
+ t.Fatalf("undo after dw cursor = %d, want 0", got)
+ }
+}
+
func key(value string) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: 0, Text: value}
}