feat: implementing vertical scrolling, tested
Heavy on the AI...
This commit is contained in:
parent
c2b5e6f67c
commit
db70ca39f1
@ -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
|
||||
|
||||
@ -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})
|
||||
|
||||
187
internal/editor/integration_scroll_test.go
Normal file
187
internal/editor/integration_scroll_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user