diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-26 22:13:11 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-26 22:13:11 +0200 |
| commit | 1be48a6d1603ad9c4d9612432688d978be012fca (patch) | |
| tree | 5a31deaf4def1fc0b340204d36c389e247a99fed /internal/viinput | |
| parent | bc5668ba7bb011e1aa33e4a998d600c8c5ea68fa (diff) | |
task 85b801c7: add viinput motions
Diffstat (limited to 'internal/viinput')
| -rw-r--r-- | internal/viinput/edit.go | 58 | ||||
| -rw-r--r-- | internal/viinput/model.go | 184 | ||||
| -rw-r--r-- | internal/viinput/model_test.go | 133 | ||||
| -rw-r--r-- | internal/viinput/motion.go | 75 | ||||
| -rw-r--r-- | internal/viinput/motion_test.go | 81 |
5 files changed, 529 insertions, 2 deletions
diff --git a/internal/viinput/edit.go b/internal/viinput/edit.go index 375c397..1f89e62 100644 --- a/internal/viinput/edit.go +++ b/internal/viinput/edit.go @@ -1,3 +1,59 @@ package viinput -// Editing helpers will live here in a later task. +func (m *Model) snapshot() { + history := make([]rune, len(m.runes)) + copy(history, m.runes) + m.history = append(m.history, history) +} + +func (m *Model) undo() { + if len(m.history) == 0 { + return + } + + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.runes = append([]rune(nil), last...) + m.cursor = clampInt(m.cursor, 0, len(m.runes)) +} + +func (m *Model) insertText(text string) { + if text == "" { + return + } + + m.snapshot() + runes := []rune(text) + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor > len(m.runes) { + m.cursor = len(m.runes) + } + + next := make([]rune, 0, len(m.runes)+len(runes)) + next = append(next, m.runes[:m.cursor]...) + next = append(next, runes...) + next = append(next, m.runes[m.cursor:]...) + m.runes = next + m.cursor += len(runes) +} + +func (m *Model) deleteBeforeCursor() { + if m.cursor <= 0 || len(m.runes) == 0 { + return + } + + m.snapshot() + m.runes = append(append([]rune(nil), m.runes[:m.cursor-1]...), m.runes[m.cursor:]...) + m.cursor-- +} + +func (m *Model) deleteAtCursor() { + if len(m.runes) == 0 || m.cursor >= len(m.runes) { + return + } + + m.snapshot() + m.runes = append(append([]rune(nil), m.runes[:m.cursor]...), m.runes[m.cursor+1:]...) +} diff --git a/internal/viinput/model.go b/internal/viinput/model.go index 78e00f3..6fd895a 100644 --- a/internal/viinput/model.go +++ b/internal/viinput/model.go @@ -1,5 +1,11 @@ package viinput +import ( + "strings" + + tea "charm.land/bubbletea/v2" +) + // Mode represents the current vi-style input state. type Mode int @@ -26,3 +32,181 @@ type Model struct { func New() Model { return Model{mode: ModeInsert} } + +// Focus marks the model as active and returns a no-op command. +func (m *Model) Focus() tea.Cmd { + m.focused = true + m.mode = ModeInsert + m.pending = 0 + m.wantsExit = false + return nil +} + +// Blur marks the model as inactive. +func (m *Model) Blur() { + m.focused = false + m.pending = 0 + m.wantsExit = false +} + +// SetValue replaces the current buffer contents. +func (m *Model) SetValue(value string) { + m.runes = []rune(value) + m.cursor = len(m.runes) + m.pending = 0 + m.wantsExit = false +} + +// Value returns the current buffer contents. +func (m Model) Value() string { + return string(m.runes) +} + +// Mode returns the current editing mode. +func (m Model) Mode() Mode { + return m.mode +} + +// WantsExit reports whether normal mode requested that editing end. +func (m Model) WantsExit() bool { + return m.wantsExit +} + +// Update applies a key event to the model. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok || !m.focused { + return m, nil + } + + 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.pending = 0 + } + + switch m.mode { + case ModeInsert: + return m.updateInsertMode(keyMsg) + case ModeNormal: + return m.updateNormalMode(keyMsg) + default: + return m, nil + } +} + +// View renders the prompt, value and cursor. +func (m Model) View() string { + var builder strings.Builder + builder.WriteString(m.Prompt) + + cursorRune := "█" + if m.mode == ModeInsert { + cursorRune = "▏" + } + + cursor := clampInt(m.cursor, 0, len(m.runes)) + builder.WriteString(string(m.runes[:cursor])) + builder.WriteString(cursorRune) + builder.WriteString(string(m.runes[cursor:])) + return builder.String() +} + +func (m Model) updateInsertMode(keyMsg tea.KeyPressMsg) (Model, tea.Cmd) { + switch keyMsg.String() { + case "esc": + m.mode = ModeNormal + m.pending = 0 + case "left": + m.cursor = clampInt(m.cursor-1, 0, len(m.runes)) + case "right": + m.cursor = clampInt(m.cursor+1, 0, len(m.runes)) + case "home", "ctrl+a": + m.cursor = 0 + case "end", "ctrl+e": + m.cursor = len(m.runes) + case "backspace", "ctrl+h": + m.deleteBeforeCursor() + case "delete", "ctrl+d": + m.deleteAtCursor() + default: + if text, ok := insertedText(keyMsg); ok { + m.insertText(text) + } + } + + return m, nil +} + +func (m Model) updateNormalMode(keyMsg tea.KeyPressMsg) (Model, tea.Cmd) { + switch keyMsg.String() { + case "esc": + m.wantsExit = true + return m, nil + case "h", "left": + m.cursor = clampInt(m.cursor-1, 0, len(m.runes)) + case "l", "right": + m.cursor = clampInt(m.cursor+1, 0, len(m.runes)) + case "w": + m.cursor = wordForward(m.runes, m.cursor) + case "b": + m.cursor = wordBackward(m.runes, m.cursor) + case "e": + m.cursor = wordEnd(m.runes, m.cursor) + case "0": + m.cursor = 0 + case "$": + m.cursor = len(m.runes) + case "g": + m.pending = 'g' + case "i": + m.mode = ModeInsert + case "a": + m.cursor = clampInt(m.cursor+1, 0, len(m.runes)) + m.mode = ModeInsert + case "I": + m.cursor = 0 + m.mode = ModeInsert + case "A": + m.cursor = len(m.runes) + m.mode = ModeInsert + default: + m.pending = 0 + } + + return m, nil +} + +func insertedText(keyMsg tea.KeyPressMsg) (string, bool) { + text := keyMsg.Text + if text != "" { + return text, true + } + + value := keyMsg.String() + if len([]rune(value)) == 1 { + return value, true + } + + return "", false +} + +func clampInt(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/internal/viinput/model_test.go b/internal/viinput/model_test.go new file mode 100644 index 0000000..9289854 --- /dev/null +++ b/internal/viinput/model_test.go @@ -0,0 +1,133 @@ +package viinput + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestModelNormalModeMotionDispatch(t *testing.T) { + t.Parallel() + + model := New() + model.Focus() + model.SetValue("alpha beta") + model.mode = ModeNormal + model.cursor = 0 + + model, _ = model.Update(key("w")) + if got := model.cursor; got != 6 { + t.Fatalf("w cursor = %d, want 6", got) + } + + model, _ = model.Update(key("b")) + if got := model.cursor; got != 0 { + t.Fatalf("b cursor = %d, want 0", got) + } + + model, _ = model.Update(key("e")) + if got := model.cursor; got != 4 { + t.Fatalf("e cursor = %d, want 4", got) + } + + model, _ = model.Update(key("0")) + if got := model.cursor; got != 0 { + t.Fatalf("0 cursor = %d, want 0", got) + } + + model, _ = model.Update(key("$")) + if got := model.cursor; got != len(model.runes) { + t.Fatalf("$ cursor = %d, want %d", got, len(model.runes)) + } +} + +func TestModelHelixPendingG(t *testing.T) { + t.Parallel() + + model := New() + model.Focus() + model.SetValue("alpha beta") + model.mode = ModeNormal + model.cursor = 5 + + model, _ = model.Update(key("g")) + if got := model.pending; got != 'g' { + t.Fatalf("pending = %q, want 'g'", got) + } + + model, _ = model.Update(key("h")) + if got := model.cursor; got != 0 { + t.Fatalf("gh cursor = %d, want 0", got) + } + + model.cursor = 3 + model, _ = model.Update(key("g")) + model, _ = model.Update(key("l")) + if got := model.cursor; got != len(model.runes) { + t.Fatalf("gl cursor = %d, want %d", got, len(model.runes)) + } +} + +func TestModelModeTransitions(t *testing.T) { + t.Parallel() + + model := New() + model.Focus() + model.SetValue("abc") + + model, _ = model.Update(special(tea.KeyEscape)) + if got := model.Mode(); got != ModeNormal { + t.Fatalf("mode = %v, want ModeNormal", got) + } + if model.WantsExit() { + t.Fatal("esc in insert mode should not request exit") + } + + model, _ = model.Update(key("i")) + if got := model.Mode(); got != ModeInsert { + t.Fatalf("mode = %v, want ModeInsert", got) + } + + model, _ = model.Update(special(tea.KeyEscape)) + model, _ = model.Update(special(tea.KeyEscape)) + if !model.WantsExit() { + t.Fatal("esc in normal mode should request exit") + } +} + +func TestModelInsertModeEditing(t *testing.T) { + t.Parallel() + + model := New() + model.Focus() + model.SetValue("abc") + + model.cursor = 2 + model, _ = model.Update(special(tea.KeyBackspace)) + if got := model.Value(); got != "ac" { + t.Fatalf("value = %q, want %q", got, "ac") + } + + model, _ = model.Update(special(tea.KeyEnd)) + model, _ = model.Update(key("d")) + if got := model.Value(); got != "acd" { + t.Fatalf("value = %q, want %q", got, "acd") + } + + other := New() + other.Focus() + other.SetValue("abc") + other.cursor = 1 + other, _ = other.Update(key("h")) + if got := other.Value(); got != "ahbc" { + t.Fatalf("insert-mode h value = %q, want %q", got, "ahbc") + } +} + +func key(value string) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: 0, Text: value} +} + +func special(code rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: code} +} diff --git a/internal/viinput/motion.go b/internal/viinput/motion.go index 43c8279..b6577e3 100644 --- a/internal/viinput/motion.go +++ b/internal/viinput/motion.go @@ -1,3 +1,76 @@ package viinput -// Motion helpers will live here in a later task. +import "unicode" + +func isWordChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' +} + +func wordForward(runes []rune, cursor int) int { + if len(runes) == 0 { + return 0 + } + if cursor < 0 { + cursor = 0 + } + if cursor >= len(runes) { + return len(runes) + } + + pos := cursor + if isWordChar(runes[pos]) { + for pos < len(runes) && isWordChar(runes[pos]) { + pos++ + } + } else { + for pos < len(runes) && !isWordChar(runes[pos]) { + pos++ + } + } + for pos < len(runes) && !isWordChar(runes[pos]) { + pos++ + } + return pos +} + +func wordBackward(runes []rune, cursor int) int { + if len(runes) == 0 || cursor <= 0 { + return 0 + } + if cursor > len(runes) { + cursor = len(runes) + } + + pos := cursor - 1 + for pos >= 0 && !isWordChar(runes[pos]) { + pos-- + } + for pos >= 0 && isWordChar(runes[pos]) { + pos-- + } + return pos + 1 +} + +func wordEnd(runes []rune, cursor int) int { + if len(runes) == 0 { + return 0 + } + if cursor < 0 { + cursor = 0 + } + if cursor >= len(runes) { + return len(runes) + } + + pos := cursor + for pos < len(runes) && !isWordChar(runes[pos]) { + pos++ + } + if pos >= len(runes) { + return len(runes) + } + for pos+1 < len(runes) && isWordChar(runes[pos+1]) { + pos++ + } + return pos +} diff --git a/internal/viinput/motion_test.go b/internal/viinput/motion_test.go new file mode 100644 index 0000000..b2cd952 --- /dev/null +++ b/internal/viinput/motion_test.go @@ -0,0 +1,81 @@ +package viinput + +import "testing" + +func TestWordForward(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + cursor int + want int + }{ + {name: "empty", input: "", cursor: 0, want: 0}, + {name: "within word", input: "alpha beta", cursor: 0, want: 6}, + {name: "from whitespace", input: "alpha beta", cursor: 5, want: 6}, + {name: "from punctuation", input: "alpha, beta", cursor: 5, want: 7}, + {name: "at end", input: "alpha beta", cursor: 10, want: 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := wordForward([]rune(tt.input), tt.cursor); got != tt.want { + t.Fatalf("wordForward(%q, %d) = %d, want %d", tt.input, tt.cursor, got, tt.want) + } + }) + } +} + +func TestWordBackward(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + cursor int + want int + }{ + {name: "empty", input: "", cursor: 0, want: 0}, + {name: "from middle of next word", input: "alpha beta", cursor: 7, want: 6}, + {name: "from whitespace", input: "alpha beta", cursor: 6, want: 0}, + {name: "from end", input: "alpha beta", cursor: 10, want: 6}, + {name: "single word", input: "alpha", cursor: 5, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := wordBackward([]rune(tt.input), tt.cursor); got != tt.want { + t.Fatalf("wordBackward(%q, %d) = %d, want %d", tt.input, tt.cursor, got, tt.want) + } + }) + } +} + +func TestWordEnd(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + cursor int + want int + }{ + {name: "empty", input: "", cursor: 0, want: 0}, + {name: "within word", input: "alpha beta", cursor: 0, want: 4}, + {name: "from whitespace", input: "alpha beta", cursor: 5, want: 9}, + {name: "from punctuation", input: "alpha, beta", cursor: 5, want: 10}, + {name: "at end", input: "alpha beta", cursor: 10, want: 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := wordEnd([]rune(tt.input), tt.cursor); got != tt.want { + t.Fatalf("wordEnd(%q, %d) = %d, want %d", tt.input, tt.cursor, got, tt.want) + } + }) + } +} |
