feat: implemented horizontal scroll, tested
All checks were successful
Run Test Suite / test (push) Successful in 42s
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:
parent
9899d1af1f
commit
31629d1908
@ -164,8 +164,8 @@
|
||||
- [ ] `~` - Swap case of selection
|
||||
- [ ] `u` - Lowercase selection
|
||||
- [ ] `U` - Uppercase selection
|
||||
- [ ] `J` - Join selected lines
|
||||
- [ ] `o` - Go to other end of selection
|
||||
- [ ] `J` - Join selected lines
|
||||
- [ ] `O` - Go to other corner (block mode)
|
||||
|
||||
---
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package core
|
||||
|
||||
import "strconv"
|
||||
|
||||
type WinOptions struct {
|
||||
Number bool
|
||||
RelativeNumber bool
|
||||
@ -26,6 +28,7 @@ type Window struct {
|
||||
Anchor Position
|
||||
|
||||
ScrollY int
|
||||
ScrollX int
|
||||
Height 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.
|
||||
func (w *Window) AdjustScroll() {
|
||||
if w.Height <= 0 {
|
||||
if w.Buffer == nil || w.Height <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
viewPort := w.ViewportHeight()
|
||||
viewPortHeight := w.ViewportHeight()
|
||||
|
||||
// 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
|
||||
if w.Cursor.Line < w.ScrollY+off {
|
||||
@ -88,13 +91,29 @@ func (w *Window) AdjustScroll() {
|
||||
}
|
||||
|
||||
// Cursor too close to bottom — scroll down
|
||||
if w.Cursor.Line > w.ScrollY+viewPort-1-off {
|
||||
w.ScrollY = w.Cursor.Line - viewPort + 1 + off
|
||||
if w.Cursor.Line > w.ScrollY+viewPortHeight-1-off {
|
||||
w.ScrollY = w.Cursor.Line - viewPortHeight + 1 + off
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// ==================================================
|
||||
|
||||
@ -279,6 +279,115 @@ func TestWindow_AdjustScroll(t *testing.T) {
|
||||
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) {
|
||||
@ -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) {
|
||||
t.Run("updates options", func(t *testing.T) {
|
||||
buf := NewBufferBuilder().Build()
|
||||
|
||||
86
internal/editor/integration_scroll_horizontal_test.go
Normal file
86
internal/editor/integration_scroll_horizontal_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -141,8 +141,13 @@ func (m *Model) ClearLastChangeKeys() {
|
||||
m.lastChangeKeys = []string{}
|
||||
}
|
||||
|
||||
// Handle key also adjusts the scroll anytime an input is pressed.
|
||||
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() {
|
||||
@ -151,7 +156,7 @@ func (m *Model) ExitInsertMode() {
|
||||
m.replayInsert()
|
||||
}
|
||||
if win.Cursor.Col > 0 {
|
||||
win.Cursor.Col--
|
||||
win.SetCursorCol(win.Cursor.Col - 1)
|
||||
}
|
||||
m.mode = core.NormalMode
|
||||
m.insertCount = 0
|
||||
@ -271,6 +276,7 @@ func (m *Model) processInsertKey(key string) {
|
||||
}
|
||||
win.SetCursorCol(col + len(key))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
|
||||
@ -48,6 +48,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
for i := range m.windows {
|
||||
m.windows[i].Height = msg.Height
|
||||
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
|
||||
@ -60,6 +61,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
scrollAction := motion.ScrollDownPage{Divisor: 4} // Quarter page
|
||||
cmd = scrollAction.Execute(m)
|
||||
}
|
||||
if len(m.windows) > 0 {
|
||||
m.ActiveWindow().AdjustScroll()
|
||||
}
|
||||
|
||||
case tea.KeyMsg:
|
||||
// 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()
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
@ -61,12 +61,12 @@ func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mo
|
||||
// Draw buffer lines
|
||||
for lineNum := start; lineNum < end; lineNum++ {
|
||||
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 {
|
||||
styleMap = sx.LineStyleMap(buf, lineNum, t)
|
||||
}
|
||||
line := drawLine(w, t, options, mode, buf.Line(lineNum), lineNum, styleMap)
|
||||
view.WriteString(line)
|
||||
view.WriteString(drawLine(w, t, options, mode, line, lineNum, styleMap))
|
||||
} else {
|
||||
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.
|
||||
// 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
|
||||
runes := []rune(line)
|
||||
lineLen := line.Len()
|
||||
|
||||
// Draw gutter first
|
||||
gutter := drawGutter(w, t, options, lineNumber)
|
||||
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
|
||||
if col == w.Cursor.Col && lineNumber == w.Cursor.Line {
|
||||
if col < len(runes) {
|
||||
if col < lineLen {
|
||||
cur := t.Cursor(mode, styleMap[col])
|
||||
view.WriteString(cur.Render(string(runes[col])))
|
||||
view.WriteString(cur.Render(string(line.RuneAt(col))))
|
||||
} else {
|
||||
view.WriteString(t.DefaultCursor(mode).Render(" "))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Not cursor, but not end
|
||||
} else if col < len(runes) {
|
||||
if col < lineLen {
|
||||
s := styleMap[col]
|
||||
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
|
||||
vis := t.VisualHighlightWithTextColor(s)
|
||||
view.WriteString(vis.Render(string(runes[col])))
|
||||
view.WriteString(vis.Render(string(line.RuneAt(col))))
|
||||
} 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) {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
4
v0.1.md
4
v0.1.md
@ -20,8 +20,8 @@
|
||||
- [ ] Better command-mode autocomplete/hints
|
||||
- [ ] Configuration file support (~/.gimrc or similar)
|
||||
- [ ] ~Persistent undo history~
|
||||
- [ ] Line wrapping display option
|
||||
- [ ] Horozontal scroll
|
||||
- [ ] ~Line wrapping display option~
|
||||
- [ ] Horizontal scroll
|
||||
- [ ] Handle large files gracefully (>10k lines) [Pretty sure this is fine]
|
||||
- [ ] Alternate buffer implementation
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user