diff --git a/internal/editor/integration_motion_jump_test.go b/internal/editor/integration_motion_jump_test.go index bb3e054..09ac491 100644 --- a/internal/editor/integration_motion_jump_test.go +++ b/internal/editor/integration_motion_jump_test.go @@ -14,8 +14,8 @@ func TestMoveToBottom(t *testing.T) { sendKeys(tm, "G") m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) + if m.CursorY() != 5 { + t.Errorf("CursorY() = %d, want 5", m.CursorY()) } }) @@ -24,8 +24,8 @@ func TestMoveToBottom(t *testing.T) { sendKeys(tm, "G") m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) + if m.CursorY() != 5 { + t.Errorf("CursorY() = %d, want 5", m.CursorY()) } }) @@ -34,23 +34,23 @@ func TestMoveToBottom(t *testing.T) { sendKeys(tm, "G") m := getFinalModel(t, tm) - if m.cursor.y != 5 { - t.Errorf("cursor.y = %d, want 5", m.cursor.y) + if m.CursorY() != 5 { + t.Errorf("CursorY() = %d, want 5", m.CursorY()) } }) - t.Run("test 'G' clamps cursor.x", func(t *testing.T) { + t.Run("test 'G' clamps CursorX()", func(t *testing.T) { lines := []string{"long line here", "short"} tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) sendKeys(tm, "G") m := getFinalModel(t, tm) - if m.cursor.y != 1 { - t.Errorf("cursor.y = %d, want 1", m.cursor.y) + if m.CursorY() != 1 { + t.Errorf("CursorY() = %d, want 1", m.CursorY()) } want := len(lines[1]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + if m.CursorX() != want { + t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) } }) @@ -60,8 +60,8 @@ func TestMoveToBottom(t *testing.T) { sendKeys(tm, "G") m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) } }) } @@ -72,8 +72,8 @@ func TestMoveToTop(t *testing.T) { sendKeys(tm, "g", "g") m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) } }) @@ -82,8 +82,8 @@ func TestMoveToTop(t *testing.T) { sendKeys(tm, "g", "g") m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) } }) @@ -92,28 +92,28 @@ func TestMoveToTop(t *testing.T) { sendKeys(tm, "g", "g") m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) } }) - t.Run("test 'gg' clamps cursor.x", func(t *testing.T) { + t.Run("test 'gg' clamps CursorX()", func(t *testing.T) { lines := []string{"short", "long line here"} tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1}) sendKeys(tm, "g", "g") m := getFinalModel(t, tm) - if m.cursor.y != 0 { - t.Errorf("cursor.y = %d, want 0", m.cursor.y) + if m.CursorY() != 0 { + t.Errorf("CursorY() = %d, want 0", m.CursorY()) } want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + if m.CursorX() != want { + t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) } }) } -// --- 0 and $ Tests --- +// --- 0, $ and _ Tests --- func TestMoveToLineStart(t *testing.T) { t.Run("test '0' from middle of line", func(t *testing.T) { @@ -121,8 +121,8 @@ func TestMoveToLineStart(t *testing.T) { sendKeys(tm, "0") m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) } }) @@ -132,8 +132,8 @@ func TestMoveToLineStart(t *testing.T) { sendKeys(tm, "0") m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) } }) @@ -142,8 +142,8 @@ func TestMoveToLineStart(t *testing.T) { sendKeys(tm, "0") m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) } }) @@ -153,8 +153,8 @@ func TestMoveToLineStart(t *testing.T) { sendKeys(tm, "0") m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) } }) @@ -163,8 +163,8 @@ func TestMoveToLineStart(t *testing.T) { sendKeys(tm, "0") m := getFinalModel(t, tm) - if m.cursor.y != 2 { - t.Errorf("cursor.y = %d, want 2", m.cursor.y) + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) } }) } @@ -177,8 +177,8 @@ func TestMoveToLineEnd(t *testing.T) { m := getFinalModel(t, tm) want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + if m.CursorX() != want { + t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) } }) @@ -189,8 +189,8 @@ func TestMoveToLineEnd(t *testing.T) { m := getFinalModel(t, tm) want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + if m.CursorX() != want { + t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) } }) @@ -201,8 +201,8 @@ func TestMoveToLineEnd(t *testing.T) { m := getFinalModel(t, tm) want := len(lines[0]) - if m.cursor.x != want { - t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) + if m.CursorX() != want { + t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) } }) @@ -212,8 +212,8 @@ func TestMoveToLineEnd(t *testing.T) { sendKeys(tm, "$") m := getFinalModel(t, tm) - if m.cursor.x != 0 { - t.Errorf("cursor.x = %d, want 0", m.cursor.x) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) } }) @@ -222,8 +222,87 @@ func TestMoveToLineEnd(t *testing.T) { sendKeys(tm, "$") m := getFinalModel(t, tm) - if m.cursor.y != 2 { - t.Errorf("cursor.y = %d, want 2", m.cursor.y) + if m.CursorY() != 2 { + t.Errorf("CursorY() = %d, want 2", m.CursorY()) + } + }) +} + +func TestMoveToLineContentStart(t *testing.T) { + t.Run("test '_' from middle of line with no leading whitespace", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0}) + sendKeys(tm, "_") + + m := getFinalModel(t, tm) + if m.CursorX() != 0 { + t.Errorf("CursorX() = %d, want 0", m.CursorX()) + } + }) + + t.Run("test '_' from middle of line with leading whitespace", func(t *testing.T) { + lines := []string{" hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0}) + sendKeys(tm, "_") + + m := getFinalModel(t, tm) + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '_' from start of line with leading whitespace", 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() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '_' from start of line with no leading whitespace", 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 '_' from middle of line with only whitespace", func(t *testing.T) { + lines := []string{" "} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0}) + sendKeys(tm, "_") + + m := getFinalModel(t, tm) + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '_' from end of line with only whitespace", func(t *testing.T) { + lines := []string{" "} + tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0}) + sendKeys(tm, "_") + + m := getFinalModel(t, tm) + if m.CursorX() != 4 { + t.Errorf("CursorX() = %d, want 4", m.CursorX()) + } + }) + + t.Run("test '_' on empty line", 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()) } }) } diff --git a/internal/input/keymap.go b/internal/input/keymap.go index a6dcc6c..473ae3b 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -22,6 +22,7 @@ func NewNormalKeymap() *Keymap { "gg": motion.MoveToTop{}, "0": motion.MoveToLineStart{}, "$": motion.MoveToLineEnd{}, + "_": motion.MoveToLineContentStart{}, "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 30a5e1c..da40276 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -1,8 +1,8 @@ package motion import ( - tea "github.com/charmbracelet/bubbletea" "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" ) // MoveToTop implements Motion (gg) @@ -40,3 +40,26 @@ func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd { m.ClampCursorX() return nil } + +// MoveToLineContentStart implements Motion (_) +type MoveToLineContentStart struct{} + +func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd { + line := m.Line(m.CursorY()) + x := 0 + for x < len(line) { + ch := line[x] + if ch != ' ' && ch != '\t' { + break + } + x++ + } + + // If we are on the last char, we overflew, back once + if x == len(line) && x > 0 { + x-- + } + + m.SetCursorX(x) + return nil +}