feat: added support for full page jumping
This commit is contained in:
parent
a9dd5c008f
commit
0767ee0982
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user