From db70ca39f1eb05317a55c90f72c5aa232159a4c6 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Thu, 12 Feb 2026 22:40:17 -0700 Subject: [PATCH] feat: implementing vertical scrolling, tested Heavy on the AI... --- internal/action/action.go | 5 + internal/editor/helpers_test.go | 4 + internal/editor/integration_scroll_test.go | 187 +++++++++++++++++++++ internal/editor/model.go | 50 +++++- internal/editor/update.go | 9 +- internal/editor/view.go | 30 ++-- 6 files changed, 267 insertions(+), 18 deletions(-) create mode 100644 internal/editor/integration_scroll_test.go diff --git a/internal/action/action.go b/internal/action/action.go index 33f62ee..f9d4cc7 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -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 diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index 244c17c..b80a305 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -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}) diff --git a/internal/editor/integration_scroll_test.go b/internal/editor/integration_scroll_test.go new file mode 100644 index 0000000..8d921ec --- /dev/null +++ b/internal/editor/integration_scroll_test.go @@ -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()) + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go index ed6eb80..880a590 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -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, }, + scrollY: 0, + mode: action.NormalMode, + command: "", + input: input.NewHandler(), + gutterSize: 5, tabSize: 2, - mode: action.NormalMode, - command: "", - input: input.NewHandler(), + 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 } diff --git a/internal/editor/update.go b/internal/editor/update.go index 07f776a..0d03a1d 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -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 } diff --git a/internal/editor/view.go b/internal/editor/view.go index fd4932e..be12617 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -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: