diff --git a/FEATURES.md b/FEATURES.md index 0b3ec0a..9b90add 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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) --- diff --git a/internal/core/window.go b/internal/core/window.go index 71999c7..99d3ddf 100644 --- a/internal/core/window.go +++ b/internal/core/window.go @@ -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 // ================================================== diff --git a/internal/core/window_test.go b/internal/core/window_test.go index a755819..fef06b9 100644 --- a/internal/core/window_test.go +++ b/internal/core/window_test.go @@ -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() diff --git a/internal/editor/integration_scroll_horizontal_test.go b/internal/editor/integration_scroll_horizontal_test.go new file mode 100644 index 0000000..86aee43 --- /dev/null +++ b/internal/editor/integration_scroll_horizontal_test.go @@ -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") + } +} diff --git a/internal/editor/model.go b/internal/editor/model.go index c2b92ad..f0d3d5f 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -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)) } + } // ================================================== diff --git a/internal/editor/update.go b/internal/editor/update.go index d747120..fab8b46 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -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 } diff --git a/internal/editor/view.go b/internal/editor/view.go index 3cd4888..2e23814 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -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() } diff --git a/v0.1.md b/v0.1.md index b198781..6262dd5 100644 --- a/v0.1.md +++ b/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