diff --git a/internal/editor/integration_motion_jump_test.go b/internal/editor/integration_motion_jump_test.go index eaee020..7e3f1de 100644 --- a/internal/editor/integration_motion_jump_test.go +++ b/internal/editor/integration_motion_jump_test.go @@ -385,3 +385,343 @@ func TestMoveToLineContentStartAlias(t *testing.T) { } }) } + +// --- | (Pipe / Move to Column) Tests --- +// In Vim, | moves to column N (1-indexed). So: +// - | = 1| = column 1 = index 0 +// - 5| = column 5 = index 4 +// - 10| = column 10 = index 9 + +func TestMoveToColumn(t *testing.T) { + t.Run("test '|' alone goes to column 1 (index 0)", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "|") + + m := getFinalModel(t, tm) + // | with no count = 1| = column 1 = index 0 + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test '1|' goes to column 1 (index 0)", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "1", "|") + + m := getFinalModel(t, tm) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test '5|' goes to column 5 (index 4)", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "5", "|") + + m := getFinalModel(t, tm) + // Column 5 = index 4 (the 'o' in hello) + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '10|' goes to column 10 (index 9)", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "1", "0", "|") + + m := getFinalModel(t, tm) + // Column 10 = index 9 (the 'l' in world) + if m.CursorX() != 9 { + t.Errorf("CursorX() = %d, want 9", m.CursorX()) + } + }) + + t.Run("test '|' already at column 1", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "|") + + m := getFinalModel(t, tm) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test '5|' already at column 5", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0}) + sendKeys(tm, "5", "|") + + m := getFinalModel(t, tm) + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) +} + +func TestMoveToColumnClamp(t *testing.T) { + t.Run("test '20|' clamps to end of short line", func(t *testing.T) { + lines := []string{"hello"} // 5 chars, max index 4 + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "2", "0", "|") + + m := getFinalModel(t, tm) + // Column 20 exceeds line length, should clamp to last char (index 4) + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '100|' clamps to end of line", func(t *testing.T) { + lines := []string{"hello world"} // 11 chars, max index 10 + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "1", "0", "0", "|") + + m := getFinalModel(t, tm) + // Should clamp to last char (index 10) + if m.CursorX() != 10 { + t.Errorf("CursorX() = %d, want 10", m.CursorX()) + } + }) + + t.Run("test '6|' clamps on 5-char line", func(t *testing.T) { + lines := []string{"hello"} // 5 chars + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "6", "|") + + m := getFinalModel(t, tm) + // Column 6 = index 5, but line only has 5 chars (max index 4) + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '|' on empty line stays at 0", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "|") + + m := getFinalModel(t, tm) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test '5|' on empty line stays at 0", func(t *testing.T) { + lines := []string{""} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "5", "|") + + m := getFinalModel(t, tm) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test '3|' on 2-char line clamps", func(t *testing.T) { + lines := []string{"ab"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "3", "|") + + m := getFinalModel(t, tm) + // Column 3 = index 2, but line only has 2 chars (max index 1) + if m.CursorX() != 1 { + t.Errorf("CursorX() = %d, want 1", m.CursorX()) + } + }) +} + +func TestMoveToColumnPreservesLine(t *testing.T) { + t.Run("test '|' preserves Y position", func(t *testing.T) { + lines := []string{"line one", "line two", "line three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 1}) + sendKeys(tm, "|") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + }) + + t.Run("test '5|' preserves Y position", func(t *testing.T) { + lines := []string{"line one", "line two", "line three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2}) + sendKeys(tm, "5", "|") + + m := getFinalModel(t, tm) + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + }) + + t.Run("test '|' on different lines", func(t *testing.T) { + lines := []string{"short", "longer line here"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1}) + sendKeys(tm, "|") + + m := getFinalModel(t, tm) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) +} + +func TestMoveToColumnWithWhitespace(t *testing.T) { + t.Run("test '5|' with leading whitespace", func(t *testing.T) { + lines := []string{" hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "5", "|") + + m := getFinalModel(t, tm) + // Column 5 = index 4 = 'h' in " hello" + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '3|' lands on whitespace", func(t *testing.T) { + lines := []string{" hello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "3", "|") + + m := getFinalModel(t, tm) + // Column 3 = index 2 = third space + if m.CursorX() != 2 { + t.Errorf("CursorX() = %d, want 2", m.CursorX()) + } + }) + + t.Run("test '|' with tabs", func(t *testing.T) { + lines := []string{"\thello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0}) + sendKeys(tm, "|") + + m := getFinalModel(t, tm) + // | goes to column 1 = index 0 = the tab + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test '2|' with tabs", func(t *testing.T) { + lines := []string{"\thello"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "2", "|") + + m := getFinalModel(t, tm) + // Column 2 = index 1 = 'h' in "\thello" + if m.CursorX() != 1 { + t.Errorf("CursorX() = %d, want 1", m.CursorX()) + } + }) +} + +func TestMoveToColumnWithOperator(t *testing.T) { + t.Run("test 'd|' deletes to column 1", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "d", "|") + + m := getFinalModel(t, tm) + // Deletes from column 1 to current position (exclusive), so "hello" deleted + // Result depends on inclusive/exclusive behavior + // In Vim: d| from col 5 deletes chars 0-4, leaving " world" + if m.Line(0) != " world" { + t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) + } + }) + + t.Run("test 'd5|' deletes to column 5", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "5", "|") + + m := getFinalModel(t, tm) + // Deletes from cursor (0) to column 5 (index 4), so "hell" deleted + // Result: "o world" + if m.Line(0) != "o world" { + t.Errorf("Line(0) = %q, want 'o world'", m.Line(0)) + } + }) + + t.Run("test 'y5|' yanks to column 5", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "y", "5", "|") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // Should yank "hell" (indices 0-3, up to but not including col 5) + if len(reg.Content) != 1 || reg.Content[0] != "hell" { + t.Errorf("register content = %q, want 'hell'", reg.Content) + } + }) + + t.Run("test 'y|' yanks to column 1 (nothing if at start)", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "y", "|") + + m := getFinalModel(t, tm) + reg, ok := m.GetRegister('"') + if !ok { + t.Fatal("unnamed register not found") + } + // At col 0, y| should yank nothing (empty string) + if len(reg.Content) != 1 || reg.Content[0] != "" { + t.Errorf("register content = %q, want ''", reg.Content) + } + }) +} + +func TestMoveToColumnInVisualMode(t *testing.T) { + t.Run("test 'v5|' selects to column 5", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "v", "5", "|") + + m := getFinalModel(t, tm) + if m.AnchorX() != 0 { + t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) + } + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test 'v|' selects backward to column 1", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0}) + sendKeys(tm, "v", "|") + + m := getFinalModel(t, tm) + if m.AnchorX() != 5 { + t.Errorf("AnchorX() = %d, want 5", m.AnchorX()) + } + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test 'v5|d' deletes selection to column 5", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0}) + sendKeys(tm, "v", "5", "|", "d") + + m := getFinalModel(t, tm) + // Visual selection from 0 to 4 inclusive, delete "hello" + if m.Line(0) != " world" { + t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) + } + }) +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 6a9f0a8..51b2386 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -26,6 +26,7 @@ func NewNormalKeymap() *Keymap { "$": motion.MoveToLineEnd{}, "_": motion.MoveToLineContentStart{}, "^": motion.MoveToLineContentStart{}, + "|": motion.MoveToColumn{Count: 0}, "w": motion.MoveForwardWord{Count: 1}, "e": motion.MoveForwardWordEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, @@ -71,6 +72,7 @@ func NewVisualKeymap() *Keymap { "$": motion.MoveToLineEnd{}, "_": motion.MoveToLineContentStart{}, "^": motion.MoveToLineContentStart{}, + "|": motion.MoveToColumn{Count: 0}, "w": motion.MoveForwardWord{Count: 1}, "e": motion.MoveForwardWordEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, diff --git a/internal/motion/jump.go b/internal/motion/jump.go index 5fb1b59..ab85204 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -74,6 +74,26 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd { func (a MoveToLineContentStart) Type() action.MotionType { return action.CharwiseExclusive } +// MoveToColumn implements Motion (|) - charwise +type MoveToColumn struct { + Count int +} + +func (a MoveToColumn) Execute(m action.Model) tea.Cmd { + line := m.Line(m.CursorY()) + col := min(a.Count-1, len(line)-1) + + m.SetCursorX(col) + m.ClampCursorX() + return nil +} + +func (a MoveToColumn) Type() action.MotionType { return action.CharwiseExclusive } + +func (a MoveToColumn) WithCount(n int) action.Action { + return MoveToColumn{Count: n} +} + // TODO: Count for these, maybe? // ScrollDownHalfPage implements Motion (ctrl+d) - linewise