feat: implemented insert mode keymaps and ctrl+w, tested

This commit is contained in:
Hayden Hargreaves 2026-02-12 17:30:06 -07:00
parent 49ef0212a6
commit 39c4bf1b6b
9 changed files with 421 additions and 40 deletions

View File

@ -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(),

View File

@ -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

View File

@ -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
}

View File

@ -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)})
}

View File

@ -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())
}
})
}

View File

@ -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:])

View File

@ -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":

View File

@ -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()}

View File

@ -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 {