| |
| |
| |
| |
| |
|
|
| import { render } from '../../test-utils/render.js'; |
| import { |
| ScrollProvider, |
| useScrollable, |
| type ScrollState, |
| } from './ScrollProvider.js'; |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react'; |
| import { Box, type DOMElement } from 'ink'; |
| import type { MouseEvent } from '../hooks/useMouse.js'; |
| import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; |
|
|
| vi.mock('../utils/terminalCapabilityManager.js', () => ({ |
| terminalCapabilityManager: { |
| isGhosttyTerminal: vi.fn(() => false), |
| }, |
| })); |
|
|
| |
| const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>(); |
| vi.mock('../hooks/useMouse.js', async () => { |
| |
| const React = await import('react'); |
| return { |
| useMouse: (callback: (event: MouseEvent) => void | boolean) => { |
| React.useEffect(() => { |
| mockUseMouseCallbacks.add(callback); |
| return () => { |
| mockUseMouseCallbacks.delete(callback); |
| }; |
| }, [callback]); |
| }, |
| }; |
| }); |
|
|
| |
| vi.mock('ink', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('ink')>(); |
| return { |
| ...actual, |
| getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 10 })), |
| }; |
| }); |
|
|
| const TestScrollable = forwardRef( |
| ( |
| props: { |
| id: string; |
| scrollBy: (delta: number) => void; |
| scrollTo?: (scrollTop: number) => void; |
| getScrollState: () => ScrollState; |
| }, |
| ref, |
| ) => { |
| const elementRef = useRef<DOMElement>(null); |
| useImperativeHandle(ref, () => elementRef.current); |
|
|
| useScrollable( |
| { |
| ref: elementRef as RefObject<DOMElement>, |
| getScrollState: props.getScrollState, |
| scrollBy: props.scrollBy, |
| scrollTo: props.scrollTo, |
| hasFocus: () => true, |
| flashScrollbar: () => {}, |
| }, |
| true, |
| ); |
|
|
| return <Box ref={elementRef} />; |
| }, |
| ); |
| TestScrollable.displayName = 'TestScrollable'; |
|
|
| describe('ScrollProvider', () => { |
| beforeEach(() => { |
| vi.useFakeTimers(); |
| mockUseMouseCallbacks.clear(); |
| }); |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| vi.useRealTimers(); |
| }); |
|
|
| describe('Event Handling Status', () => { |
| it('returns true when scroll event is handled', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 0, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| let handled = false; |
| for (const callback of mockUseMouseCallbacks) { |
| if ( |
| callback({ |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }) === true |
| ) { |
| handled = true; |
| } |
| } |
| expect(handled).toBe(true); |
| }); |
|
|
| it('returns false when scroll event is ignored (cannot scroll further)', async () => { |
| const scrollBy = vi.fn(); |
| |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 90, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| let handled = false; |
| for (const callback of mockUseMouseCallbacks) { |
| if ( |
| callback({ |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }) === true |
| ) { |
| handled = true; |
| } |
| } |
| expect(handled).toBe(false); |
| }); |
| }); |
|
|
| it('calls scrollTo when clicking scrollbar track if available', async () => { |
| const scrollBy = vi.fn(); |
| const scrollTo = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 0, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| scrollTo={scrollTo} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'left-press', |
| col: 10, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| expect(scrollTo).toHaveBeenCalled(); |
| expect(scrollBy).not.toHaveBeenCalled(); |
| }); |
|
|
| it('calls scrollBy when clicking scrollbar track if scrollTo is not available', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 0, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'left-press', |
| col: 10, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| expect(scrollBy).toHaveBeenCalled(); |
| }); |
|
|
| it('batches multiple scroll events into a single update', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 0, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| |
| const mouseEvent: MouseEvent = { |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }; |
| for (const callback of mockUseMouseCallbacks) { |
| callback(mouseEvent); |
| callback(mouseEvent); |
| callback(mouseEvent); |
| } |
|
|
| |
| expect(scrollBy).not.toHaveBeenCalled(); |
|
|
| |
| await vi.runAllTimersAsync(); |
|
|
| |
| expect(scrollBy).toHaveBeenCalledTimes(1); |
| expect(scrollBy).toHaveBeenCalledWith(3); |
| }); |
|
|
| it('handles mixed direction scroll events in batch', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 10, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| |
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }); |
| callback({ |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }); |
| callback({ |
| name: 'scroll-up', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }); |
| } |
|
|
| expect(scrollBy).not.toHaveBeenCalled(); |
|
|
| await vi.runAllTimersAsync(); |
|
|
| expect(scrollBy).toHaveBeenCalledTimes(1); |
| expect(scrollBy).toHaveBeenCalledWith(1); |
| }); |
|
|
| it('respects scroll limits during batching', async () => { |
| const scrollBy = vi.fn(); |
| |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 89, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| |
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }); |
| callback({ |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }); |
| callback({ |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }); |
| } |
|
|
| await vi.runAllTimersAsync(); |
|
|
| |
| |
| |
| |
| |
| expect(scrollBy).toHaveBeenCalledTimes(1); |
| expect(scrollBy).toHaveBeenCalledWith(1); |
| }); |
|
|
| it('calls scrollTo when dragging scrollbar thumb if available', async () => { |
| const scrollBy = vi.fn(); |
| const scrollTo = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 0, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| scrollTo={scrollTo} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| |
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'left-press', |
| col: 10, |
| row: 0, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| |
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'move', |
| col: 10, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| |
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'left-release', |
| col: 10, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| expect(scrollTo).toHaveBeenCalled(); |
| expect(scrollBy).not.toHaveBeenCalled(); |
| }); |
|
|
| it('calls scrollBy when dragging scrollbar thumb if scrollTo is not available', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 0, |
| scrollHeight: 100, |
| innerHeight: 10, |
| })); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| |
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'left-press', |
| col: 10, |
| row: 0, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| |
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'move', |
| col: 10, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| for (const callback of mockUseMouseCallbacks) { |
| callback({ |
| name: 'left-release', |
| col: 10, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'left', |
| }); |
| } |
|
|
| expect(scrollBy).toHaveBeenCalled(); |
| }); |
|
|
| describe('Scroll Acceleration', () => { |
| it('accelerates scroll for non-Ghostty terminals during rapid scrolling', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 50, |
| scrollHeight: 1000, |
| innerHeight: 10, |
| })); |
|
|
| vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue( |
| false, |
| ); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| const mouseEvent: MouseEvent = { |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }; |
|
|
| |
| for (let i = 0; i < 60; i++) { |
| for (const callback of mockUseMouseCallbacks) { |
| callback(mouseEvent); |
| } |
| |
| vi.advanceTimersByTime(10); |
| } |
|
|
| await vi.runAllTimersAsync(); |
|
|
| |
| const totalDelta = scrollBy.mock.calls.reduce( |
| (sum, call) => sum + call[0], |
| 0, |
| ); |
| expect(totalDelta).toBeGreaterThan(60); |
| expect(totalDelta).toBe(150); |
| }); |
|
|
| it('does not accelerate for Ghostty terminals even during rapid scrolling', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 50, |
| scrollHeight: 1000, |
| innerHeight: 10, |
| })); |
|
|
| vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue( |
| true, |
| ); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| const mouseEvent: MouseEvent = { |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }; |
|
|
| for (let i = 0; i < 60; i++) { |
| for (const callback of mockUseMouseCallbacks) { |
| callback(mouseEvent); |
| } |
| vi.advanceTimersByTime(10); |
| } |
|
|
| await vi.runAllTimersAsync(); |
|
|
| |
| const totalDelta = scrollBy.mock.calls.reduce( |
| (sum, call) => sum + call[0], |
| 0, |
| ); |
| expect(totalDelta).toBe(60); |
| }); |
|
|
| it('resets acceleration count if scrolling is slow', async () => { |
| const scrollBy = vi.fn(); |
| const getScrollState = vi.fn(() => ({ |
| scrollTop: 50, |
| scrollHeight: 1000, |
| innerHeight: 10, |
| })); |
|
|
| vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue( |
| false, |
| ); |
|
|
| await render( |
| <ScrollProvider> |
| <TestScrollable |
| id="test-scrollable" |
| scrollBy={scrollBy} |
| getScrollState={getScrollState} |
| /> |
| </ScrollProvider>, |
| ); |
|
|
| const mouseEvent: MouseEvent = { |
| name: 'scroll-down', |
| col: 5, |
| row: 5, |
| shift: false, |
| ctrl: false, |
| meta: false, |
| button: 'none', |
| }; |
|
|
| |
| for (let i = 0; i < 60; i++) { |
| for (const callback of mockUseMouseCallbacks) { |
| callback(mouseEvent); |
| } |
| vi.advanceTimersByTime(100); |
| } |
|
|
| await vi.runAllTimersAsync(); |
|
|
| |
| const totalDelta = scrollBy.mock.calls.reduce( |
| (sum, call) => sum + call[0], |
| 0, |
| ); |
| expect(totalDelta).toBe(60); |
| }); |
| }); |
| }); |
|
|