From 0767ee0982ba0a0b9b7915823936ad02627cea54 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sun, 5 Apr 2026 00:00:40 -0700 Subject: [PATCH] feat: added support for full page jumping --- internal/editor/integration_scroll_test.go | 265 +++++++++++++++++++++ internal/input/keymap.go | 50 ++-- internal/motion/jump.go | 28 ++- 3 files changed, 310 insertions(+), 33 deletions(-) diff --git a/internal/editor/integration_scroll_test.go b/internal/editor/integration_scroll_test.go index 7906acc..0622227 100644 --- a/internal/editor/integration_scroll_test.go +++ b/internal/editor/integration_scroll_test.go @@ -412,6 +412,271 @@ func TestHalfPageScrollRoundTrip(t *testing.T) { }) } +// Tests use terminal 80x30: viewportH=28, full-scroll=28, scrollOff=8, safe zone relY 8-19. + +func TestFullPageScrollDown(t *testing.T) { + t.Run("ctrl+f scrolls viewport down by full page", func(t *testing.T) { + // cursor at line 15 (relY=15, in safe zone), scrollY starts at 0 + // After ctrl+f: newScrollY=28, newCursorY=28+15=43 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+f") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 28 { + t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 43 { + t.Errorf("CursorY() = %d, want 43", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("ctrl+f preserves cursor relative position in viewport", func(t *testing.T) { + // relY=15 before and after + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+f") + + m := getFinalModel(t, tm) + relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY + if relY != 15 { + t.Errorf("relative position = %d, want 15", relY) + } + }) + + t.Run("ctrl+f clamps cursor to scrollOff when cursor is near top", func(t *testing.T) { + // cursor at line 0 (relY=0 < scrollOff=8), clamp to scrollOff + // After ctrl+f: newScrollY=28, newCursorY=28+8=36 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 30) + sendKeys(tm, "ctrl+f") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 28 { + t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 36 { + t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("ctrl+f at end of file does not scroll past max", func(t *testing.T) { + // 50 lines, viewport 28: maxScroll=22 + // cursor at line 45: AdjustScroll puts cursor at scrollY=22 (clamped), relY=23 + // After ctrl+f: newScrollY clamped to 22, relY=23>19 clamped to 19, newCursorY=41 + lines := generateLines(50) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 45}, 80, 30) + sendKeys(tm, "ctrl+f") + + m := getFinalModel(t, tm) + maxScroll := 50 - 28 + if m.ActiveWindow().ScrollY > maxScroll { + t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll) + } + if m.ActiveWindow().Cursor.Line != 41 { + t.Errorf("CursorY() = %d, want 41", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("ctrl+f on file smaller than viewport does not crash", func(t *testing.T) { + // 20 lines < viewport 28: maxScroll=0, scrollY stays 0 + // relY=0 < scrollOff, clamp to 8; newCursorY=0+8=8 + lines := generateLines(20) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 30) + sendKeys(tm, "ctrl+f") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 0 { + t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 8 { + t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("ctrl+f clamps cursor x to new line length", func(t *testing.T) { + // Lines 0-27: "hello world" (11 chars), lines 28-99: "hi" (2 chars) + // cursor at line 5, cursorX=10 + // ctrl+f: relY=5<8 clamp to 8; newScrollY=28; newCursorY=36 (a "hi" line) + // ClampCursorX: 10 >= 2, so cursorX=2 + lines := make([]string, 100) + for i := range 28 { + lines[i] = "hello world" + } + for i := 28; i < 100; i++ { + lines[i] = "hi" + } + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 10, Line: 5}, 80, 30) + sendKeys(tm, "ctrl+f") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Cursor.Line != 36 { + t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col > m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line].Len() { + t.Errorf("CursorX() = %d exceeds line length %d", m.ActiveWindow().Cursor.Col, m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line].Len()) + } + }) + + t.Run("multiple ctrl+f presses scroll incrementally", func(t *testing.T) { + // cursor at line 15, scrollY=0, relY=15 + // ctrl+f #1: scrollY=28, cursorY=43, relY=15 + // ctrl+f #2: scrollY=56, cursorY=71, relY=15 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+f", "ctrl+f") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 56 { + t.Errorf("ScrollY() = %d, want 56", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 71 { + t.Errorf("CursorY() = %d, want 71", m.ActiveWindow().Cursor.Line) + } + }) +} + +func TestFullPageScrollUp(t *testing.T) { + t.Run("ctrl+b scrolls viewport up by full page", func(t *testing.T) { + // cursor at line 50: AdjustScroll -> scrollY=31, relY=19 + // After ctrl+b: newScrollY=3, newCursorY=3+19=22 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) + sendKeys(tm, "ctrl+b") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 3 { + t.Errorf("ScrollY() = %d, want 3", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 22 { + t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("ctrl+b preserves cursor relative position in viewport", func(t *testing.T) { + // relY=19 before and after + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) + sendKeys(tm, "ctrl+b") + + m := getFinalModel(t, tm) + relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY + if relY != 19 { + t.Errorf("relative position = %d, want 19", relY) + } + }) + + t.Run("ctrl+b at top of file does not make scrollY negative", func(t *testing.T) { + // cursor at line 20, scrollY=0, relY=20 (clamped to 19 by scrollOff) + // AdjustScroll: relY=20>19, clamp to 19, scrollY stays 0, cursorY=19 + // ctrl+b: newScrollY=max(0,-28)=0, relY=19 preserved, cursorY=19 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 20}, 80, 30) + sendKeys(tm, "ctrl+b") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY < 0 { + t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().ScrollY != 0 { + t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) + } + // Cursor should be clamped to safe zone bottom (relY=19) + if m.ActiveWindow().Cursor.Line != 19 { + t.Errorf("CursorY() = %d, want 19", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("ctrl+b clamps cursor to scrollOff when cursor is near top of viewport", func(t *testing.T) { + // cursor at line 30, scrollY=0, relY=30>19 -> clamp to 19, cursorY=19 + // ctrl+b: newScrollY=0; relY=19; newCursorY=19 + lines := generateLines(100) + // First normalize the position + m := getFinalModel(t, newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 30}, 80, 30)) + initialY := m.ActiveWindow().Cursor.Line + + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: initialY}, 80, 30) + sendKeys(tm, "ctrl+b") + + m2 := getFinalModel(t, tm) + if m2.ActiveWindow().ScrollY != 0 { + t.Errorf("ScrollY() = %d, want 0", m2.ActiveWindow().ScrollY) + } + // Cursor should remain in safe zone + relY := m2.ActiveWindow().Cursor.Line - m2.ActiveWindow().ScrollY + if relY < 8 || relY > 19 { + t.Errorf("relY = %d, want in range [8, 19]", relY) + } + }) + + t.Run("multiple ctrl+b presses scroll incrementally", func(t *testing.T) { + // cursor at line 80: AdjustScroll -> scrollY=61, relY=19 + // ctrl+b #1: newScrollY=33, cursorY=52 + // ctrl+b #2: newScrollY=5, cursorY=24 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 80}, 80, 30) + sendKeys(tm, "ctrl+b", "ctrl+b") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 5 { + t.Errorf("ScrollY() = %d, want 5", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 24 { + t.Errorf("CursorY() = %d, want 24", m.ActiveWindow().Cursor.Line) + } + }) +} + +func TestFullPageScrollRoundTrip(t *testing.T) { + t.Run("ctrl+f then ctrl+b returns cursor to original position", func(t *testing.T) { + // cursor at line 15, scrollY=0, relY=15 + // ctrl+f: scrollY=28, cursorY=43, relY=15 + // ctrl+b: newScrollY=max(0,28-28)=0, cursorY=0+15=15 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+f", "ctrl+b") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 0 { + t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 15 { + t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("ctrl+b then ctrl+f returns cursor to original position", func(t *testing.T) { + // cursor at line 50: AdjustScroll -> scrollY=31, relY=19 + // ctrl+b: scrollY=3, cursorY=22, relY=19 + // ctrl+f: scrollY=31, cursorY=50, relY=19 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) + sendKeys(tm, "ctrl+b", "ctrl+f") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 31 { + t.Errorf("ScrollY() = %d, want 31", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 50 { + t.Errorf("CursorY() = %d, want 50", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("alternating ctrl+f and ctrl+b maintains scroll stability", func(t *testing.T) { + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+f", "ctrl+b", "ctrl+f", "ctrl+b") + + m := getFinalModel(t, tm) + if m.ActiveWindow().ScrollY != 0 { + t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ActiveWindow().ScrollY) + } + if m.ActiveWindow().Cursor.Line != 15 { + t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.ActiveWindow().Cursor.Line) + } + }) +} + func TestScrollWithCount(t *testing.T) { t.Run("5j scrolls appropriately", func(t *testing.T) { lines := generateLines(50) diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 4876bed..a2cfc3e 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -41,8 +41,10 @@ func NewNormalKeymap() *Keymap { "B": motion.MoveBackwardWORD{Count: 1}, "ge": motion.MoveBackwardWordEnd{Count: 1}, "gE": motion.MoveBackwardWORDEnd{Count: 1}, - "ctrl+u": motion.ScrollUpHalfPage{}, - "ctrl+d": motion.ScrollDownHalfPage{}, + "ctrl+u": motion.ScrollUpPage{Divisor: 2}, + "ctrl+d": motion.ScrollDownPage{Divisor: 2}, + "ctrl+b": motion.ScrollUpPage{Divisor: 1}, + "ctrl+f": motion.ScrollDownPage{Divisor: 1}, ";": action.RepeatFind{Count: 1, Reverse: false}, ",": action.RepeatFind{Count: 1, Reverse: true}, }, @@ -111,25 +113,31 @@ func NewNormalKeymap() *Keymap { func NewVisualKeymap() *Keymap { return &Keymap{ motions: map[string]action.Motion{ - "j": motion.MoveDown{Count: 1}, - "k": motion.MoveUp{Count: 1}, - "h": motion.MoveLeft{Count: 1}, - "l": motion.MoveRight{Count: 1}, - "G": motion.MoveToBottom{}, - "gg": motion.MoveToTop{}, - "0": motion.MoveToLineStart{}, - "$": motion.MoveToLineEnd{}, - "_": motion.MoveToLineContentStart{}, - "^": motion.MoveToLineContentStart{}, - "|": motion.MoveToColumn{Count: 0}, - "w": motion.MoveForwardWord{Count: 1}, - "W": motion.MoveForwardWORD{Count: 1}, - "e": motion.MoveForwardWordEnd{Count: 1}, - "E": motion.MoveForwardWORDEnd{Count: 1}, - "b": motion.MoveBackwardWord{Count: 1}, - "B": motion.MoveBackwardWORD{Count: 1}, - "ge": motion.MoveBackwardWordEnd{Count: 1}, - "gE": motion.MoveBackwardWORDEnd{Count: 1}, + "j": motion.MoveDown{Count: 1}, + "k": motion.MoveUp{Count: 1}, + "h": motion.MoveLeft{Count: 1}, + "l": motion.MoveRight{Count: 1}, + "G": motion.MoveToBottom{}, + "gg": motion.MoveToTop{}, + "0": motion.MoveToLineStart{}, + "$": motion.MoveToLineEnd{}, + "_": motion.MoveToLineContentStart{}, + "^": motion.MoveToLineContentStart{}, + "|": motion.MoveToColumn{Count: 0}, + "w": motion.MoveForwardWord{Count: 1}, + "W": motion.MoveForwardWORD{Count: 1}, + "e": motion.MoveForwardWordEnd{Count: 1}, + "E": motion.MoveForwardWORDEnd{Count: 1}, + "b": motion.MoveBackwardWord{Count: 1}, + "B": motion.MoveBackwardWORD{Count: 1}, + "ge": motion.MoveBackwardWordEnd{Count: 1}, + "gE": motion.MoveBackwardWORDEnd{Count: 1}, + "ctrl+u": motion.ScrollUpPage{Divisor: 2}, + "ctrl+d": motion.ScrollDownPage{Divisor: 2}, + "ctrl+b": motion.ScrollUpPage{Divisor: 1}, + "ctrl+f": motion.ScrollDownPage{Divisor: 1}, + ";": action.RepeatFind{Count: 1, Reverse: false}, + ",": action.RepeatFind{Count: 1, Reverse: true}, // TODO: O and o. These are fun ones! Should be simple too }, operators: map[string]action.Operator{ diff --git a/internal/motion/jump.go b/internal/motion/jump.go index 0f6cb94..98e3937 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -111,21 +111,23 @@ func (a MoveToColumn) WithCount(n int) action.Action { // TODO: Count for these, maybe? -// ScrollDownHalfPage implements Motion (ctrl+d) - linewise -type ScrollDownHalfPage struct{} +// ScrollDownPage implements Motion (ctrl+d) - linewise +type ScrollDownPage struct { + Divisor int +} // ScrollDownHalfPage.Execute: Scrolls down half a page while maintaining the // cursor's relative position in the viewport. -func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd { +func (a ScrollDownPage) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() - viewportHeight := win.Height - 2 + viewportHeight := win.ViewportHeight() if viewportHeight <= 0 { return nil } - scroll := viewportHeight / 2 + scroll := viewportHeight / a.Divisor scrollOff := win.Options.ScrollOff // Current relative position in viewport @@ -152,22 +154,24 @@ func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd { return nil } -func (a ScrollDownHalfPage) Type() core.MotionType { return core.Linewise } +func (a ScrollDownPage) Type() core.MotionType { return core.Linewise } -// ScrollUpHalfPage implements Motion (ctrl+u) - linewise -type ScrollUpHalfPage struct{} +// ScrollUpPage implements Motion (ctrl+u) - linewise +type ScrollUpPage struct { + Divisor int +} // ScrollUpHalfPage.Execute: Scrolls up half a page while maintaining the // cursor's relative position in the viewport. -func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd { +func (a ScrollUpPage) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() - viewportHeight := win.Height - 2 + viewportHeight := win.ViewportHeight() if viewportHeight <= 0 { return nil } - scroll := viewportHeight / 2 + scroll := viewportHeight / a.Divisor scrollOff := win.Options.ScrollOff // Current relative position in viewport @@ -193,4 +197,4 @@ func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd { return nil } -func (a ScrollUpHalfPage) Type() core.MotionType { return core.Linewise } +func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }