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)
|
SetCursorY(y int)
|
||||||
ClampCursorX()
|
ClampCursorX()
|
||||||
|
|
||||||
|
// Window
|
||||||
|
ScrollY() int
|
||||||
|
SetScrollY(y int)
|
||||||
|
|
||||||
// Anchor
|
// Anchor
|
||||||
AnchorX() int
|
AnchorX() int
|
||||||
AnchorY() int
|
AnchorY() int
|
||||||
@ -43,6 +47,7 @@ type Model interface {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
TabSize() int
|
TabSize() int
|
||||||
|
ScrollOff() int
|
||||||
|
|
||||||
// Mode
|
// Mode
|
||||||
Mode() 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))
|
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)
|
// getFinalModel extracts the final model state (sends ctrl+c to quit first)
|
||||||
func getFinalModel(t *testing.T, tm *teatest.TestModel) Model {
|
func getFinalModel(t *testing.T, tm *teatest.TestModel) Model {
|
||||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
|
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
|
lines []string
|
||||||
cursor cursor
|
cursor cursor
|
||||||
anchor cursor // starting point for visual modes
|
anchor cursor // starting point for visual modes
|
||||||
|
scrollY int
|
||||||
mode action.Mode
|
mode action.Mode
|
||||||
win_h int
|
win_h int
|
||||||
win_w int
|
win_w int
|
||||||
@ -31,6 +32,7 @@ type Model struct {
|
|||||||
// Settings
|
// Settings
|
||||||
gutterSize int
|
gutterSize int
|
||||||
tabSize int
|
tabSize int
|
||||||
|
scrollOff int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(lines []string, pos action.Position) Model {
|
func NewModel(lines []string, pos action.Position) Model {
|
||||||
@ -40,11 +42,14 @@ func NewModel(lines []string, pos action.Position) Model {
|
|||||||
x: pos.Col,
|
x: pos.Col,
|
||||||
y: pos.Line,
|
y: pos.Line,
|
||||||
},
|
},
|
||||||
gutterSize: 5,
|
scrollY: 0,
|
||||||
tabSize: 2,
|
|
||||||
mode: action.NormalMode,
|
mode: action.NormalMode,
|
||||||
command: "",
|
command: "",
|
||||||
input: input.NewHandler(),
|
input: input.NewHandler(),
|
||||||
|
|
||||||
|
gutterSize: 5,
|
||||||
|
tabSize: 2,
|
||||||
|
scrollOff: 8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,6 +143,19 @@ func (m *Model) TabSize() int {
|
|||||||
return m.tabSize
|
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() {
|
func (m *Model) ClampCursorX() {
|
||||||
lineLen := len(m.lines[m.cursor.y])
|
lineLen := len(m.lines[m.cursor.y])
|
||||||
if lineLen == 0 {
|
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 {
|
func (m *Model) Mode() action.Mode {
|
||||||
return m.mode
|
return m.mode
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@ -24,7 +26,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
action.VisualMode,
|
action.VisualMode,
|
||||||
action.VisualBlockMode,
|
action.VisualBlockMode,
|
||||||
action.VisualLineMode:
|
action.VisualLineMode:
|
||||||
return m, m.input.Handle(&m, msg.String())
|
cmd = m.input.Handle(&m, msg.String())
|
||||||
|
|
||||||
// The only one left to migrate!
|
// The only one left to migrate!
|
||||||
case action.CommandMode:
|
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 {
|
func (m Model) View() string {
|
||||||
var view strings.Builder
|
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 (
|
var (
|
||||||
gutter string
|
gutter string
|
||||||
currentLine bool = false
|
currentLine bool = false
|
||||||
lineNumber int
|
lineNumber int
|
||||||
)
|
)
|
||||||
if y > m.cursor.y {
|
if i > m.CursorY() {
|
||||||
lineNumber = y - m.cursor.y
|
lineNumber = i - m.CursorY()
|
||||||
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
|
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
|
||||||
} else if y < m.cursor.y {
|
} else if i < m.CursorY() {
|
||||||
lineNumber = m.cursor.y - y
|
lineNumber = m.CursorY() - i
|
||||||
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
|
gutter = fmt.Sprintf("%*d ", m.gutterSize-1, lineNumber)
|
||||||
} else {
|
} else {
|
||||||
lineNumber = y + 1
|
lineNumber = i + 1
|
||||||
currentLine = true
|
currentLine = true
|
||||||
if lineNumber < 100 {
|
if lineNumber < 100 {
|
||||||
gutter = fmt.Sprintf("%*d ", m.gutterSize-2, lineNumber)
|
gutter = fmt.Sprintf("%*d ", m.gutterSize-2, lineNumber)
|
||||||
@ -85,24 +89,24 @@ func (m Model) View() string {
|
|||||||
}
|
}
|
||||||
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
|
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
|
||||||
|
|
||||||
runes := []rune(m.lines[y])
|
runes := []rune(m.Line(i))
|
||||||
for x := 0; x <= len(runes); x++ {
|
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) {
|
if x < len(runes) {
|
||||||
view.WriteString(m.cursorStyle().Render(string(runes[x])))
|
view.WriteString(m.cursorStyle().Render(string(runes[x])))
|
||||||
} else {
|
} else {
|
||||||
view.WriteString(m.cursorStyle().Render(" "))
|
view.WriteString(m.cursorStyle().Render(" "))
|
||||||
}
|
}
|
||||||
} else if x < len(runes) {
|
} 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])))
|
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])))
|
view.WriteString(m.visualHighlightStyle().Render(string(runes[x])))
|
||||||
} else {
|
} else {
|
||||||
view.WriteRune(runes[x])
|
view.WriteRune(runes[x])
|
||||||
}
|
}
|
||||||
// To highlight blank lines when in visual mode
|
// 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(" "))
|
view.WriteString(m.visualHighlightStyle().Render(" "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -116,7 +120,7 @@ func (m Model) View() string {
|
|||||||
|
|
||||||
// Draw status bar
|
// Draw status bar
|
||||||
var modeString string
|
var modeString string
|
||||||
switch m.mode {
|
switch m.Mode() {
|
||||||
case action.NormalMode:
|
case action.NormalMode:
|
||||||
modeString = "NORMAL"
|
modeString = "NORMAL"
|
||||||
case action.InsertMode:
|
case action.InsertMode:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user