feat: implemented horizontal scroll, tested
All checks were successful
Run Test Suite / test (push) Successful in 42s

Updated lots of pieces with this, but it looks good.
This commit is contained in:
Hayden Hargreaves 2026-04-08 21:06:27 -07:00
parent 9899d1af1f
commit 31629d1908
8 changed files with 306 additions and 37 deletions

View File

@ -164,8 +164,8 @@
- [ ] `~` - Swap case of selection - [ ] `~` - Swap case of selection
- [ ] `u` - Lowercase selection - [ ] `u` - Lowercase selection
- [ ] `U` - Uppercase selection - [ ] `U` - Uppercase selection
- [ ] `J` - Join selected lines
- [ ] `o` - Go to other end of selection - [ ] `o` - Go to other end of selection
- [ ] `J` - Join selected lines
- [ ] `O` - Go to other corner (block mode) - [ ] `O` - Go to other corner (block mode)
--- ---

View File

@ -1,5 +1,7 @@
package core package core
import "strconv"
type WinOptions struct { type WinOptions struct {
Number bool Number bool
RelativeNumber bool RelativeNumber bool
@ -26,6 +28,7 @@ type Window struct {
Anchor Position Anchor Position
ScrollY int ScrollY int
ScrollX int
Height int Height int
Width int Width int
@ -70,17 +73,17 @@ func (w *Window) ClampCursor() {
} }
} }
// Window.AdjustScroll ensures the cursor stays within the height with scrollOff margins. // Window.AdjustScroll ensures the cursor stays within the visible viewport on both axes.
// Call this after any cursor movement. // Call this after any cursor movement.
func (w *Window) AdjustScroll() { func (w *Window) AdjustScroll() {
if w.Height <= 0 { if w.Buffer == nil || w.Height <= 0 {
return return
} }
viewPort := w.ViewportHeight() viewPortHeight := w.ViewportHeight()
// Effective scrollOff (can't be more than half the viewport) // Effective scrollOff (can't be more than half the viewport)
off := min(w.Options.ScrollOff, viewPort/2) off := min(w.Options.ScrollOff, viewPortHeight/2)
// Cursor too close to top — scroll up // Cursor too close to top — scroll up
if w.Cursor.Line < w.ScrollY+off { if w.Cursor.Line < w.ScrollY+off {
@ -88,13 +91,29 @@ func (w *Window) AdjustScroll() {
} }
// Cursor too close to bottom — scroll down // Cursor too close to bottom — scroll down
if w.Cursor.Line > w.ScrollY+viewPort-1-off { if w.Cursor.Line > w.ScrollY+viewPortHeight-1-off {
w.ScrollY = w.Cursor.Line - viewPort + 1 + off w.ScrollY = w.Cursor.Line - viewPortHeight + 1 + off
} }
// Clamp scrollY to valid range // Clamp scrollY to valid range
maxScroll := max(0, w.Buffer.LineCount()-viewPort) maxScroll := max(0, w.Buffer.LineCount()-viewPortHeight)
w.ScrollY = max(0, min(w.ScrollY, maxScroll)) w.ScrollY = max(0, min(w.ScrollY, maxScroll))
viewPortWidth := w.ViewportWidth()
if viewPortWidth <= 0 {
w.ScrollX = 0
return
}
if w.Cursor.Col < w.ScrollX {
w.ScrollX = w.Cursor.Col
} else if w.Cursor.Col >= w.ScrollX+viewPortWidth {
w.ScrollX = w.Cursor.Col - viewPortWidth + 1
}
lineLen := w.Buffer.Lines[w.Cursor.Line].Len()
maxScrollX := max(0, lineLen-viewPortWidth+1)
w.ScrollX = max(0, min(w.ScrollX, maxScrollX))
} }
// ================================================== // ==================================================
@ -109,6 +128,24 @@ func (w *Window) ViewportHeight() int {
return w.Height - 2 return w.Height - 2
} }
func (w *Window) GutterWidth() int {
if !(w.Options.Number || w.Options.RelativeNumber) {
return 0
}
lineCount := 1
if w.Buffer != nil {
lineCount = max(1, w.Buffer.LineCount())
}
maxLineLen := len(strconv.Itoa(lineCount))
return max(w.Options.GutterSize, maxLineLen+2)
}
func (w *Window) ViewportWidth() int {
return max(0, w.Width-w.GutterWidth())
}
// ================================================== // ==================================================
// Setters // Setters
// ================================================== // ==================================================

View File

