feat: implementing vertical scrolling, tested

Heavy on the AI...
This commit is contained in:
Hayden Hargreaves 2026-02-12 22:40:17 -07:00
parent c2b5e6f67c
commit db70ca39f1
6 changed files with 267 additions and 18 deletions

View File

@ -31,6 +31,10 @@ type Model interface {
SetCursorY(y int)
ClampCursorX()
// Window
ScrollY() int
SetScrollY(y int)
// Anchor
AnchorX() int
AnchorY() int
@ -43,6 +47,7 @@ type Model interface {
// Settings
TabSize() int
ScrollOff() int
// Mode
Mode() Mode

View File

@ -52,6 +52,10 @@ func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos action.
return teatest.NewTestModel(t, NewModel(lines, pos), teatest.WithInitialTermSize(80, 24))
}
func newTestModelWithTermSize(t *testing.T, lines []string, pos action.Position, width, height int) *teatest.TestModel {
return teatest.NewTestModel(t, NewModel(lines, pos), teatest.WithInitialTermSize(width, height))
}
// getFinalModel extracts the final model state (sends ctrl+c to quit first)
func getFinalModel(t *testing.T, tm *teatest.TestModel) Model {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})

View File

@ -0,0 +1,187 @@
package editor
import (
"fmt"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// NOTE: AI Generated tests
// generateLines creates n lines of content
func generateLines(n int) []string {
lines := make([]string, n)
for i := range n {
lines[i] = fmt.Sprintf("line %d", i+1)
}
return lines
}
func TestScrollBasic(t *testing.T) {
t.Run("small file does not scroll", func(t *testing.T) {
// 10 lines, viewport 24 -> no scrolling needed
lines := generateLines(10)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 24)
sendKeys(tm, "G") // go to bottom
m := getFinalModel(t, tm)
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
}
})
t.Run("scrolls down when cursor moves past bottom margin", func(t *testing.T) {
// 50 lines, viewport 20, scrollOff 8
// Viewport shows lines 0-18 (19 lines, -1 for status bar)
// Safe zone: lines 8 to 10 (19-1-8=10)
// Moving to line 11+ should trigger scroll
lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
// Move down 15 times to get to line 15
for range 15 {
sendKeys(tm, "j")
}
m := getFinalModel(t, tm)
if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15", m.CursorY())
}
// With scrollOff=8, viewport=19, cursor at 15 means:
// cursor should be at position 10 from top (19-1-8=10)
// so scrollY = 15 - 10 = 5
if m.ScrollY() < 1 {
t.Errorf("ScrollY() = %d, want > 0 (should have scrolled)", m.ScrollY())
}
})
t.Run("scrolls up when cursor moves past top margin", func(t *testing.T) {
// Start at line 20, move up
lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 20}, 80, 20)
// First, let the model adjust (it will scroll to show cursor)
// Then move up 15 times
for range 15 {
sendKeys(tm, "k")
}
m := getFinalModel(t, tm)
if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY())
}
// Cursor at line 5 with scrollOff=8 means scrollY should be 0
// (can't scroll negative, and cursor is within safe zone from top)
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
}
})
t.Run("G jumps to bottom and scrolls", func(t *testing.T) {
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.CursorY() != 99 {
t.Errorf("CursorY() = %d, want 99", m.CursorY())
}
// With 100 lines and viewport 19, max scrollY = 100 - 19 = 81
// Cursor at 99 with scrollOff=8 means cursor at position 10 from top
// scrollY = 99 - 10 = 89, but clamped to maxScroll = 81
if m.ScrollY() != 81 {
t.Errorf("ScrollY() = %d, want 81", m.ScrollY())
}
})
t.Run("gg jumps to top and scrolls", func(t *testing.T) {
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 20)
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY())
}
if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
}
})
}
func TestScrollEdgeCases(t *testing.T) {
t.Run("scrollY never goes negative", func(t *testing.T) {
lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
// Try to move up from top
for range 5 {
sendKeys(tm, "k")
}
m := getFinalModel(t, tm)
if m.ScrollY() < 0 {
t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY())
}
})
t.Run("scrollY clamped to max scroll", func(t *testing.T) {
lines := generateLines(30)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G")
m := getFinalModel(t, tm)
// 30 lines, viewport 19 -> maxScroll = 30 - 19 = 11
maxScroll := 30 - 19
if m.ScrollY() > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll)
}
})
t.Run("cursor stays visible after delete at bottom", func(t *testing.T) {
lines := generateLines(30)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 29}, 80, 20)
// Delete some lines at bottom
sendKeys(tm, "d", "d", "d", "d")
m := getFinalModel(t, tm)
// Cursor should still be visible
viewportHeight := 19
if m.CursorY() < m.ScrollY() || m.CursorY() >= m.ScrollY()+viewportHeight {
t.Errorf("Cursor at %d not visible in viewport [%d, %d)",
m.CursorY(), m.ScrollY(), m.ScrollY()+viewportHeight)
}
})
}
func TestScrollWithCount(t *testing.T) {
t.Run("5j scrolls appropriately", func(t *testing.T) {
lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 5}, 80, 20)
sendKeys(tm, "1", "0", "j") // move down 10 lines
m := getFinalModel(t, tm)
if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15", m.CursorY())
}
// Should have scrolled since we moved past the safe zone
if m.ScrollY() == 0 {
t.Errorf("ScrollY() = %d, want > 0", m.ScrollY())
}
})
t.Run("5k scrolls appropriately", func(t *testing.T) {
lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 25}, 80, 20)
sendKeys(tm, "1", "5", "k") // move up 15 lines
m := getFinalModel(t, tm)
if m.CursorY() != 10 {
t.Errorf("CursorY() = %d, want 10", m.CursorY())
}
})
}

