feat: implemented and tested the dot operator.

The content gets stored in the '.' register.
This commit is contained in:
Hayden Hargreaves 2026-03-31 18:26:18 -07:00
parent ddbc860530
commit 0e8bb50c20
8 changed files with 502 additions and 8 deletions

View File

@ -64,6 +64,12 @@ type Model interface {
GetRegister(name rune) (core.Register, bool)
SetRegister(name rune, t core.RegisterType, cnt []string) error
UpdateDefaultRegister(t core.RegisterType, cnt []string)
// Dot operator - accumulate keys for repeat
SetLastChangeKeys(keys []string)
LastChangeKeys() []string
ClearLastChangeKeys()
HandleKey(key string) tea.Cmd
}
// Action is the base interface - anything executable

View File

@ -3,6 +3,7 @@ package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
tea "github.com/charmbracelet/bubbletea"
)
// MockModel is a shared test implementation of the Model interface.
@ -23,6 +24,7 @@ type MockModel struct {
CommandHistoryCur int
LastFindVal core.LastFindCommand
StylesVal style.Styles
LastChangeKeysList []string
}
// NewMockModel creates a mock with an empty buffer and 24x80 window.
@ -131,3 +133,9 @@ func (m *MockModel) SetRegister(name rune, t core.RegisterType, cnt []string) er
func (m *MockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
m.RegistersMap['"'] = core.Register{Type: t, Content: cnt}
}
// Dot operator
func (m *MockModel) SetLastChangeKeys(keys []string) { m.LastChangeKeysList = keys }
func (m *MockModel) LastChangeKeys() []string { return m.LastChangeKeysList }
func (m *MockModel) ClearLastChangeKeys() { m.LastChangeKeysList = []string{} }
func (m *MockModel) HandleKey(key string) tea.Cmd { return nil }

41
internal/action/repeat.go Normal file
View File

@ -0,0 +1,41 @@
package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// Repeat implements Action (.) - repeat last input
type Repeat struct {
Count int
}
func (a Repeat) Execute(m Model) tea.Cmd {
keys := m.LastChangeKeys()
if len(keys) == 1 && keys[0] == "." {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{"Cannot repeat '.'"},
Inline: true,
IsError: true,
})
return nil
}
var cmds []tea.Cmd
for _, key := range keys {
cmd := m.HandleKey(key)
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
// Ensure Repeat implements Repeatable
var _ Repeatable = Repeat{}
// Repeat.WithCount: Returns a new Repeat with the given count.
func (a Repeat) WithCount(n int) Action {
return Repeat{Count: n}
}

View File

@ -82,7 +82,8 @@ func addSpecialRegisters(reg map[rune]Register) {
// Small delete? Expression?
// Last inserted text (readonly)
// VIM: Last inserted text (readonly)
// GIM: Content stored for the '.' operator (for debugging)
reg['.'] = emptyRegister()
// Current file name (readonly)

View File

@ -0,0 +1,361 @@
package editor
import (
"testing"
)
// ==================================================
// P0: Basic Recording Tests
// ==================================================
func TestDotOperatorRecording(t *testing.T) {
t.Run("records simple delete", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 1 || keys[0] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys)
}
// Also verify . register
reg, ok := m.GetRegister('.')
if !ok {
t.Fatal("dot register not found")
}
if reg.Content[0] != "x" {
t.Errorf("dot register = %q, want \"x\"", reg.Content[0])
}
})
t.Run("records operator motion", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "d", "w")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys)
}
})
t.Run("records double press operator", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello", "world"}))
sendKeys(tm, "d", "d")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys)
}
})
t.Run("records visual operation", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"}))
sendKeys(tm, "V", "j", "x")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 3 || keys[0] != "V" || keys[1] != "j" || keys[2] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"V\", \"j\", \"x\"]", keys)
}
})
t.Run("records insert mode", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "i", "X", "Y", "Z", "esc")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 5 || keys[0] != "i" || keys[1] != "X" || keys[2] != "Y" || keys[3] != "Z" || keys[4] != "esc" {
t.Errorf("LastChangeKeys() = %v, want [\"i\", \"X\", \"Y\", \"Z\", \"esc\"]", keys)
}
})
}
// ==================================================
// P0: Non-Recording Tests
// ==================================================
func TestDotOperatorNonRecording(t *testing.T) {
t.Run("does not record pure motions", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"}))
sendKeys(tm, "k", "k", "k")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Pure motions should result in empty recording
if len(keys) != 0 {
t.Errorf("LastChangeKeys() = %v, want []", keys)
}
})
t.Run("does not record dot operator itself", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x", ".", ".")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should still be just ["x"], not ["x", ".", "."]
if len(keys) != 1 || keys[0] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys)
}
})
t.Run("does not record command mode entry", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, ":")
sendKeys(tm, "esc") // Exit command mode
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Command mode entry should not record
if len(keys) != 0 {
t.Errorf("LastChangeKeys() = %v, want []", keys)
}
})
t.Run("does not record visual mode entry without action", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "v", "esc")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Just entering and exiting visual mode should not record
if len(keys) != 0 {
t.Errorf("LastChangeKeys() = %v, want []", keys)
}
})
}
// ==================================================
// P0: Basic Replay Tests
// ==================================================
func TestDotOperatorReplay(t *testing.T) {
t.Run("repeats simple delete", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x") // "ello"
sendKeys(tm, ".") // "llo"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "llo" {
t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeats operator motion", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"one two three"}))
sendKeys(tm, "d", "w") // "two three"
sendKeys(tm, ".") // "three"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "three" {
t.Errorf("buffer = %q, want \"three\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeats double press operator", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"}))
sendKeys(tm, "d", "d") // Delete first line -> "line2", "line3"
sendKeys(tm, ".") // Delete next line -> "line3"
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "line3" {
t.Errorf("buffer = %q, want \"line3\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P1: Recording Replacement Tests
// ==================================================
func TestDotOperatorRecordingReplacement(t *testing.T) {
t.Run("new action replaces old recording", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "x") // Record ["x"]
sendKeys(tm, "l", "l") // Motions clear recording buffer but don't save
sendKeys(tm, "d", "w") // Record ["d", "w"]
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should be the latest action ["d", "w"], not ["x"]
if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys)
}
})
t.Run("motions clear recording without saving", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"}))
sendKeys(tm, "x") // Record ["x"]
sendKeys(tm, "j", "j", "j") // Motions don't overwrite saved recording
sendKeys(tm, "d", "d") // New action replaces
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should be ["d", "d"] from the last modifying action
if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys)
}
})
}
// ==================================================
// P1: Visual Mode Tests
// ==================================================
func TestDotOperatorVisualMode(t *testing.T) {
t.Run("repeats visual line operation", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"a", "b", "c", "d", "e"}))
sendKeys(tm, "V", "j", "x") // Delete lines 0-1 -> "c", "d", "e"
// Cursor should be at line 0 after deletion
sendKeys(tm, ".") // Repeat -> delete next 2 lines -> "e"
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "e" {
t.Errorf("buffer = %q, want \"e\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P1: Insert Mode Tests
// ==================================================
func TestDotOperatorInsertMode(t *testing.T) {
t.Run("repeats insert mode operation", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "i", "X", "Y", "Z", "esc") // "XYZhello", cursor at col 2 (on Z)
// Move cursor after XYZ (to col 3, between Z and h)
sendKeys(tm, "l")
sendKeys(tm, ".") // Should insert XYZ again at col 3
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "XYZXYZhello" {
t.Errorf("buffer = %q, want \"XYZXYZhello\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P1: Multiple Repeat Tests
// ==================================================
func TestDotOperatorMultipleRepeats(t *testing.T) {
t.Run("dot can be used multiple times", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world foo bar"}))
sendKeys(tm, "d", "w") // "world foo bar"
sendKeys(tm, ".") // "foo bar"
sendKeys(tm, ".") // "bar"
sendKeys(tm, ".") // ""
m := getFinalModel(t, tm)
line := m.ActiveBuffer().Line(0)
// After deleting 4 words, should be empty or just whitespace
if line != "" && line != " " {
t.Errorf("buffer = %q, want empty or space", line)
}
})
}
// ==================================================
// P2: Edge Cases
// ==================================================
func TestDotOperatorEdgeCases(t *testing.T) {
t.Run("repeat at start of file", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x") // "ello"
sendKeys(tm, ".") // "llo"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "llo" {
t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeat after undo", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "x") // "ello world"
sendKeys(tm, "u") // Undo -> "hello world"
sendKeys(tm, ".") // Repeat should still work -> "ello world"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "ello world" {
t.Errorf("buffer = %q, want \"ello world\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeat with no recorded change", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, ".") // Dot with nothing recorded
m := getFinalModel(t, tm)
// Should not crash, buffer should be unchanged
if m.ActiveBuffer().Line(0) != "hello" {
t.Errorf("buffer = %q, want \"hello\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P2: Integration with Counts
// ==================================================
func TestDotOperatorWithCounts(t *testing.T) {
t.Run("recording includes count", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "3", "x")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should record both the count and the action
if len(keys) != 2 || keys[0] != "3" || keys[1] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"3\", \"x\"]", keys)
}
})
t.Run("repeat preserves count", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "3", "x") // Delete 3 chars -> "lo"
sendKeys(tm, ".") // Should delete 3 more (or try to)
m := getFinalModel(t, tm)
// After deleting 3 + 3 = 6 chars, should be empty or have no chars left
if m.ActiveBuffer().Line(0) != "" {
t.Errorf("buffer = %q, want empty", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P2: Complex Sequences
// ==================================================
func TestDotOperatorComplexSequences(t *testing.T) {
t.Run("complex sequence of operations", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"}))
sendKeys(tm, "x") // Delete 'l' from line1 -> ["ine1", "line2", "line3"]
sendKeys(tm, "j", "j") // Move to line 2
sendKeys(tm, "d", "d") // Delete line 2 (line3) -> ["ine1", "line2"], cursor at line 1
sendKeys(tm, "k") // Move up to line 0
sendKeys(tm, ".") // Repeat dd - deletes line 0 (ine1) -> ["line2"]
m := getFinalModel(t, tm)
// After the sequence, dd was recorded and repeated at line 0, deleting it
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "line2" {
t.Errorf("buffer = %q, want \"line2\"", m.ActiveBuffer().Line(0))
}
})
}

View File

@ -51,6 +51,9 @@ type Model struct {
// Visual styles
styles style.Styles
// Dot operator state
lastChangeKeys []string
}
// Model.Init: Initialize the model and start any commands that may need to run. Required
@ -120,6 +123,25 @@ func (m *Model) GetLastFind() *core.LastFindCommand {
return &m.lastFind
}
// Does update the '.' register
func (m *Model) SetLastChangeKeys(keys []string) {
m.lastChangeKeys = keys
m.SetRegister('.', core.CharwiseRegister, []string{strings.Join(keys, "")})
}
func (m *Model) LastChangeKeys() []string {
return m.lastChangeKeys
}
func (m *Model) ClearLastChangeKeys() {
m.lastChangeKeys = []string{}
}
func (m *Model) HandleKey(key string) tea.Cmd {
return m.input.Handle(m, key)
}
func (m *Model) ExitInsertMode() {
win := m.ActiveWindow()
if m.insertCount > 1 {

View File

@ -1,6 +1,8 @@
package input
import (
"slices"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/operator"
@ -32,6 +34,9 @@ type Handler struct {
charMotionType string // which char motion is waiting: "f", "t", "F", or "T"
modifier string // which modifier used for text object: "i" or "a"
// Dot operator - accumulate keys for current operation
recordingKeys []string
// Keymaps
normalKeymap *Keymap
visualKeymap *Keymap
@ -56,8 +61,21 @@ func NewHandler() *Handler {
// Handler.Handle: Main entry point for processing a keypress. Routes to appropriate
// handler based on current mode and state.
func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
ignoreKeys := []string{".", "u", "ctrl+r"}
// Record key for dot operator (except in insert/command mode which handle separately)
if m.Mode() != core.InsertMode && m.Mode() != core.CommandMode && !slices.Contains(ignoreKeys, key) {
h.recordingKeys = append(h.recordingKeys, key)
}
// ESC always resets everything
if key == "esc" {
// If insert mode, keep the escape
if m.Mode() == core.InsertMode {
m.SetLastChangeKeys(append(m.LastChangeKeys(), key))
}
h.recordingKeys = []string{} // Clear recording on ESC
h.Reset()
if m.Mode() == core.InsertMode {
// Before exiting insert mode, end the block in the undo stack
@ -188,6 +206,12 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
mot = res.Resolve(m)
}
cmd := h.executeMotion(m, mot)
// Only clear recording for pure motions in normal mode
// In visual mode, motions are part of building the selection
if !m.Mode().IsVisualMode() {
h.recordingKeys = []string{}
}
h.Reset()
return cmd
@ -206,7 +230,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if m.Mode() != core.InsertMode {
m.SetMode(core.NormalMode)
}
h.Reset()
h.RecordAndReset(m)
return cmd
}
// In normal mode, wait for a motion to define the range
@ -221,7 +245,12 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
act = r.WithCount(count)
}
cmd := h.executeAction(m, act)
h.Reset()
// Only record if we're not entering visual mode (visual ops record when they complete)
if m.Mode().IsVisualMode() {
h.Reset() // In visual mode now, don't save yet
} else {
h.RecordAndReset(m)
}
return cmd
}
@ -240,7 +269,7 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
// Only call DoublePress if the operator supports it
if dp, ok := h.operator.(action.DoublePresser); ok {
cmd := h.executeDoublePress(m, dp, count)
h.Reset()
h.RecordAndReset(m)
return cmd
}
h.Reset()
@ -268,7 +297,7 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
h.executeMotion(m, mot)
end := win.Cursor
cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
h.Reset()
h.RecordAndReset(m)
return cmd
}
@ -343,7 +372,7 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
h.executeMotion(m, mot)
end := win.Cursor
cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
h.Reset()
h.RecordAndReset(m)
return cmd
}
@ -374,7 +403,7 @@ func (h *Handler) handleTextObject(m action.Model, kind string, binding any, key
// If we have an operator pending (e.g., "diw")
if h.operator != nil {
cmd := h.executeOperator(m, h.operator, start, end, mtype)
h.Reset()
h.RecordAndReset(m)
return cmd
}
@ -456,6 +485,7 @@ func (h *Handler) effectiveCount() int {
}
// Handler.Reset: Clears all handler state including counts, operators, and buffers.
// Does NOT clear recordingKeys - those accumulate across an operation.
func (h *Handler) Reset() {
h.state = StateReady
h.count1 = 0
@ -466,6 +496,28 @@ func (h *Handler) Reset() {
h.pending = ""
h.charMotionType = ""
h.modifier = ""
// NOTE: recordingKeys is NOT cleared here - it accumulates across the operation
}
func (h *Handler) RecordAndReset(m action.Model) {
// Save the recorded keys to the model for dot operator
// Filter out mode-switch keys that don't modify the buffer
ignoreStates := []string{":", "v", "V", "."}
if len(h.recordingKeys) > 0 {
// Check if the entire sequence is just a mode switch
shouldRecord := true
if len(h.recordingKeys) == 1 && slices.Contains(ignoreStates, h.recordingKeys[0]) {
shouldRecord = false
}
if shouldRecord {
m.SetLastChangeKeys(h.recordingKeys)
}
}
h.recordingKeys = []string{} // Clear recording after saving
h.Reset()
}
// Handler.Pending: Returns the accumulated input buffer for display.
@ -489,6 +541,7 @@ 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))
m.SetLastChangeKeys(append(m.LastChangeKeys(), key))
// Check the insert keymap first
kind, binding := h.insertKeymap.Lookup(key)

View File

@ -41,7 +41,7 @@ func NewNormalKeymap() *Keymap {
"ctrl+u": motion.ScrollUpHalfPage{},
"ctrl+d": motion.ScrollDownHalfPage{},
";": action.RepeatFind{Count: 1, Reverse: false},
".": action.RepeatFind{Count: 1, Reverse: true},
",": action.RepeatFind{Count: 1, Reverse: true},
},
operators: map[string]action.Operator{
"d": operator.DeleteOperator{},
@ -71,6 +71,7 @@ func NewNormalKeymap() *Keymap {
"P": action.PasteBefore{Count: 1},
"u": action.Undo{},
"ctrl+r": action.Redo{},
".": action.Repeat{Count: 1},
},
charMotions: map[string]action.Motion{
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
@ -134,6 +135,7 @@ func NewVisualKeymap() *Keymap {
},
actions: map[string]action.Action{
"p": action.VisualPaste{Count: 1},
".": action.Repeat{Count: 1},
// ":": action.EnterComandMode{}, // Different OP
},
charMotions: map[string]action.Motion{