From 050935941c7581a71b8b371369d8d95b2d8cb79c Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 13 Mar 2026 10:53:44 -0700 Subject: [PATCH 1/2] feat: implemented find char motions. tested. --- internal/action/find.go | 83 ++ internal/action/find_test.go | 1754 ++++++++++++++++++++++++++++++++++ internal/action/interface.go | 7 + internal/input/handler.go | 106 +- internal/input/keymap.go | 32 +- 5 files changed, 1971 insertions(+), 11 deletions(-) create mode 100644 internal/action/find.go create mode 100644 internal/action/find_test.go diff --git a/internal/action/find.go b/internal/action/find.go new file mode 100644 index 0000000..8bb13b4 --- /dev/null +++ b/internal/action/find.go @@ -0,0 +1,83 @@ +package action + +import ( + "git.gophernest.net/azpect/TextEditor/internal/core" + tea "github.com/charmbracelet/bubbletea" +) + +type FindChar struct { + Char string + Forward bool + Inclusive bool + Count int +} + +func (m FindChar) WithChar(char string) Motion { + m.Char = char + return m +} + +func (m FindChar) Type() core.MotionType { + if m.Inclusive { + return core.CharwiseInclusive + } + return core.CharwiseExclusive + +} + +// WithCount sets the count (required by Repeatable interface) +func (f FindChar) WithCount(n int) Action { + f.Count = n + return f +} + +func (a FindChar) Execute(m Model) tea.Cmd { + // Get the line + // Get the current position, moved based on inputs + win := m.ActiveWindow() + buf := win.Buffer + + line := buf.Line(win.Cursor.Line) + col := win.Cursor.Col + + if len(line) <= 0 { + return nil + } + + if a.Forward { + for x := col; x < len(line); x++ { + if string(line[x]) == a.Char { + if a.Count == 1 { + if a.Inclusive { + win.SetCursorCol(x) + } else { + win.SetCursorCol(x - 1) + } + break + } else { + a.Count-- + } + } + } + } + + if !a.Forward { + for x := col; x >= 0; x-- { + if string(line[x]) == a.Char { + if a.Count == 1 { + if a.Inclusive { + win.SetCursorCol(x) + } else { + win.SetCursorCol(x + 1) + } + break + } else { + a.Count-- + } + } + } + + } + + return nil +} diff --git a/internal/action/find_test.go b/internal/action/find_test.go new file mode 100644 index 0000000..3f5a950 --- /dev/null +++ b/internal/action/find_test.go @@ -0,0 +1,1754 @@ +package action + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/core" +) + +// ================================================== +// Mock Model Implementation +// ================================================== + +type mockModel struct { + windows []*core.Window + activeWindow *core.Window + buffers []*core.Buffer + settings core.EditorSettings + mode core.Mode + registers map[rune]core.Register + insertKeys []string + command string + commandCursor int + commandError error + commandOutput string +} + +func newMockModel() *mockModel { + buf := core.NewBufferBuilder(). + WithLines([]string{""}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithHeight(24). + WithWidth(80). + Build() + + return &mockModel{ + windows: []*core.Window{&win}, + activeWindow: &win, + buffers: []*core.Buffer{&buf}, + settings: core.NewDefaultSettings(), + mode: core.NormalMode, + registers: core.DefaultRegisters(), + } +} + +func newMockModelWithBuffer(buf *core.Buffer) *mockModel { + win := core.NewWindowBuilder(). + WithBuffer(buf). + WithHeight(24). + WithWidth(80). + Build() + + return &mockModel{ + windows: []*core.Window{&win}, + activeWindow: &win, + buffers: []*core.Buffer{buf}, + settings: core.NewDefaultSettings(), + mode: core.NormalMode, + registers: core.DefaultRegisters(), + } +} + +func newMockModelWithWindow(win *core.Window) *mockModel { + return &mockModel{ + windows: []*core.Window{win}, + activeWindow: win, + buffers: []*core.Buffer{win.Buffer}, + settings: core.NewDefaultSettings(), + mode: core.NormalMode, + registers: core.DefaultRegisters(), + } +} + +// Core Data Access +func (m *mockModel) Windows() []*core.Window { return m.windows } +func (m *mockModel) ActiveWindow() *core.Window { return m.activeWindow } +func (m *mockModel) Buffers() []*core.Buffer { return m.buffers } +func (m *mockModel) SetBuffers(bufs []*core.Buffer) { m.buffers = bufs } +func (m *mockModel) ActiveBuffer() *core.Buffer { return m.activeWindow.Buffer } + +// Insert Mode State +func (m *mockModel) InsertKeys() []string { return m.insertKeys } +func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys } +func (m *mockModel) SetInsertRecording(count int, action Action) {} +func (m *mockModel) ExitInsertMode() {} + +// Command Mode State +func (m *mockModel) Command() string { return m.command } +func (m *mockModel) SetCommand(cmd string) { m.command = cmd } +func (m *mockModel) CommandCursor() int { return m.commandCursor } +func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur } +func (m *mockModel) CommandError() error { return m.commandError } +func (m *mockModel) SetCommandError(err error) { m.commandError = err } +func (m *mockModel) CommandOutput() string { return m.commandOutput } +func (m *mockModel) SetCommandOutput(out string) { m.commandOutput = out } + +// Editor-wide State +func (m *mockModel) Mode() core.Mode { return m.mode } +func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode } +func (m *mockModel) Settings() core.EditorSettings { return m.settings } +func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s } + +// Registers +func (m *mockModel) Registers() map[rune]core.Register { return m.registers } +func (m *mockModel) GetRegister(name rune) (core.Register, bool) { + reg, ok := m.registers[name] + return reg, ok +} +func (m *mockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error { + m.registers[name] = core.Register{Type: t, Content: cnt} + return nil +} +func (m *mockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) { + m.registers['"'] = core.Register{Type: t, Content: cnt} +} + +// ================================================== +// f (find forward inclusive) Tests +// ================================================== + +func TestFindCharForward(t *testing.T) { + t.Run("finds next character on same line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). // At 'h' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should move to first 'o' at position 4 + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds character at current position", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 4). // At first 'o' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Current implementation finds char at cursor position + // In real vim, 'f' skips current position + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("character not found does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "x", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at position 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds character near end of line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "d", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should move to 'd' at position 10 + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor col = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds second occurrence from middle of line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 5). // At space after 'hello' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find 'o' in 'world' at position 7 + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles empty line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{""}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles single character line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"a"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find 'a' at position 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds space character", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: " ", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find space at position 5 + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("multiple same characters finds first", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaaaabbbbb"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "b", + Forward: true, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find first 'b' at position 5 + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// F (find backward inclusive) Tests +// ================================================== + +func TestFindCharBackward(t *testing.T) { + t.Run("finds previous character on same line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). // At 'd' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should move to 'o' in 'world' at position 7 + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds character at current position", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 7). // At 'o' in 'world' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Current implementation finds char at cursor position + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("character not found does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "x", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at position 10 + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor col = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds character at beginning of line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "h", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should move to 'h' at position 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds first occurrence from middle of line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 6). // At 'w' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find 'o' in 'hello' at position 4 + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles empty line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{""}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles single character line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"a"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find 'a' at position 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("finds space character backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: " ", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find space at position 5 + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("multiple same characters finds first going backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaaaabbbbb"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 9). // At last 'b' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: true, + Count: 1, + } + + action.Execute(m) + + // Should find last 'a' at position 4 + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// t (till forward exclusive) Tests +// ================================================== + +func TestTillCharForward(t *testing.T) { + t.Run("stops before next character", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). // At 'h' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: true, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 3 (one before 'o' at position 4) + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("character not found does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "x", + Forward: true, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at position 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles adjacent character", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"ab"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). // At 'a' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "b", + Forward: true, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 0 (one before 'b' at position 1) + // This will be the same position, which matches vim behavior + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("stops before character near end of line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "d", + Forward: true, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 9 (one before 'd' at position 10) + if m.ActiveWindow().Cursor.Col != 9 { + t.Errorf("cursor col = %d, want 9", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles empty line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{""}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("stops before space character", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: " ", + Forward: true, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 4 (one before space at position 5) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("stops before second occurrence", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 5). // At space + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: true, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 6 (one before 'o' in 'world' at position 7) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// T (till backward exclusive) Tests +// ================================================== + +func TestTillCharBackward(t *testing.T) { + t.Run("stops after previous character", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). // At 'd' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 8 (one after 'o' in 'world' at position 7) + if m.ActiveWindow().Cursor.Col != 8 { + t.Errorf("cursor col = %d, want 8", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("character not found does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "x", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at position 10 + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor col = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles adjacent character", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"ab"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 1). // At 'b' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 1 (one after 'a' at position 0) + // This will be the same position, which matches vim behavior + if m.ActiveWindow().Cursor.Col != 1 { + t.Errorf("cursor col = %d, want 1", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("stops after character at beginning of line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "h", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 1 (one after 'h' at position 0) + if m.ActiveWindow().Cursor.Col != 1 { + t.Errorf("cursor col = %d, want 1", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("handles empty line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{""}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Cursor should stay at 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("stops after space character backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: " ", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 6 (one after space at position 5) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("stops after first occurrence going backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 6). // At 'w' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "o", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 5 (one after 'o' in 'hello' at position 4) + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("multiple same characters stops after last going backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaaaabbbbb"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 9). // At last 'b' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: false, + Count: 1, + } + + action.Execute(m) + + // Should move to position 5 (one after last 'a' at position 4) + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("cursor col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// Count Tests (f with count) +// ================================================== + +func TestFindCharForwardWithCount(t *testing.T) { + t.Run("2f finds second occurrence", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). // At 'h' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: true, + Inclusive: true, + Count: 2, + } + + action.Execute(m) + + // Should find 2nd 'l' at position 3 + // "hello world" + // 01234567890 + // ^ (first 'l' at 2) + // ^ (second 'l' at 3) + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("3f finds third occurrence", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: true, + Inclusive: true, + Count: 3, + } + + action.Execute(m) + + // Should find 3rd 'l' at position 9 + // "hello world" + // 01234567890 + // ^^ (first two 'l's at 2, 3) + // ^ (third 'l' at 9) + if m.ActiveWindow().Cursor.Col != 9 { + t.Errorf("cursor col = %d, want 9", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: true, + Inclusive: true, + Count: 5, // Only 3 'l's in the line + } + + action.Execute(m) + + // Cursor should stay at 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0 (should not move)", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("2f with only one occurrence does not move", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "w", + Forward: true, + Inclusive: true, + Count: 2, // Only 1 'w' in the line + } + + action.Execute(m) + + // Cursor should stay at 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0 (should not move)", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("5f finds fifth occurrence", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaaaa bbbbb"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: true, + Count: 5, + } + + action.Execute(m) + + // Should find 5th 'a' at position 4 + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("count from middle of line", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaa bbb aaa"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 4). // At first 'b' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: true, + Count: 2, + } + + action.Execute(m) + + // From position 4, should find 2nd 'a' ahead at position 9 + // "aaa bbb aaa" + // 01234567890 + // ^ (starting at 4) + // ^^ (first 'a' at 8, second at 9) + if m.ActiveWindow().Cursor.Col != 9 { + t.Errorf("cursor col = %d, want 9", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// Count Tests (F with count) +// ================================================== + +func TestFindCharBackwardWithCount(t *testing.T) { + t.Run("2F finds second occurrence backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). // At 'd' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: false, + Inclusive: true, + Count: 2, + } + + action.Execute(m) + + // Going backward from 'd', should find 2nd 'l' at position 3 + // "hello world" + // 01234567890 + // ^ (first 'l' backward at 9) + // ^ (second 'l' backward at 3) + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("3F finds third occurrence backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: false, + Inclusive: true, + Count: 3, + } + + action.Execute(m) + + // Going backward, should find 3rd 'l' at position 2 + // "hello world" + // 01234567890 + // ^ (first 'l' backward at 9) + // ^ (second 'l' backward at 3) + // ^ (third 'l' backward at 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("cursor col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: false, + Inclusive: true, + Count: 5, // Only 3 'l's in the line + } + + action.Execute(m) + + // Cursor should stay at 10 + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor col = %d, want 10 (should not move)", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("5F finds fifth occurrence backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaaaa bbbbb"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). // At last 'b' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "b", + Forward: false, + Inclusive: true, + Count: 5, + } + + action.Execute(m) + + // Going backward from position 10, should find 5th 'b' at position 6 + // "aaaaa bbbbb" + // 01234567890 + // ^ (at 10) + // ^ (5th 'b' backward is at position 6) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("count from middle of line backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaa bbb aaa"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 6). // At middle 'b' + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: true, + Count: 2, + } + + action.Execute(m) + + // From position 6 going backward, should find 2nd 'a' at position 1 + // "aaa bbb aaa" + // 01234567890 + // ^ (starting at 6) + // ^^ (first 'a' backward at 2, second at 1) + if m.ActiveWindow().Cursor.Col != 1 { + t.Errorf("cursor col = %d, want 1", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// Count Tests (t with count) +// ================================================== + +func TestTillCharForwardWithCount(t *testing.T) { + t.Run("2t stops before second occurrence", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: true, + Inclusive: false, + Count: 2, + } + + action.Execute(m) + + // Should stop before 2nd 'l' at position 2 (one before position 3) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("cursor col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("3t stops before third occurrence", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: true, + Inclusive: false, + Count: 3, + } + + action.Execute(m) + + // Should stop before 3rd 'l' at position 8 (one before position 9) + if m.ActiveWindow().Cursor.Col != 8 { + t.Errorf("cursor col = %d, want 8", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: true, + Inclusive: false, + Count: 5, + } + + action.Execute(m) + + // Cursor should stay at 0 + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0 (should not move)", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("5t stops before fifth occurrence", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaaaa bbbbb"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 0). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: false, + Count: 5, + } + + action.Execute(m) + + // Should stop before 5th 'a' at position 3 (one before position 4) + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// Count Tests (T with count) +// ================================================== + +func TestTillCharBackwardWithCount(t *testing.T) { + t.Run("2T stops after second occurrence backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: false, + Inclusive: false, + Count: 2, + } + + action.Execute(m) + + // Going backward, should stop after 2nd 'l' at position 4 (one after position 3) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("3T stops after third occurrence backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: false, + Inclusive: false, + Count: 3, + } + + action.Execute(m) + + // Going backward, should stop after 3rd 'l' at position 3 (one after position 2) + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("count exceeds available matches does not move cursor", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"hello world"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "l", + Forward: false, + Inclusive: false, + Count: 5, + } + + action.Execute(m) + + // Cursor should stay at 10 + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor col = %d, want 10 (should not move)", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("5T stops after fifth occurrence backward", func(t *testing.T) { + buf := core.NewBufferBuilder(). + WithLines([]string{"aaaaa bbbbb"}). + Build() + + win := core.NewWindowBuilder(). + WithBuffer(&buf). + WithCursorPos(0, 10). + Build() + + m := newMockModelWithWindow(&win) + + action := FindChar{ + Char: "b", + Forward: false, + Inclusive: false, + Count: 5, + } + + action.Execute(m) + + // Going backward, should stop after 5th 'b' at position 7 (one after position 6) + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) +} + +// ================================================== +// Motion Type Tests +// ================================================== + +func TestFindCharType(t *testing.T) { + t.Run("f returns CharwiseInclusive", func(t *testing.T) { + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: true, + } + + if action.Type() != core.CharwiseInclusive { + t.Errorf("Type() = %v, want CharwiseInclusive", action.Type()) + } + }) + + t.Run("t returns CharwiseExclusive", func(t *testing.T) { + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: false, + } + + if action.Type() != core.CharwiseExclusive { + t.Errorf("Type() = %v, want CharwiseExclusive", action.Type()) + } + }) + + t.Run("F returns CharwiseInclusive", func(t *testing.T) { + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: true, + } + + if action.Type() != core.CharwiseInclusive { + t.Errorf("Type() = %v, want CharwiseInclusive", action.Type()) + } + }) + + t.Run("T returns CharwiseExclusive", func(t *testing.T) { + action := FindChar{ + Char: "a", + Forward: false, + Inclusive: false, + } + + if action.Type() != core.CharwiseExclusive { + t.Errorf("Type() = %v, want CharwiseExclusive", action.Type()) + } + }) +} + +// ================================================== +// WithChar Tests +// ================================================== + +func TestFindCharWithChar(t *testing.T) { + t.Run("WithChar sets the character", func(t *testing.T) { + action := FindChar{ + Forward: true, + Inclusive: true, + } + + newAction := action.WithChar("x") + + findAction, ok := newAction.(FindChar) + if !ok { + t.Fatal("WithChar should return FindChar") + } + + if findAction.Char != "x" { + t.Errorf("Char = %q, want %q", findAction.Char, "x") + } + }) + + t.Run("WithChar preserves other fields", func(t *testing.T) { + action := FindChar{ + Forward: true, + Inclusive: true, + Count: 3, + } + + newAction := action.WithChar("y") + + findAction, ok := newAction.(FindChar) + if !ok { + t.Fatal("WithChar should return FindChar") + } + + if !findAction.Forward { + t.Error("Forward should be preserved") + } + if !findAction.Inclusive { + t.Error("Inclusive should be preserved") + } + if findAction.Count != 3 { + t.Errorf("Count = %d, want 3", findAction.Count) + } + }) +} + +// ================================================== +// WithCount Tests +// ================================================== + +func TestFindCharWithCount(t *testing.T) { + t.Run("WithCount sets the count", func(t *testing.T) { + action := FindChar{ + Char: "a", + Forward: true, + Inclusive: true, + } + + newAction := action.WithCount(5) + + findAction, ok := newAction.(FindChar) + if !ok { + t.Fatal("WithCount should return FindChar") + } + + if findAction.Count != 5 { + t.Errorf("Count = %d, want 5", findAction.Count) + } + }) + + t.Run("WithCount preserves other fields", func(t *testing.T) { + action := FindChar{ + Char: "x", + Forward: false, + Inclusive: false, + } + + newAction := action.WithCount(2) + + findAction, ok := newAction.(FindChar) + if !ok { + t.Fatal("WithCount should return FindChar") + } + + if findAction.Char != "x" { + t.Errorf("Char = %q, want %q", findAction.Char, "x") + } + if findAction.Forward { + t.Error("Forward should be preserved as false") + } + if findAction.Inclusive { + t.Error("Inclusive should be preserved as false") + } + }) +} diff --git a/internal/action/interface.go b/internal/action/interface.go index 53641d9..07cca43 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -83,3 +83,10 @@ type DoublePresser interface { type Repeatable interface { WithCount(n int) Action } + +// CharMotion is a motion that requires a character argument (f/t/F/T) +// The state machine will call WithChar to set the character before executing +type CharMotion interface { + Motion + WithChar(char string) Motion +} diff --git a/internal/input/handler.go b/internal/input/handler.go index 15da691..94c4c83 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -14,18 +14,20 @@ const ( StateCount StateOperatorPending StateMotionCount + StateWaitingForChar // Waiting for character argument (f/t/F/T) ) // Handler: Manages input processing with a state machine for vim-style commands. // Handles counts, operators, motions, and multi-key sequences. type Handler struct { - state InputState - count1 int - count2 int - operator action.Operator - operatorKey string // track which key started operator (for dd, yy, cc) - buffer string // for display (what user has typed) - pending string // partial key sequence (e.g., "g" waiting for second key) + state InputState + count1 int + count2 int + operator action.Operator + operatorKey string // track which key started operator (for dd, yy, cc) + buffer string // for display (what user has typed) + pending string // partial key sequence (e.g., "g" waiting for second key) + charMotionType string // which char motion is waiting: "f", "t", "F", or "T" // Keymaps normalKeymap *Keymap @@ -70,6 +72,11 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { return h.handleCommandKey(m, key) } + // If waiting for character argument (f/t/F/T), capture it + if h.state == StateWaitingForChar { + return h.handleCharMotion(m, key) + } + // Try to accumulate count (only if no pending sequence) if h.pending == "" && h.tryAccumulateCount(key) { return nil @@ -120,6 +127,13 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd { // Handler.dispatch: Routes to the appropriate handler based on current state. func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd { + // Handle character motions (f/t/F/T) - transition to waiting state + if kind == "char_motion" { + h.charMotionType = key + h.state = StateWaitingForChar + return nil + } + switch h.state { case StateReady, StateCount: return h.handleInitial(m, kind, binding, key) @@ -220,6 +234,83 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, return nil } +// Handler.handleCharMotion: Handles input when waiting for a character argument +// for f/t/F/T motions. Captures the character and creates the appropriate motion. +// +// USAGE FOR IMPLEMENTING f/t/F/T MOTIONS: +// +// You need to create a CharMotion interface in internal/action/interface.go: +// +// type CharMotion interface { +// Motion +// WithChar(char string) Motion +// } +// +// Then implement it for your FindChar motion (example): +// +// type FindChar struct { +// Char string +// Forward bool // true = f/t, false = F/T +// To bool // true = f/F (to char), false = t/T (till before/after char) +// Count int +// } +// +// func (f FindChar) WithChar(char string) action.Motion { +// f.Char = char +// return f +// } +// +// The state machine will: +// 1. Call WithChar(key) to set the character +// 2. Apply count if the motion is Repeatable +// 3. If operator pending (df{char}), execute motion and operate on range +// 4. Otherwise just execute the motion +func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd { + count := h.effectiveCount() + + // Get the char motion from the keymap + // The keymap should have registered f/t/F/T as "char_motion" type + // and stored the motion template (without character set yet) + motion := h.currentKeymap.LookupCharMotion(h.charMotionType) + if motion == nil { + // Motion not found - shouldn't happen if keymap configured correctly + h.Reset() + return nil + } + + // Type assert to CharMotion interface and set the character + charMot, ok := motion.(action.CharMotion) + if !ok { + // Motion doesn't implement CharMotion interface + h.Reset() + return nil + } + + // Set the character that was pressed + mot := charMot.WithChar(key) + + // Apply count if supported + if r, ok := mot.(action.Repeatable); ok { + mot = r.WithCount(count).(action.Motion) + } + + // If operator pending (e.g., "df{char}"), get range and operate + if h.operator != nil { + win := m.ActiveWindow() + start := win.Cursor + mot.Execute(m) + end := win.Cursor + cmd := h.operator.Operate(m, start, end, mot.Type()) + h.Reset() + return cmd + } + + // Otherwise just execute the motion + cmd := mot.Execute(m) + h.Reset() + return cmd +} + // Handler.tryAccumulateCount: Attempts to add a digit to the count. Returns // true if successful, false if the key is not a digit or is an invalid count. func (h *Handler) tryAccumulateCount(key string) bool { @@ -277,6 +368,7 @@ func (h *Handler) Reset() { h.operatorKey = "" h.buffer = "" h.pending = "" + h.charMotionType = "" } // Handler.Pending: Returns the accumulated input buffer for display. diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 957d70d..08a0d7b 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -9,9 +9,10 @@ import ( // Keymap: Maps key sequences to motions, operators, and actions. type Keymap struct { - motions map[string]action.Motion - operators map[string]action.Operator - actions map[string]action.Action // standalone actions: i.e., 'i', 'a' + motions map[string]action.Motion + operators map[string]action.Operator + actions map[string]action.Action // standalone actions: i.e., 'i', 'a' + charMotions map[string]action.Motion // motions that need character argument: f/t/F/T } // NewNormalKeymap: Creates a keymap for normal mode with all standard vim bindings. @@ -63,6 +64,12 @@ func NewNormalKeymap() *Keymap { "p": action.Paste{Count: 1}, "P": action.PasteBefore{Count: 1}, }, + charMotions: map[string]action.Motion{ + "f": action.FindChar{Forward: true, Inclusive: true}, + "F": action.FindChar{Forward: false, Inclusive: true}, + "t": action.FindChar{Forward: true, Inclusive: false}, + "T": action.FindChar{Forward: false, Inclusive: false}, + }, } } @@ -140,7 +147,7 @@ func NewCommandKeymap() *Keymap { } -// Keymap.Lookup: Returns the type and value of a key binding (motion, operator, or action). +// Keymap.Lookup: Returns the type and value of a key binding (motion, operator, action, or char_motion). func (km *Keymap) Lookup(key string) (kind string, value any) { if m, ok := km.motions[key]; ok { return "motion", m @@ -151,6 +158,9 @@ func (km *Keymap) Lookup(key string) (kind string, value any) { if a, ok := km.actions[key]; ok { return "action", a } + if cm, ok := km.charMotions[key]; ok { + return "char_motion", cm + } return "", nil } @@ -171,5 +181,19 @@ func (km *Keymap) HasPrefix(prefix string) bool { return true } } + for key := range km.charMotions { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + return true + } + } return false } + +// Keymap.LookupCharMotion: Returns the motion template for character motions (f/t/F/T). +// The returned motion should implement the CharMotion interface. +func (km *Keymap) LookupCharMotion(key string) action.Motion { + if cm, ok := km.charMotions[key]; ok { + return cm + } + return nil +} -- 2.47.2 From 8df55c60d200cea09fb2e6fc35038acd9a421076 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 13 Mar 2026 10:54:22 -0700 Subject: [PATCH 2/2] fix: forgot visual mode keymaps --- internal/input/keymap.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 08a0d7b..4a20ec1 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -104,6 +104,12 @@ func NewVisualKeymap() *Keymap { "p": action.VisualPaste{Count: 1}, // ":": action.EnterComandMode{}, // Different OP }, + charMotions: map[string]action.Motion{ + "f": action.FindChar{Forward: true, Inclusive: true}, + "F": action.FindChar{Forward: false, Inclusive: true}, + "t": action.FindChar{Forward: true, Inclusive: false}, + "T": action.FindChar{Forward: false, Inclusive: false}, + }, } } -- 2.47.2