View File

@ -17,6 +17,7 @@ type Model struct {
lines []string
cursor cursor
anchor cursor // starting point for visual modes
scrollY int
mode action.Mode
win_h int
win_w int
@ -31,6 +32,7 @@ type Model struct {
// Settings
gutterSize int
tabSize int
scrollOff int
}
func NewModel(lines []string, pos action.Position) Model {
@ -40,11 +42,14 @@ func NewModel(lines []string, pos action.Position) Model {
x: pos.Col,
y: pos.Line,
},
gutterSize: 5,
tabSize: 2,
scrollY: 0,
mode: action.NormalMode,
command: "",
input: input.NewHandler(),
gutterSize: 5,
tabSize: 2,
scrollOff: 8,
}
}
@ -138,6 +143,19 @@ func (m *Model) TabSize() int {
return m.tabSize
}
func (m *Model) ScrollOff() int {
return m.scrollOff
}
// Window
func (m *Model) ScrollY() int {
return m.scrollY
}
func (m *Model) SetScrollY(y int) {
m.scrollY = y
}
func (m *Model) ClampCursorX() {
lineLen := len(m.lines[m.cursor.y])
if lineLen == 0 {
@ -147,6 +165,32 @@ func (m *Model) ClampCursorX() {
}
}
// AdjustScroll ensures the cursor stays within the viewport with scrollOff margins.
// Call this after any cursor movement.
func (m *Model) AdjustScroll() {
viewportHeight := m.win_h - 1 // -1 for status bar
if viewportHeight <= 0 {
return
}
// Effective scrollOff (can't be more than half the viewport)
off := min(m.ScrollOff(), viewportHeight/2)
// Cursor too close to top — scroll up
if m.CursorY() < m.ScrollY()+off {
m.SetScrollY(m.CursorY() - off)
}
// Cursor too close to bottom — scroll down
if m.CursorY() > m.ScrollY()+viewportHeight-1-off {
m.SetScrollY(m.CursorY() - viewportHeight + 1 + off)
}
// Clamp scrollY to valid range
maxScroll := max(0, m.LineCount()-viewportHeight)
m.SetScrollY(max(0, min(m.ScrollY(), maxScroll)))
}
func (m *Model) Mode() action.Mode {
return m.mode
}

View File

@ -6,6 +6,8 @@ import (
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@ -24,7 +26,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
action.VisualMode,
action.VisualBlockMode,
action.VisualLineMode:
return m, m.input.Handle(&m, msg.String())
cmd = m.input.Handle(&m, msg.String())
// The only one left to migrate!
case action.CommandMode:
@ -39,5 +41,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
return m, nil
// Keep cursor in view after any update
m.AdjustScroll()
return m, cmd
}

View File

@ -59,23 +59,27 @@ func posIsAnchor(m Model, col, line int) bool {
func (m Model) View() string {
var view strings.Builder
for y := 0; y < m.win_h-1; y++ {
viewportHeight := m.win_h - 1 // -1 for status bar
start := m.ScrollY()
end := m.ScrollY() + viewportHeight
if y < len(m.lines) {
for i := start; i < end; i++ {
if i < m.LineCount() {
var (
gutter string
currentLine bool = false
lineNumber int
)
if y > m.cursor.y {
lineNumber = y - m.cursor.y
if i > m.CursorY() {
lineNumber = i - m.CursorY()
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
} else if y < m.cursor.y {
lineNumber = m.cursor.y - y
} else if i < m.CursorY() {
lineNumber = m.CursorY() - i
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
} else {
lineNumber = y + 1
lineNumber = i + 1
currentLine = true
if lineNumber < 100 {
gutter = fmt.Sprintf("%*d ", m.gutterSize-2, lineNumber)
@ -85,24 +89,24 @@ func (m Model) View() string {
}
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
runes := []rune(m.lines[y])
runes := []rune(m.Line(i))
for x := 0; x <= len(runes); x++ {
if m.cursor.y == y && m.cursor.x == x {
if m.CursorY() == i && m.CursorX() == x {
if x < len(runes) {
view.WriteString(m.cursorStyle().Render(string(runes[x])))
} else {
view.WriteString(m.cursorStyle().Render(" "))
}
} else if x < len(runes) {
if m.IsVisualMode() && posIsAnchor(m, x, y) {
if m.IsVisualMode() && posIsAnchor(m, x, i) {
view.WriteString(m.visualAnchorStyle().Render(string(runes[x])))
} else if m.IsVisualMode() && posInsideSelection(m, x, y) {
} else if m.IsVisualMode() && posInsideSelection(m, x, i) {
view.WriteString(m.visualHighlightStyle().Render(string(runes[x])))
} else {
view.WriteRune(runes[x])
}
// To highlight blank lines when in visual mode
} else if m.IsVisualMode() && posInsideSelection(m, x, y) {
} else if m.IsVisualMode() && posInsideSelection(m, x, i) {
view.WriteString(m.visualHighlightStyle().Render(" "))
}
}
@ -116,7 +120,7 @@ func (m Model) View() string {
// Draw status bar
var modeString string
switch m.mode {
switch m.Mode() {
case action.NormalMode:
modeString = "NORMAL"
case action.InsertMode: