diff --git a/internal/action/action.go b/internal/action/action.go index d4bd846..c300c49 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -57,11 +57,13 @@ type Model interface { SetCursorX(x int) SetCursorY(y int) ClampCursorX() - ClampCursorXNormal() // Window ScrollY() int SetScrollY(y int) + WinH() int + WinW() int + ViewPortH() int // Anchor AnchorX() int diff --git a/internal/editor/helpers_test.go b/internal/editor/helpers_test.go index b80a305..6be1e5d 100644 --- a/internal/editor/helpers_test.go +++ b/internal/editor/helpers_test.go @@ -23,6 +23,8 @@ func sendKeys(tm *teatest.TestModel, keys ...string) { tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) case "ctrl+d": tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD}) + case "ctrl+u": + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU}) case "ctrl+v": tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV}) case "ctrl+w": diff --git a/internal/editor/integration_scroll_test.go b/internal/editor/integration_scroll_test.go index 8d921ec..38b8c8f 100644 --- a/internal/editor/integration_scroll_test.go +++ b/internal/editor/integration_scroll_test.go @@ -87,11 +87,10 @@ func TestScrollBasic(t *testing.T) { 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()) + // With 100 lines and viewport 18 (height - 2 for status + command bar), + // max scrollY = 100 - 18 = 82 + if m.ScrollY() != 82 { + t.Errorf("ScrollY() = %d, want 82", m.ScrollY()) } }) @@ -132,8 +131,8 @@ func TestScrollEdgeCases(t *testing.T) { sendKeys(tm, "G") m := getFinalModel(t, tm) - // 30 lines, viewport 19 -> maxScroll = 30 - 19 = 11 - maxScroll := 30 - 19 + // 30 lines, viewport 18 (height - 2) -> maxScroll = 30 - 18 = 12 + maxScroll := 30 - 18 if m.ScrollY() > maxScroll { t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll) } @@ -156,6 +155,263 @@ func TestScrollEdgeCases(t *testing.T) { }) } +// Tests use terminal 80x30: viewportH=28, half-scroll=14, scrollOff=8, safe zone relY 8-19. + +func TestHalfPageScrollDown(t *testing.T) { + t.Run("ctrl+d scrolls viewport down by half", func(t *testing.T) { + // cursor at line 15 (relY=15, in safe zone), scrollY starts at 0 + // After ctrl+d: newScrollY=14, newCursorY=14+15=29 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+d") + + m := getFinalModel(t, tm) + if m.ScrollY() != 14 { + t.Errorf("ScrollY() = %d, want 14", m.ScrollY()) + } + if m.CursorY() != 29 { + t.Errorf("CursorY() = %d, want 29", m.CursorY()) + } + }) + + t.Run("ctrl+d preserves cursor relative position in viewport", func(t *testing.T) { + // relY=15 before and after + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+d") + + m := getFinalModel(t, tm) + relY := m.CursorY() - m.ScrollY() + if relY != 15 { + t.Errorf("relative position = %d, want 15", relY) + } + }) + + t.Run("ctrl+d clamps cursor to scrollOff when cursor is near top", func(t *testing.T) { + // cursor at line 0 (relY=0 < scrollOff=8), clamp to scrollOff + // After ctrl+d: newScrollY=14, newCursorY=14+8=22 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 30) + sendKeys(tm, "ctrl+d") + + m := getFinalModel(t, tm) + if m.ScrollY() != 14 { + t.Errorf("ScrollY() = %d, want 14", m.ScrollY()) + } + if m.CursorY() != 22 { + t.Errorf("CursorY() = %d, want 22", m.CursorY()) + } + }) + + t.Run("ctrl+d at end of file does not scroll past max", func(t *testing.T) { + // 40 lines, viewport 28: maxScroll=12 + // AdjustScroll puts cursor 35 at scrollY=12 (clamped), relY=23 + // After ctrl+d: newScrollY clamped to 12, relY=23>19 clamped to 19, newCursorY=31 + lines := generateLines(40) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 35}, 80, 30) + sendKeys(tm, "ctrl+d") + + m := getFinalModel(t, tm) + maxScroll := 40 - 28 + if m.ScrollY() > maxScroll { + t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll) + } + if m.CursorY() != 31 { + t.Errorf("CursorY() = %d, want 31", m.CursorY()) + } + }) + + t.Run("ctrl+d on file smaller than viewport does not crash", func(t *testing.T) { + // 20 lines < viewport 28: maxScroll=0, scrollY stays 0 + // relY=0 < scrollOff, clamp to 8; newCursorY=0+8=8 + lines := generateLines(20) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 30) + sendKeys(tm, "ctrl+d") + + m := getFinalModel(t, tm) + if m.ScrollY() != 0 { + t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) + } + if m.CursorY() != 8 { + t.Errorf("CursorY() = %d, want 8", m.CursorY()) + } + }) + + t.Run("ctrl+d clamps cursor x to new line length", func(t *testing.T) { + // Lines 0-14: "hello world" (11 chars), lines 15-99: "hi" (2 chars) + // cursor at line 5, cursorX=10 + // ctrl+d: relY=5<8 clamp to 8; newScrollY=14; newCursorY=22 (a "hi" line) + // ClampCursorX: 10 >= 2, so cursorX=2 + lines := make([]string, 100) + for i := range 15 { + lines[i] = "hello world" + } + for i := 15; i < 100; i++ { + lines[i] = "hi" + } + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 10, Line: 5}, 80, 30) + sendKeys(tm, "ctrl+d") + + m := getFinalModel(t, tm) + if m.CursorY() != 22 { + t.Errorf("CursorY() = %d, want 22", m.CursorY()) + } + if m.CursorX() > len(m.Line(m.CursorY())) { + t.Errorf("CursorX() = %d exceeds line length %d", m.CursorX(), len(m.Line(m.CursorY()))) + } + }) + + t.Run("multiple ctrl+d presses scroll incrementally", func(t *testing.T) { + // cursor at line 15, scrollY=0, relY=15 + // ctrl+d #1: scrollY=14, cursorY=29, relY=15 + // ctrl+d #2: scrollY=28, cursorY=43, relY=15 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+d", "ctrl+d") + + m := getFinalModel(t, tm) + if m.ScrollY() != 28 { + t.Errorf("ScrollY() = %d, want 28", m.ScrollY()) + } + if m.CursorY() != 43 { + t.Errorf("CursorY() = %d, want 43", m.CursorY()) + } + }) +} + +func TestHalfPageScrollUp(t *testing.T) { + t.Run("ctrl+u scrolls viewport up by half", func(t *testing.T) { + // cursor at line 50: AdjustScroll -> scrollY=31, relY=19 + // After ctrl+u: newScrollY=17, newCursorY=17+19=36 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30) + sendKeys(tm, "ctrl+u") + + m := getFinalModel(t, tm) + if m.ScrollY() != 17 { + t.Errorf("ScrollY() = %d, want 17", m.ScrollY()) + } + if m.CursorY() != 36 { + t.Errorf("CursorY() = %d, want 36", m.CursorY()) + } + }) + + t.Run("ctrl+u preserves cursor relative position in viewport", func(t *testing.T) { + // relY=19 before and after + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30) + sendKeys(tm, "ctrl+u") + + m := getFinalModel(t, tm) + relY := m.CursorY() - m.ScrollY() + if relY != 19 { + t.Errorf("relative position = %d, want 19", relY) + } + }) + + t.Run("ctrl+u at top of file does not make scrollY negative", func(t *testing.T) { + // cursor at line 10, scrollY=0, relY=10 (in safe zone) + // ctrl+u: newScrollY=max(0,-14)=0, relY=10 preserved, cursorY=10 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 10}, 80, 30) + sendKeys(tm, "ctrl+u") + + m := getFinalModel(t, tm) + if m.ScrollY() < 0 { + t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY()) + } + if m.ScrollY() != 0 { + t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) + } + if m.CursorY() != 10 { + t.Errorf("CursorY() = %d, want 10", m.CursorY()) + } + }) + + t.Run("ctrl+u clamps cursor to scrollOff when cursor is near top of viewport", func(t *testing.T) { + // cursor at line 5, scrollY=0, relY=5 < scrollOff=8 + // ctrl+u: newScrollY=0; relY clamp to 8; newCursorY=8 + lines := generateLines(100) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 5}, 80, 30) + sendKeys(tm, "ctrl+u") + + m := getFinalModel(t, tm) + if m.ScrollY() != 0 { + t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) + } + if m.CursorY() != 8 { + t.Errorf("CursorY() = %d, want 8", m.CursorY()) + } + }) + + t.Run("multiple ctrl+u presses scroll incrementally", func(t *testing.T) { + // cursor at line 80: AdjustScroll -> scrollY=61, relY=19 + // ctrl+u #1: newScrollY=47, cursorY=66 + // ctrl+u #2: newScrollY=33, cursorY=52 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 80}, 80, 30) + sendKeys(tm, "ctrl+u", "ctrl+u") + + m := getFinalModel(t, tm) + if m.ScrollY() != 33 { + t.Errorf("ScrollY() = %d, want 33", m.ScrollY()) + } + if m.CursorY() != 52 { + t.Errorf("CursorY() = %d, want 52", m.CursorY()) + } + }) +} + +func TestHalfPageScrollRoundTrip(t *testing.T) { + t.Run("ctrl+d then ctrl+u returns cursor to original position", func(t *testing.T) { + // cursor at line 15, scrollY=0, relY=15 + // ctrl+d: scrollY=14, cursorY=29, relY=15 + // ctrl+u: newScrollY=max(0,14-14)=0, cursorY=0+15=15 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+d", "ctrl+u") + + m := getFinalModel(t, tm) + if m.ScrollY() != 0 { + t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) + } + if m.CursorY() != 15 { + t.Errorf("CursorY() = %d, want 15", m.CursorY()) + } + }) + + t.Run("ctrl+u then ctrl+d returns cursor to original position", func(t *testing.T) { + // cursor at line 50: AdjustScroll -> scrollY=31, relY=19 + // ctrl+u: scrollY=17, cursorY=36, relY=19 + // ctrl+d: scrollY=31, cursorY=50, relY=19 + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30) + sendKeys(tm, "ctrl+u", "ctrl+d") + + m := getFinalModel(t, tm) + if m.ScrollY() != 31 { + t.Errorf("ScrollY() = %d, want 31", m.ScrollY()) + } + if m.CursorY() != 50 { + t.Errorf("CursorY() = %d, want 50", m.CursorY()) + } + }) + + t.Run("alternating ctrl+d and ctrl+u maintains scroll stability", func(t *testing.T) { + lines := generateLines(200) + tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30) + sendKeys(tm, "ctrl+d", "ctrl+u", "ctrl+d", "ctrl+u") + + m := getFinalModel(t, tm) + if m.ScrollY() != 0 { + t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ScrollY()) + } + if m.CursorY() != 15 { + t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.CursorY()) + } + }) +} + func TestScrollWithCount(t *testing.T) { t.Run("5j scrolls appropriately", func(t *testing.T) { lines := generateLines(50) diff --git a/internal/editor/model.go b/internal/editor/model.go index aa0a112..5275e1a 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -195,6 +195,18 @@ func (m *Model) SetScrollY(y int) { m.scrollY = y } +func (m *Model) WinH() int { + return m.win_h +} + +func (m *Model) WinW() int { + return m.win_w +} + +func (m *Model) ViewPortH() int { + return m.win_h - 2 // -2 for status bar and commmand bar +} + func (m *Model) ClampCursorX() { lineLen := len(m.lines[m.cursor.y]) if lineLen == 0 { @@ -207,7 +219,7 @@ 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 + viewportHeight := m.ViewPortH() if viewportHeight <= 0 { return } diff --git a/internal/editor/update.go b/internal/editor/update.go index 618e192..936fd1c 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -14,6 +14,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.win_w = msg.Width case tea.KeyMsg: + // TODO: This needs to be removed, but for now its required for the tests. + // Ctrl+C always quits regardless of mode + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } cmd = m.input.Handle(&m, msg.String()) } diff --git a/internal/editor/view.go b/internal/editor/view.go index fd5ba7c..9575a49 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -59,7 +59,7 @@ func posIsAnchor(m Model, col, line int) bool { func (m Model) View() string { var view strings.Builder - viewportHeight := m.win_h - 2 // -2 for status bar and command bar + viewportHeight := m.ViewPortH() start := m.ScrollY() end := m.ScrollY() + viewportHeight diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 63c45a8..669edd7 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -16,18 +16,20 @@ type Keymap struct { func NewNormalKeymap() *Keymap { return &Keymap{ motions: map[string]action.Motion{ - "j": motion.MoveDown{Count: 1}, - "k": motion.MoveUp{Count: 1}, - "h": motion.MoveLeft{Count: 1}, - "l": motion.MoveRight{Count: 1}, - "G": motion.MoveToBottom{}, - "gg": motion.MoveToTop{}, - "0": motion.MoveToLineStart{}, - "$": motion.MoveToLineEnd{}, - "_": motion.MoveToLineContentStart{}, - "w": motion.MoveForwardWord{Count: 1}, - "e": motion.MoveForwardWordEnd{Count: 1}, - "b": motion.MoveBackwardWord{Count: 1}, + "j": motion.MoveDown{Count: 1}, + "k": motion.MoveUp{Count: 1}, + "h": motion.MoveLeft{Count: 1}, + "l": motion.MoveRight{Count: 1}, + "G": motion.MoveToBottom{}, + "gg": motion.MoveToTop{}, + "0": motion.MoveToLineStart{}, + "$": motion.MoveToLineEnd{}, + "_": motion.MoveToLineContentStart{}, + "w": motion.MoveForwardWord{Count: 1}, + "e": motion.MoveForwardWordEnd{Count: 1}, + "b": motion.MoveBackwardWord{Count: 1}, + "ctrl+u": motion.ScrollUpHalfPage{}, + "ctrl+d": motion.ScrollDownHalfPage{}, }, operators: map[string]action.Operator{ "d": operator.DeleteOperator{}, @@ -45,7 +47,6 @@ func NewNormalKeymap() *Keymap { "o": action.OpenLineBelow{}, "O": action.OpenLineAbove{}, "x": action.DeleteChar{Count: 1}, - "ctrl+c": action.Quit{}, ":": action.EnterComandMode{}, "v": action.EnterVisualMode{}, "V": action.EnterVisualLineMode{}, @@ -81,7 +82,6 @@ func NewVisualKeymap() *Keymap { // "~": SwapCaseOp{}, }, actions: map[string]action.Action{ - "ctrl+c": action.Quit{}, // ":": action.EnterComandMode{}, // Different OP }, } @@ -102,7 +102,6 @@ func NewInsertKeymap() *Keymap { "delete": action.InsertDelete{}, "tab": action.InsertTab{}, "ctrl+w": action.InsertDeletePreviousWord{}, - "ctrl+c": action.Quit{}, }, } diff --git a/internal/motion/jump.go b/internal/motion/jump.go index b0721a1..08713fe 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -73,3 +73,81 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd { } func (a MoveToLineContentStart) Type() action.MotionType { return action.Charwise } + +// TODO: Count for these, maybe? + +// ScrollDownHalfPage implements Motion (ctrl+d) - linewise +type ScrollDownHalfPage struct{} + +func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd { + viewportHeight := m.ViewPortH() + if viewportHeight <= 0 { + return nil + } + + scroll := viewportHeight / 2 + scrollOff := m.Settings().ScrollOff + + // Current relative position in viewport + relY := m.CursorY() - m.ScrollY() + + // Scroll down, clamped to valid range + newScrollY := m.ScrollY() + scroll + maxScroll := max(0, m.LineCount()-viewportHeight) + newScrollY = min(newScrollY, maxScroll) + m.SetScrollY(newScrollY) + + // Maintain relative position, respecting scrollOff + if relY < scrollOff { + relY = scrollOff + } + if relY > viewportHeight-1-scrollOff { + relY = viewportHeight - 1 - scrollOff + } + + newCursorY := newScrollY + relY + newCursorY = max(0, min(newCursorY, m.LineCount()-1)) + m.SetCursorY(newCursorY) + m.ClampCursorX() + + return nil +} + +func (a ScrollDownHalfPage) Type() action.MotionType { return action.Linewise } + +// ScrollUpHalfPage implements Motion (ctrl+u) - linewise +type ScrollUpHalfPage struct{} + +func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd { + viewportHeight := m.ViewPortH() + if viewportHeight <= 0 { + return nil + } + scroll := viewportHeight / 2 + scrollOff := m.Settings().ScrollOff + + // Current relative position in viewport + relY := m.CursorY() - m.ScrollY() + + // Scroll up, clamped to valid range + newScrollY := m.ScrollY() - scroll + newScrollY = max(0, newScrollY) + m.SetScrollY(newScrollY) + + // Maintain relative position, respecting scrollOff + if relY < scrollOff { + relY = scrollOff + } + if relY > viewportHeight-1-scrollOff { + relY = viewportHeight - 1 - scrollOff + } + + newCursorY := newScrollY + relY + newCursorY = max(0, min(newCursorY, m.LineCount()-1)) + m.SetCursorY(newCursorY) + m.ClampCursorX() + + return nil +} + +func (a ScrollUpHalfPage) Type() action.MotionType { return action.Linewise }