@ -279,6 +279,115 @@ func TestWindow_AdjustScroll(t *testing.T) {
t.Error("cursor should be visible in small viewport") t.Error("cursor should be visible in small viewport")
} }
}) })
t.Run("scrolls right when cursor moves past visible width", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"0123456789abcdefghij"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(12).
WithHeight(10).
Build()
win.SetCursorCol(10)
win.AdjustScroll()
if win.ScrollX == 0 {
t.Fatal("expected horizontal scroll to move right")
}
viewport := win.ViewportWidth()
if win.Cursor.Col < win.ScrollX || win.Cursor.Col >= win.ScrollX+viewport {
t.Errorf("cursor at %d not visible in scroll range [%d, %d)",
win.Cursor.Col, win.ScrollX, win.ScrollX+viewport)
}
})
t.Run("scrolls left when cursor moves back into hidden content", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"0123456789abcdefghij"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(12).
WithHeight(10).
Build()
win.SetCursorCol(14)
win.AdjustScroll()
if win.ScrollX == 0 {
t.Fatal("expected initial horizontal scroll to move right")
}
win.SetCursorCol(2)
win.AdjustScroll()
if win.ScrollX != 2 {
t.Errorf("expected horizontal scroll to follow cursor left, got %d", win.ScrollX)
}
})
}
func TestWindow_AdjustScrollHorizontalRuneAware(t *testing.T) {
tests := []struct {
name string
line string
width int
cursorCol int
initialScroll int
expected int
}{
{
name: "ascii line scrolls using visible columns",
line: "0123456789abcdef",
width: 12,
cursorCol: 10,
expected: 4,
},
{
name: "multibyte rune line uses rune length not bytes",
line: "abécdefghij",
width: 10,
cursorCol: 10,
expected: 6,
},
{
name: "moving left pulls scroll back toward cursor",
line: "abécdefghij",
width: 10,
cursorCol: 2,
initialScroll: 6,
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := NewBufferBuilder().WithLines([]string{tt.line}).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(tt.width).
WithHeight(10).
Build()
win.ScrollX = tt.initialScroll
win.SetCursorCol(tt.cursorCol)
win.AdjustScroll()
if win.ScrollX != tt.expected {
t.Errorf("ScrollX() = %d, want %d", win.ScrollX, tt.expected)
}
viewport := win.ViewportWidth()
if viewport > 0 && (win.Cursor.Col < win.ScrollX || win.Cursor.Col >= win.ScrollX+viewport) {
t.Errorf("cursor at %d not visible in scroll range [%d, %d)",
win.Cursor.Col, win.ScrollX, win.ScrollX+viewport)
}
})
}
} }
func TestWindow_ViewportHeight(t *testing.T) { func TestWindow_ViewportHeight(t *testing.T) {
@ -327,6 +436,34 @@ func TestWindow_ViewportHeight(t *testing.T) {
}) })
} }
func TestWindow_ViewportWidth(t *testing.T) {
t.Run("subtracts gutter width", func(t *testing.T) {
buf := NewBufferBuilder().WithLines([]string{"line"}).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(20).
Build()
expected := 15
if win.ViewportWidth() != expected {
t.Errorf("expected viewport width %d, got %d", expected, win.ViewportWidth())
}
})
t.Run("returns full width when gutter disabled", func(t *testing.T) {
buf := NewBufferBuilder().WithLines([]string{"line"}).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithWidth(20).
WithOptions(WinOptions{Number: false, RelativeNumber: false, GutterSize: 5, ScrollOff: 8}).
Build()
if win.ViewportWidth() != 20 {
t.Errorf("expected viewport width 20, got %d", win.ViewportWidth())
}
})
}
func TestWindow_SetOptions(t *testing.T) { func TestWindow_SetOptions(t *testing.T) {
t.Run("updates options", func(t *testing.T) { t.Run("updates options", func(t *testing.T) {
buf := NewBufferBuilder().Build() buf := NewBufferBuilder().Build()

View File

@ -0,0 +1,86 @@
package editor
import (
"regexp"
"strings"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func stripANSI(s string) string {
return ansiPattern.ReplaceAllString(s, "")
}
func firstViewLine(view string) string {
lines := strings.Split(view, "\n")
if len(lines) == 0 {
return ""
}
return stripANSI(lines[0])
}
func TestHorizontalScrollRender(t *testing.T) {
line := "0123456789abcdef"
tm := newTestModelWithTermSize(t, []string{line}, core.Position{Line: 0, Col: 0}, 12, 10)
for range 10 {
sendKeys(tm, "l")
}
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollX != 4 {
t.Fatalf("ScrollX() = %d, want 4", m.ActiveWindow().ScrollX)
}
visible := firstViewLine(m.View())
if !strings.Contains(visible, "456789a") {
t.Fatalf("first visible line = %q, want it to contain %q", visible, "456789a")
}
}
func TestHorizontalScrollRenderReturnsWhenMovingLeft(t *testing.T) {
line := "0123456789abcdef"
tm := newTestModelWithTermSize(t, []string{line}, core.Position{Line: 0, Col: 0}, 12, 10)
for range 10 {
sendKeys(tm, "l")
}
for range 10 {
sendKeys(tm, "h")
}
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollX != 0 {
t.Fatalf("ScrollX() after moving back left = %d, want 0", m.ActiveWindow().ScrollX)
}
visible := firstViewLine(m.View())
if !strings.Contains(visible, "0123456") {
t.Fatalf("first visible line after moving back left = %q, want it to contain %q", visible, "0123456")
}
}
func TestHorizontalScrollResizeClampWithRunes(t *testing.T) {
line := "abécdefghij"
tm := newTestModelWithTermSize(t, []string{line}, core.Position{Line: 0, Col: 0}, 10, 10)
for range 10 {
sendKeys(tm, "l")
}
tm.Send(tea.WindowSizeMsg{Width: 12, Height: 10})
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollX != 5 {
t.Fatalf("ScrollX() after resize = %d, want 5", m.ActiveWindow().ScrollX)
}
visible := firstViewLine(m.View())
if !strings.Contains(visible, "efghij") {
t.Fatalf("first visible line after resize = %q, want it to contain %q", visible, "efghij")
}
}

View File

@ -141,8 +141,13 @@ func (m *Model) ClearLastChangeKeys() {
m.lastChangeKeys = []string{} m.lastChangeKeys = []string{}
} }
// Handle key also adjusts the scroll anytime an input is pressed.
func (m *Model) HandleKey(key string) tea.Cmd { func (m *Model) HandleKey(key string) tea.Cmd {
return m.input.Handle(m, key) cmd := m.input.Handle(m, key)
if len(m.windows) > 0 {
m.ActiveWindow().AdjustScroll()
}
return cmd
} }
func (m *Model) ExitInsertMode() { func (m *Model) ExitInsertMode() {
@ -151,7 +156,7 @@ func (m *Model) ExitInsertMode() {
m.replayInsert() m.replayInsert()
} }
if win.Cursor.Col > 0 { if win.Cursor.Col > 0 {
win.Cursor.Col-- win.SetCursorCol(win.Cursor.Col - 1)
} }
m.mode = core.NormalMode m.mode = core.NormalMode
m.insertCount = 0 m.insertCount = 0
@ -271,6 +276,7 @@ func (m *Model) processInsertKey(key string) {
} }
win.SetCursorCol(col + len(key)) win.SetCursorCol(col + len(key))
} }
} }
// ================================================== // ==================================================

View File

@ -48,6 +48,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
for i := range m.windows { for i := range m.windows {
m.windows[i].Height = msg.Height m.windows[i].Height = msg.Height
m.windows[i].Width = msg.Width m.windows[i].Width = msg.Width
m.windows[i].AdjustScroll()
} }
// TODO: This is not great, totally temporary. But I don't like vim's handling, so this is up to me // TODO: This is not great, totally temporary. But I don't like vim's handling, so this is up to me
@ -60,6 +61,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
scrollAction := motion.ScrollDownPage{Divisor: 4} // Quarter page scrollAction := motion.ScrollDownPage{Divisor: 4} // Quarter page
cmd = scrollAction.Execute(m) cmd = scrollAction.Execute(m)
} }
if len(m.windows) > 0 {
m.ActiveWindow().AdjustScroll()
}
case tea.KeyMsg: case tea.KeyMsg:
// TODO: This needs to be removed, but for now its required for the tests. // TODO: This needs to be removed, but for now its required for the tests.
@ -83,13 +87,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.CommandOutput().ScrollUp() m.CommandOutput().ScrollUp()
} }
} else { } else {
cmd = m.input.Handle(m, msg.String()) cmd = m.HandleKey(msg.String())
} }
} }
// Keep cursor in view after any update
win := m.ActiveWindow()
win.AdjustScroll()
return m, cmd return m, cmd
} }

