From 39c4bf1b6bb31dabb650ad731a64dbde45d0551a Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Thu, 12 Feb 2026 17:30:06 -0700 Subject: [PATCH] feat: implemented insert mode keymaps and ctrl+w, tested --- cmd/gim/main.go | 2 +- internal/action/action.go | 10 ++ internal/action/insert.go | 157 ++++++++++++++++++- internal/editor/helpers_test.go | 2 + internal/editor/integration_insert_test.go | 171 +++++++++++++++++++++ internal/editor/model.go | 37 +++-- internal/editor/update.go | 29 +--- internal/input/handler.go | 32 +++- internal/input/keymap.go | 21 +++ 9 files changed, 421 insertions(+), 40 deletions(-) diff --git a/cmd/gim/main.go b/cmd/gim/main.go index 664b95f..8b427cb 100644 --- a/cmd/gim/main.go +++ b/cmd/gim/main.go @@ -8,7 +8,7 @@ import ( func main() { - lines := []string{"Hello world", "line 2", "line 3", "line 4", "line 5"} + lines := []string{"Hello world testing main.go", "line 2", "line 3", "line 4", "line 5"} tea.NewProgram( editor.NewModel(lines, action.Position{Line: 0, Col: 0}), tea.WithAltScreen(), diff --git a/internal/action/action.go b/internal/action/action.go index 59720c9..33f62ee 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -37,6 +37,13 @@ type Model interface { SetAnchorX(x int) SetAnchorY(y int) + // Insert + InsertKeys() []string + SetInsertKeys(keys []string) + + // Settings + TabSize() int + // Mode Mode() Mode SetMode(mode Mode) @@ -44,6 +51,9 @@ type Model interface { // Insert recording (for count replay) SetInsertRecording(count int, action Action) + + // ExitInsertMode handles replay, cursor step-back, and mode transition on esc + ExitInsertMode() } // Position represents a location in the buffer diff --git a/internal/action/insert.go b/internal/action/insert.go index 462d4de..8b8f69f 100644 --- a/internal/action/insert.go +++ b/internal/action/insert.go @@ -1,6 +1,10 @@ package action -import tea "github.com/charmbracelet/bubbletea" +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) // EnterInsert implements Action (i) type EnterInsert struct { @@ -121,3 +125,154 @@ func (a OpenLineAbove) Execute(m Model) tea.Cmd { func (a OpenLineAbove) WithCount(n int) Action { return OpenLineAbove{Count: n} } + +// --- Insert mode edit actions --- + +// InsertChar inserts a single character (or rune sequence) at the cursor +type InsertChar struct { + Char string +} + +func (a InsertChar) Execute(m Model) tea.Cmd { + x, y := m.CursorX(), m.CursorY() + l := m.Line(y) + if x < len(l) { + m.SetLine(y, l[:x]+a.Char+l[x:]) + } else { + m.SetLine(y, l+a.Char) + } + m.SetCursorX(x + len(a.Char)) + return nil +} + +// InsertNewline splits the current line at the cursor (enter key) +type InsertNewline struct{} + +func (a InsertNewline) Execute(m Model) tea.Cmd { + x, y := m.CursorX(), m.CursorY() + l := m.Line(y) + if x == len(l) { + m.InsertLine(y+1, "") + } else { + m.SetLine(y, l[:x]) + m.InsertLine(y+1, l[x:]) + } + m.SetCursorY(y + 1) + m.SetCursorX(0) + return nil +} + +// InsertBackspace deletes the character before the cursor +type InsertBackspace struct{} + +func (a InsertBackspace) Execute(m Model) tea.Cmd { + x, y := m.CursorX(), m.CursorY() + l := m.Line(y) + if x > 0 { + m.SetLine(y, l[:x-1]+l[x:]) + m.SetCursorX(x - 1) + } else if y > 0 { + prevLine := m.Line(y - 1) + newX := len(prevLine) + m.SetLine(y-1, prevLine+l) + m.DeleteLine(y) + m.SetCursorY(y - 1) + m.SetCursorX(newX) + } + return nil +} + +// InsertDelete deletes the character under/after the cursor (delete key) +type InsertDelete struct{} + +func (a InsertDelete) Execute(m Model) tea.Cmd { + x, y := m.CursorX(), m.CursorY() + l := m.Line(y) + if x == len(l) && y < m.LineCount()-1 { + nextLine := m.Line(y + 1) + m.SetLine(y, l+nextLine) + m.DeleteLine(y + 1) + } else if x < len(l) { + m.SetLine(y, l[:x]+l[x+1:]) + } + return nil +} + +// InsertTab inserts spaces equal to the tab size +type InsertTab struct{} + +func (a InsertTab) Execute(m Model) tea.Cmd { + x, y := m.CursorX(), m.CursorY() + l := m.Line(y) + tabs := strings.Repeat(" ", m.TabSize()) + if x < len(l) { + m.SetLine(y, l[:x]+tabs+l[x:]) + } else { + m.SetLine(y, l+tabs) + } + m.SetCursorX(x + len(tabs)) + return nil +} + +// InsertDeletePreviousWord deletes the word before the cursor (ctrl+w) +type InsertDeletePreviousWord struct{} + +func isWordChar(c byte) bool { + return (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_' +} + +func isPunctuation(c byte) bool { + return c != ' ' && c != '\t' && !isWordChar(c) +} + +func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd { + x, y := m.CursorX(), m.CursorY() + line := m.Line(y) + + // At start of line: merge with previous line (same as backspace) + if x == 0 { + if y > 0 { + prevLine := m.Line(y - 1) + newX := len(prevLine) + m.SetLine(y-1, prevLine+line) + m.DeleteLine(y) + m.SetCursorY(y - 1) + m.SetCursorX(newX) + } + return nil + } + + // Scan backwards to find the new cursor position (don't mutate yet) + newX := x + + // If we are on puncuation, we should just skip them all and quit + if isPunctuation(line[newX-1]) { + for newX > 0 && isPunctuation(line[newX-1]) { + newX-- + } + + m.SetLine(y, line[:newX]+line[x:]) + m.SetCursorX(newX) + + return nil + } + + // Skip whitespace immediately before the cursor + for newX > 0 && (line[newX-1] == ' ' || line[newX-1] == '\t') { + newX-- + } + + // Skip the word characters before the cursor + for newX > 0 && isWordChar(line[newX-1]) { + newX-- + } + + // Delete everything from newX up to x in one operation + m.SetLine(y, line[:newX]+line[x:]) + m.SetCursorX(newX) + + return nil +} diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index 304624b..244c17c 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -25,6 +25,8 @@ func sendKeys(tm *teatest.TestModel, keys ...string) { tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD}) case "ctrl+v": tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV}) + case "ctrl+w": + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW}) default: tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) } diff --git a/internal/editor/integration_insert_test.go b/internal/editor/integration_insert_test.go index eec3c29..9ae65d8 100644 --- a/internal/editor/integration_insert_test.go +++ b/internal/editor/integration_insert_test.go @@ -456,3 +456,174 @@ func TestInsertModeDelete(t *testing.T) { }) } + +func TestInsertModeDeletePreviousWord(t *testing.T) { + t.Run("test 'ctrl+w' deletes word", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "hello " { + t.Errorf("lines[0] = %q, want 'hello '", m.lines[0]) + } + if m.CursorX() != 5 { + t.Errorf("CursorX() = %d, want '5'", m.CursorX()) + } + }) + + t.Run("test 'ctrl+w' deletes word with whitespace", func(t *testing.T) { + lines := []string{"hello "} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.lines[0]) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + }) + + t.Run("test 'ctrl+w' deletes word until period", func(t *testing.T) { + lines := []string{"hello wo..."} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "hello wo" { + t.Errorf("lines[0] = %q, want 'hello wo'", m.lines[0]) + } + if m.CursorX() != 7 { + t.Errorf("CursorX() = %d, want '7'", m.CursorX()) + } + }) + + t.Run("test 'ctrl+w' deletes line when blank", func(t *testing.T) { + lines := []string{"", ""} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want '1'", m.LineCount()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want '0'", m.CursorY()) + } + }) + + t.Run("test 'ctrl+w' deletes all whitespace when line is only whitespace", func(t *testing.T) { + lines := []string{" "} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want '1'", m.LineCount()) + } + if m.Line(0) != "" { + t.Errorf("Line(0) = %s, want ''", m.Line(0)) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want '0'", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want '0'", m.CursorY()) + } + }) + + t.Run("test 'ctrl+w' at start of first line does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, "i", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "hello" { + t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test 'ctrl+w' from middle of word", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "i", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "lo" { + t.Errorf("lines[0] = %q, want 'lo'", m.lines[0]) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test 'ctrl+w' deletes word after punctuation", func(t *testing.T) { + lines := []string{"...hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 7, Line: 0}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "..." { + t.Errorf("lines[0] = %q, want '...'", m.lines[0]) + } + if m.CursorX() != 2 { + t.Errorf("CursorX() = %d, want 2", m.CursorX()) + } + }) + + t.Run("test 'ctrl+w' with tabs as whitespace", func(t *testing.T) { + lines := []string{"hello\tworld"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "hello\t" { + t.Errorf("lines[0] = %q, want 'hello\\t'", m.lines[0]) + } + if m.CursorX() != 5 { + t.Errorf("CursorX() = %d, want 5", m.CursorX()) + } + }) + + t.Run("test 'ctrl+w' at start of line merges with previous line content", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1}) + sendKeys(tm, "i", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.LineCount()) + } + if m.lines[0] != "helloworld" { + t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0]) + } + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) + } + }) + + t.Run("test 'ctrl+w' with underscore in word", func(t *testing.T) { + lines := []string{"hello_world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "a", "ctrl+w", "esc") + + m := getFinalModel(t, tm) + if m.lines[0] != "" { + t.Errorf("lines[0] = %q, want ''", m.lines[0]) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go index fbb4bb7..ed6eb80 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -124,6 +124,20 @@ func (m *Model) SetAnchorY(y int) { m.anchor.y = y } +// Insert methods +func (m *Model) InsertKeys() []string { + return m.insertKeys +} + +func (m *Model) SetInsertKeys(keys []string) { + m.insertKeys = keys +} + +// Settings +func (m *Model) TabSize() int { + return m.tabSize +} + func (m *Model) ClampCursorX() { lineLen := len(m.lines[m.cursor.y]) if lineLen == 0 { @@ -181,6 +195,18 @@ func (m *Model) replayInsert() { } } +func (m *Model) ExitInsertMode() { + if m.insertCount > 1 { + m.replayInsert() + } + if m.cursor.x > 0 { + m.cursor.x-- + } + m.mode = action.NormalMode + m.insertCount = 0 + m.insertKeys = nil +} + func (m *Model) processInsertKey(key string) { x := m.CursorX() y := m.CursorY() @@ -188,17 +214,12 @@ func (m *Model) processInsertKey(key string) { switch key { case "enter": - - // Simple case, at end, just create a line if x == len(l) { m.InsertLine(y+1, "") - - // otherwise, splice } else { m.SetLine(y, l[:x]) m.InsertLine(y+1, l[x:]) } - m.SetCursorY(y + 1) m.SetCursorX(0) @@ -216,15 +237,14 @@ func (m *Model) processInsertKey(key string) { } case "delete": - if x == len(l) && y < m.LineCount() { + if x == len(l) && y < m.LineCount()-1 { nextLine := m.Line(y + 1) m.SetLine(y, l+nextLine) m.DeleteLine(y + 1) - } else if x >= 0 { + } else if x < len(l) { m.SetLine(y, l[:x]+l[x+1:]) } - // TODO: This handling is wrong, we should be able to delete an entire tab with a single space case "tab": tabs := strings.Repeat(" ", m.tabSize) if x < len(l) { @@ -263,7 +283,6 @@ func (m *Model) processInsertKey(key string) { m.SetCursorY(y + 1) } - // Regular character default: if x < len(l) { m.SetLine(y, l[:x]+key+l[x:]) diff --git a/internal/editor/update.go b/internal/editor/update.go index f64fde3..07f776a 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -13,43 +13,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.win_w = msg.Width case tea.KeyMsg: + // BUG: for use in debugging, until we have command mode if msg.String() == "ctrl+c" { return m, tea.Quit } switch m.mode { case action.NormalMode, + action.InsertMode, action.VisualMode, action.VisualBlockMode, action.VisualLineMode: return m, m.input.Handle(&m, msg.String()) - // TODO: This should be handled elsewhere - case action.InsertMode: - key := msg.String() - - switch key { - case "esc": - if m.insertCount > 1 { - m.replayInsert() - } - - // Allow i to step back, but a to stay put - if m.cursor.x > 0 { - m.cursor.x-- - } - m.mode = action.NormalMode - m.insertCount = 0 - m.insertKeys = nil - - case "ctrl+c", "ctrl+d": - return m, tea.Quit - - default: - // Record and process - m.insertKeys = append(m.insertKeys, key) - m.processInsertKey(key) - } + // The only one left to migrate! case action.CommandMode: switch msg.String() { case "esc": diff --git a/internal/input/handler.go b/internal/input/handler.go index 2d25992..669d9f1 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -31,6 +31,7 @@ type Handler struct { // Keymaps normalKeymap *Keymap visualKeymap *Keymap + insertKeymap *Keymap currentKeymap *Keymap } @@ -40,19 +41,28 @@ func NewHandler() *Handler { // keymap: NewNormalKeymap(), normalKeymap: NewNormalKeymap(), visualKeymap: NewVisualKeymap(), + insertKeymap: NewInsertKeymap(), currentKeymap: nil, } } func (h *Handler) Handle(m action.Model, key string) tea.Cmd { // ESC always resets everything - // TODO: This should prob be relocated if key == "esc" { h.Reset() - m.SetMode(action.NormalMode) + if m.Mode() == action.InsertMode { + m.ExitInsertMode() + } else { + m.SetMode(action.NormalMode) + } return nil } + // Insert mode bypasses the normal state machine entirely + if m.Mode() == action.InsertMode { + return h.handleInsertKey(m, key) + } + // Try to accumulate count (only if no pending sequence) if h.pending == "" && h.tryAccumulateCount(key) { return nil @@ -62,7 +72,6 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { sequence := h.pending + key // Set working keymap - // TODO: Do we need to reset anywhere? switch m.Mode() { case action.NormalMode: h.currentKeymap = h.normalKeymap @@ -244,6 +253,23 @@ func (h *Handler) Pending() string { return h.buffer } +func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd { + // Record the key for count replay (e.g. 5i...) + m.SetInsertKeys(append(m.InsertKeys(), key)) + + // Check the insert keymap first + kind, binding := h.insertKeymap.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.InsertChar{Char: key}.Execute(m) +} + func normalizeVisualSelection(m action.Model) (action.Position, action.Position) { a := action.Position{Line: m.AnchorY(), Col: m.AnchorX()} c := action.Position{Line: m.CursorY(), Col: m.CursorX()} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index fe048c3..06fd829 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -85,6 +85,27 @@ func NewVisualKeymap() *Keymap { } } +func NewInsertKeymap() *Keymap { + return &Keymap{ + motions: map[string]action.Motion{ + "down": motion.MoveDown{Count: 1}, + "up": motion.MoveUp{Count: 1}, + "left": motion.MoveLeft{Count: 1}, + "right": motion.MoveRight{Count: 1}, + }, + operators: map[string]action.Operator{}, // this will likely be empty + actions: map[string]action.Action{ + "enter": action.InsertNewline{}, + "backspace": action.InsertBackspace{}, + "delete": action.InsertDelete{}, + "tab": action.InsertTab{}, + "ctrl+w": action.InsertDeletePreviousWord{}, + "ctrl+c": action.Quit{}, + }, + } + +} + // Lookup returns what type of binding a key is func (km *Keymap) Lookup(key string) (kind string, value any) { if m, ok := km.motions[key]; ok {