diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-26 22:16:12 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-26 22:16:12 +0200 |
| commit | 40468f2a49a707859e5e71692e4e04b22f02e548 (patch) | |
| tree | 476eb01c10ace8783d7f99762a11a484d8ba27bf | |
| parent | 1be48a6d1603ad9c4d9612432688d978be012fca (diff) | |
902247e9-2355-4bc9-bf5e-dc41e101f6aa viinput delete/change and undo
| -rw-r--r-- | internal/viinput/edit.go | 61 | ||||
| -rw-r--r-- | internal/viinput/model.go | 53 | ||||
| -rw-r--r-- | internal/viinput/model_test.go | 164 |
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} } |