View File

@ -61,12 +61,12 @@ func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mo
// Draw buffer lines // Draw buffer lines
for lineNum := start; lineNum < end; lineNum++ { for lineNum := start; lineNum < end; lineNum++ {
if lineNum < buf.LineCount() { if lineNum < buf.LineCount() {
styleMap := make([]lipgloss.Style, len([]rune(buf.Line(lineNum)))) line := buf.Lines[lineNum]
styleMap := make([]lipgloss.Style, line.Len())
if sx != nil { if sx != nil {
styleMap = sx.LineStyleMap(buf, lineNum, t) styleMap = sx.LineStyleMap(buf, lineNum, t)
} }
line := drawLine(w, t, options, mode, buf.Line(lineNum), lineNum, styleMap) view.WriteString(drawLine(w, t, options, mode, line, lineNum, styleMap))
view.WriteString(line)
} else { } else {
view.WriteString(strings.Repeat(t.Background.Render(" "), w.Width)) view.WriteString(strings.Repeat(t.Background.Render(" "), w.Width))
} }
@ -82,46 +82,49 @@ func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mo
// drawLine: Renders a single line with syntax highlighting, cursor, and visual selection. // drawLine: Renders a single line with syntax highlighting, cursor, and visual selection.
// Handles gutter, cursor rendering, and visual mode highlighting. // Handles gutter, cursor rendering, and visual mode highlighting.
func drawLine(w *core.Window, t theme.EditorTheme, options core.WinOptions, mode core.Mode, line string, lineNumber int, styleMap []lipgloss.Style) string { func drawLine(w *core.Window, t theme.EditorTheme, options core.WinOptions, mode core.Mode, line *core.GapBuffer, lineNumber int, styleMap []lipgloss.Style) string {
var view strings.Builder var view strings.Builder
runes := []rune(line) lineLen := line.Len()
// Draw gutter first // Draw gutter first
gutter := drawGutter(w, t, options, lineNumber) gutter := drawGutter(w, t, options, lineNumber)
view.WriteString(gutter) view.WriteString(gutter)
contentWidth := w.ViewportWidth()
if contentWidth <= 0 {
return view.String()
}
// Draw visible content slice only
startCol := max(0, w.ScrollX)
for screenCol := range contentWidth {
col := startCol + screenCol
// Now draw the line content
for col := 0; col <= len(runes); col++ {
// Current char is cursor // Current char is cursor
if col == w.Cursor.Col && lineNumber == w.Cursor.Line { if col == w.Cursor.Col && lineNumber == w.Cursor.Line {
if col < len(runes) { if col < lineLen {
cur := t.Cursor(mode, styleMap[col]) cur := t.Cursor(mode, styleMap[col])
view.WriteString(cur.Render(string(runes[col]))) view.WriteString(cur.Render(string(line.RuneAt(col))))
} else { } else {
view.WriteString(t.DefaultCursor(mode).Render(" ")) view.WriteString(t.DefaultCursor(mode).Render(" "))
} }
continue
}
// Not cursor, but not end if col < lineLen {
} else if col < len(runes) {
s := styleMap[col] s := styleMap[col]
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
vis := t.VisualHighlightWithTextColor(s) vis := t.VisualHighlightWithTextColor(s)
view.WriteString(vis.Render(string(runes[col]))) view.WriteString(vis.Render(string(line.RuneAt(col))))
} else { } else {
view.WriteString(s.Render(string(runes[col]))) view.WriteString(s.Render(string(line.RuneAt(col))))
} }
// Allow highlight on blank lines or chars
} else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { } else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(t.VisualHightlight.Render(" ")) view.WriteString(t.VisualHightlight.Render(" "))
} else {
view.WriteString(t.Background.Render(" "))
} }
} }
// Pad remainder of line to window width with background color
dif := w.Width - lipgloss.Width(view.String())
if dif > 0 {
view.WriteString(strings.Repeat(t.Background.Render(" "), dif))
}
return view.String() return view.String()
} }

View File

@ -20,8 +20,8 @@
- [ ] Better command-mode autocomplete/hints - [ ] Better command-mode autocomplete/hints
- [ ] Configuration file support (~/.gimrc or similar) - [ ] Configuration file support (~/.gimrc or similar)
- [ ] ~Persistent undo history~ - [ ] ~Persistent undo history~
- [ ] Line wrapping display option - [ ] ~Line wrapping display option~
- [ ] Horozontal scroll - [ ] Horizontal scroll
- [ ] Handle large files gracefully (>10k lines) [Pretty sure this is fine] - [ ] Handle large files gracefully (>10k lines) [Pretty sure this is fine]
- [ ] Alternate buffer implementation - [ ] Alternate buffer implementation