From 2b8adf8cf35a7f517fb901ae01fe816123c8529f Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Thu, 13 Nov 2025 14:13:18 -0800 Subject: [PATCH] jacob314/drag scrollbar (#12998) --- .../ui/components/shared/ScrollableList.tsx | 19 +- .../ui/contexts/ScrollProvider.drag.test.tsx | 432 ++++++++++++++++++ .../src/ui/contexts/ScrollProvider.test.tsx | 194 ++++++++ .../cli/src/ui/contexts/ScrollProvider.tsx | 157 ++++++- 4 files changed, 799 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/contexts/ScrollProvider.drag.test.tsx diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index a274e1001c..cfd314fb51 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -133,6 +133,16 @@ function ScrollableList( Math.min(maxScrollTop, effectiveTarget), ); + if (duration === 0) { + if (targetScrollTop === SCROLL_TO_ITEM_END) { + virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END); + } else { + virtualizedListRef.current?.scrollTo(Math.round(clampedTarget)); + } + flashScrollbar(); + return; + } + smoothScrollState.current = { active: true, start: Date.now(), @@ -205,10 +215,17 @@ function ScrollableList( ref: containerRef as React.RefObject, getScrollState, scrollBy: scrollByWithAnimation, + scrollTo: smoothScrollTo, hasFocus: hasFocusCallback, flashScrollbar, }), - [getScrollState, hasFocusCallback, flashScrollbar, scrollByWithAnimation], + [ + getScrollState, + hasFocusCallback, + flashScrollbar, + scrollByWithAnimation, + smoothScrollTo, + ], ); useScrollable(scrollableEntry, hasFocus); diff --git a/packages/cli/src/ui/contexts/ScrollProvider.drag.test.tsx b/packages/cli/src/ui/contexts/ScrollProvider.drag.test.tsx new file mode 100644 index 0000000000..542e2807eb --- /dev/null +++ b/packages/cli/src/ui/contexts/ScrollProvider.drag.test.tsx @@ -0,0 +1,432 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; + +// Mock useMouse hook +const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>(); +vi.mock('../hooks/useMouse.js', async () => { + // We need to import React dynamically because this factory runs before top-level imports + const React = await import('react'); + return { + useMouse: (callback: (event: MouseEvent) => void) => { + React.useEffect(() => { + mockUseMouseCallbacks.add(callback); + return () => { + mockUseMouseCallbacks.delete(callback); + }; + }, [callback]); + }, + }; +}); + +// Mock ink's getBoundingBox +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 10 })), + }; +}); + +const TestScrollable = forwardRef( + ( + props: { + id: string; + scrollBy: (delta: number) => void; + getScrollState: () => ScrollState; + }, + ref, + ) => { + const elementRef = useRef(null); + useImperativeHandle(ref, () => elementRef.current); + + useScrollable( + { + ref: elementRef as RefObject, + getScrollState: props.getScrollState, + scrollBy: props.scrollBy, + hasFocus: () => true, + flashScrollbar: () => {}, + }, + true, + ); + + return ; + }, +); +TestScrollable.displayName = 'TestScrollable'; + +describe('ScrollProvider Drag', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockUseMouseCallbacks.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('drags the scrollbar thumb', async () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 0, + scrollHeight: 100, + innerHeight: 10, + })); + + render( + + + , + ); + + // Scrollbar at x + width = 10. + // Height 10. + // scrollHeight 100, innerHeight 10. + // thumbHeight = 1. + // maxScrollTop = 90. maxThumbY = 9. Ratio = 10. + // Thumb at 0. + + // 1. Click on thumb (row 0) + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 0, + shift: false, + ctrl: false, + meta: false, + }); + } + + // 2. Move mouse to row 1 + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'move', + col: 10, // col doesn't matter for move if dragging + row: 1, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Delta row = 1. Delta scroll = 10. + // scrollBy called with 10. + expect(scrollBy).toHaveBeenCalledWith(10); + + // 3. Move mouse to row 2 + scrollBy.mockClear(); + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'move', + col: 10, + row: 2, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Delta row from start (0) is 2. Delta scroll = 20. + // startScrollTop was 0. target 20. + // scrollBy called with (20 - scrollTop). scrollTop is still 0 in mock. + expect(scrollBy).toHaveBeenCalledWith(20); + + // 4. Release + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-release', + col: 10, + row: 2, + shift: false, + ctrl: false, + meta: false, + }); + } + + // 5. Move again - should not scroll + scrollBy.mockClear(); + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'move', + col: 10, + row: 3, + shift: false, + ctrl: false, + meta: false, + }); + } + expect(scrollBy).not.toHaveBeenCalled(); + }); + + it('jumps to position and starts drag when clicking track below thumb', async () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 0, + scrollHeight: 100, + innerHeight: 10, + })); + + render( + + + , + ); + + // Thumb at 0. Click at 5. + // thumbHeight 1. + // targetThumbY = 5. + // targetScrollTop = 50. + + // 1. Click on track below thumb + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 5, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Should jump to 50 (delta 50) + expect(scrollBy).toHaveBeenCalledWith(50); + scrollBy.mockClear(); + + // 2. Move mouse to 6 - should drag + // Start drag captured at row 5, startScrollTop 50. + // Move to 6. Delta row 1. Delta scroll 10. + // Target = 60. + // scrollBy called with 60 - 0 (current state still 0). + // Note: In real app, state would update, but here getScrollState is static mock 0. + + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'move', + col: 10, + row: 6, + shift: false, + ctrl: false, + meta: false, + }); + } + + expect(scrollBy).toHaveBeenCalledWith(60); + }); + + it('jumps to position when clicking track above thumb', async () => { + const scrollBy = vi.fn(); + // Start scrolled down + const getScrollState = vi.fn(() => ({ + scrollTop: 50, + scrollHeight: 100, + innerHeight: 10, + })); + + render( + + + , + ); + + // Thumb at 5. Click at 2. + // targetThumbY = 2. + // targetScrollTop = 20. + + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 2, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Jump to 20 (delta = 20 - 50 = -30) + expect(scrollBy).toHaveBeenCalledWith(-30); + }); + + it('jumps to top when clicking very top of track', async () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 50, + scrollHeight: 100, + innerHeight: 10, + })); + + render( + + + , + ); + + // Thumb at 5. Click at 0. + // targetThumbY = 0. + // targetScrollTop = 0. + + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 0, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Scroll to top (delta = 0 - 50 = -50) + expect(scrollBy).toHaveBeenCalledWith(-50); + }); + + it('jumps to bottom when clicking very bottom of track', async () => { + const scrollBy = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 0, + scrollHeight: 100, + innerHeight: 10, + })); + + render( + + + , + ); + + // Thumb at 0. Click at 9. + // targetThumbY = 9. + // targetScrollTop = 90. + + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 9, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Scroll to bottom (delta = 90 - 0 = 90) + expect(scrollBy).toHaveBeenCalledWith(90); + }); + + it('uses scrollTo with 0 duration if provided', async () => { + const scrollBy = vi.fn(); + const scrollTo = vi.fn(); + const getScrollState = vi.fn(() => ({ + scrollTop: 0, + scrollHeight: 100, + innerHeight: 10, + })); + + // Custom component that provides scrollTo + const TestScrollableWithScrollTo = forwardRef( + ( + props: { + id: string; + scrollBy: (delta: number) => void; + scrollTo: (scrollTop: number, duration?: number) => void; + getScrollState: () => ScrollState; + }, + ref, + ) => { + const elementRef = useRef(null); + useImperativeHandle(ref, () => elementRef.current); + useScrollable( + { + ref: elementRef as RefObject, + getScrollState: props.getScrollState, + scrollBy: props.scrollBy, + scrollTo: props.scrollTo, + hasFocus: () => true, + flashScrollbar: () => {}, + }, + true, + ); + return ; + }, + ); + TestScrollableWithScrollTo.displayName = 'TestScrollableWithScrollTo'; + + render( + + + , + ); + + // Click on track (jump) + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 5, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Expect scrollTo to be called with target (and undefined/default duration) + expect(scrollTo).toHaveBeenCalledWith(50); + + scrollTo.mockClear(); + + // Move mouse (drag) + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'move', + col: 10, + row: 6, + shift: false, + ctrl: false, + meta: false, + }); + } + // Expect scrollTo to be called with target and duration 0 + expect(scrollTo).toHaveBeenCalledWith(60, 0); + }); +}); diff --git a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx index 6ad43742c3..a741579f15 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx @@ -46,6 +46,7 @@ const TestScrollable = forwardRef( props: { id: string; scrollBy: (delta: number) => void; + scrollTo?: (scrollTop: number) => void; getScrollState: () => ScrollState; }, ref, @@ -58,6 +59,7 @@ const TestScrollable = forwardRef( ref: elementRef as RefObject, getScrollState: props.getScrollState, scrollBy: props.scrollBy, + scrollTo: props.scrollTo, hasFocus: () => true, flashScrollbar: () => {}, }, @@ -79,6 +81,82 @@ describe('ScrollProvider', () => { vi.useRealTimers(); }); + 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, + })); + + render( + + + , + ); + + // Scrollbar is at x + width = 0 + 10 = 10. + // Height is 10. y is 0. + // Click at col 10, row 5. + // Thumb height = 10/100 * 10 = 1. + // Max thumb Y = 10 - 1 = 9. + // Current thumb Y = 0. + // Click at row 5 (relative Y = 5). This is outside the thumb (0). + // It's a track click. + + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 5, + shift: false, + ctrl: false, + meta: false, + }); + } + + 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, + })); + + render( + + + , + ); + + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 5, + shift: false, + ctrl: false, + meta: false, + }); + } + + expect(scrollBy).toHaveBeenCalled(); + }); + it('batches multiple scroll events into a single update', async () => { const scrollBy = vi.fn(); const getScrollState = vi.fn(() => ({ @@ -234,4 +312,120 @@ describe('ScrollProvider', () => { 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, + })); + + render( + + + , + ); + + // Start drag on thumb + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 0, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Move mouse down + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'move', + col: 10, + row: 5, // Move down 5 units + shift: false, + ctrl: false, + meta: false, + }); + } + + // Release + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-release', + col: 10, + row: 5, + shift: false, + ctrl: false, + meta: false, + }); + } + + 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, + })); + + render( + + + , + ); + + // Start drag on thumb + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-press', + col: 10, + row: 0, + shift: false, + ctrl: false, + meta: false, + }); + } + + // Move mouse down + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'move', + col: 10, + row: 5, + shift: false, + ctrl: false, + meta: false, + }); + } + + for (const callback of mockUseMouseCallbacks) { + callback({ + name: 'left-release', + col: 10, + row: 5, + shift: false, + ctrl: false, + meta: false, + }); + } + + expect(scrollBy).toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx index 0018a03027..3c272f596d 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -28,6 +28,7 @@ export interface ScrollableEntry { ref: React.RefObject; getScrollState: () => ScrollState; scrollBy: (delta: number) => void; + scrollTo?: (scrollTop: number, duration?: number) => void; hasFocus: () => boolean; flashScrollbar: () => void; } @@ -98,6 +99,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ const pendingScrollsRef = useRef(new Map()); const flushScheduledRef = useRef(false); + const dragStateRef = useRef<{ + active: boolean; + id: string | null; + offset: number; + }>({ + active: false, + id: null, + offset: 0, + }); + const scheduleFlush = useCallback(() => { if (!flushScheduledRef.current) { flushScheduledRef.current = true; @@ -146,7 +157,91 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ } }; - const handleClick = (mouseEvent: MouseEvent) => { + const handleLeftPress = (mouseEvent: MouseEvent) => { + // Check for scrollbar interaction first + for (const entry of scrollablesRef.current.values()) { + if (!entry.ref.current || !entry.hasFocus()) { + continue; + } + + const boundingBox = getBoundingBox(entry.ref.current); + if (!boundingBox) continue; + + const { x, y, width, height } = boundingBox; + + // Check if click is on the scrollbar column (x + width) + // The findScrollableCandidates logic implies scrollbar is at x + width. + if ( + mouseEvent.col === x + width && + mouseEvent.row >= y && + mouseEvent.row < y + height + ) { + const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState(); + + if (scrollHeight <= innerHeight) continue; + + const thumbHeight = Math.max( + 1, + Math.floor((innerHeight / scrollHeight) * innerHeight), + ); + const maxScrollTop = scrollHeight - innerHeight; + const maxThumbY = innerHeight - thumbHeight; + + if (maxThumbY <= 0) continue; + + const currentThumbY = Math.round( + (scrollTop / maxScrollTop) * maxThumbY, + ); + + const absoluteThumbTop = y + currentThumbY; + const absoluteThumbBottom = absoluteThumbTop + thumbHeight; + + const isTop = mouseEvent.row === y; + const isBottom = mouseEvent.row === y + height - 1; + + const hitTop = isTop ? absoluteThumbTop : absoluteThumbTop - 1; + const hitBottom = isBottom + ? absoluteThumbBottom + : absoluteThumbBottom + 1; + + const isThumbClick = + mouseEvent.row >= hitTop && mouseEvent.row < hitBottom; + + let offset = 0; + const relativeMouseY = mouseEvent.row - y; + + if (isThumbClick) { + offset = relativeMouseY - currentThumbY; + } else { + // Track click - Jump to position + // Center the thumb on the mouse click + const targetThumbY = Math.max( + 0, + Math.min(maxThumbY, relativeMouseY - Math.floor(thumbHeight / 2)), + ); + + const newScrollTop = Math.round( + (targetThumbY / maxThumbY) * maxScrollTop, + ); + if (entry.scrollTo) { + entry.scrollTo(newScrollTop); + } else { + entry.scrollBy(newScrollTop - scrollTop); + } + + offset = relativeMouseY - targetThumbY; + } + + // Start drag (for both thumb and track clicks) + dragStateRef.current = { + active: true, + id: entry.id, + offset, + }; + return; + } + } + const candidates = findScrollableCandidates( mouseEvent, scrollablesRef.current, @@ -158,6 +253,60 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ } }; + const handleMove = (mouseEvent: MouseEvent) => { + const state = dragStateRef.current; + if (!state.active || !state.id) return; + + const entry = scrollablesRef.current.get(state.id); + if (!entry || !entry.ref.current) { + state.active = false; + return; + } + + const boundingBox = getBoundingBox(entry.ref.current); + if (!boundingBox) return; + + const { y } = boundingBox; + const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState(); + + const thumbHeight = Math.max( + 1, + Math.floor((innerHeight / scrollHeight) * innerHeight), + ); + const maxScrollTop = scrollHeight - innerHeight; + const maxThumbY = innerHeight - thumbHeight; + + if (maxThumbY <= 0) return; + + const relativeMouseY = mouseEvent.row - y; + // Calculate the target thumb position based on the mouse position and the offset. + // We clamp it to the valid range [0, maxThumbY]. + const targetThumbY = Math.max( + 0, + Math.min(maxThumbY, relativeMouseY - state.offset), + ); + + const targetScrollTop = Math.round( + (targetThumbY / maxThumbY) * maxScrollTop, + ); + + if (entry.scrollTo) { + entry.scrollTo(targetScrollTop, 0); + } else { + entry.scrollBy(targetScrollTop - scrollTop); + } + }; + + const handleLeftRelease = () => { + if (dragStateRef.current.active) { + dragStateRef.current = { + active: false, + id: null, + offset: 0, + }; + } + }; + useMouse( (event: MouseEvent) => { if (event.name === 'scroll-up') { @@ -165,7 +314,11 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ } else if (event.name === 'scroll-down') { handleScroll('down', event); } else if (event.name === 'left-press') { - handleClick(event); + handleLeftPress(event); + } else if (event.name === 'move') { + handleMove(event); + } else if (event.name === 'left-release') { + handleLeftRelease(); } }, { isActive: true },