summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-26 22:13:11 +0200
committerPaul Buetow <paul@buetow.org>2026-03-26 22:13:11 +0200
commit1be48a6d1603ad9c4d9612432688d978be012fca (patch)
tree5a31deaf4def1fc0b340204d36c389e247a99fed
parentbc5668ba7bb011e1aa33e4a998d600c8c5ea68fa (diff)
task 85b801c7: add viinput motions
-rw-r--r--internal/viinput/edit.go58
-rw-r--r--internal/viinput/model.go184
-rw-r--r--internal/viinput/model_test.go133
-rw-r--r--internal/viinput/motion.go75
-rw-r--r--internal/viinput/motion_test.go81
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)
+ }
+ })
+ }
+}