/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useRef, forwardRef, useImperativeHandle, useCallback, useMemo, useLayoutEffect, useEffect, useContext, } from 'react'; import type React from 'react'; import { VirtualizedList, type VirtualizedListRef, type VirtualizedListProps, SCROLL_TO_ITEM_END, } from './VirtualizedList.js'; import { useScrollable } from '../../contexts/ScrollProvider.js'; import { Box, type DOMElement } from 'ink'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { Command } from '../../key/keyMatchers.js'; import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { VirtualizedListContext } from './VirtualizedList.js'; const ANIMATION_FRAME_DURATION_MS = 33; interface ScrollableListProps extends VirtualizedListProps { itemKey?: string; hasFocus: boolean; width?: string | number; scrollbar?: boolean; stableScrollback?: boolean; isStatic?: boolean; targetScrollIndex?: number; containerHeight?: number; scrollbarThumbColor?: string; } export type ScrollableListRef = VirtualizedListRef; function ScrollableList( props: ScrollableListProps, ref: React.Ref>, ) { const keyMatchers = useKeyMatchers(); const settings = useSettings(); const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength; const { hasFocus, width, scrollbar = true, stableScrollback, itemKey, } = props; const virtualizedListRef = useRef>(null); const containerRef = useRef(null); const virtualizedListContext = useContext(VirtualizedListContext); useLayoutEffect(() => { if (itemKey && virtualizedListContext) { const restoredTop = virtualizedListContext.getItemState( itemKey, 'scrollTop', ); if (typeof restoredTop === 'number') { virtualizedListRef.current?.scrollTo(restoredTop); } } }, [itemKey, virtualizedListContext]); useEffect( () => () => { if (itemKey && virtualizedListContext) { const top = virtualizedListRef.current?.getScrollState().scrollTop; if (top !== undefined) { virtualizedListContext.setItemState(itemKey, 'scrollTop', top); } } }, [itemKey, virtualizedListContext], ); useImperativeHandle( ref, () => ({ scrollBy: (delta) => virtualizedListRef.current?.scrollBy(delta), scrollTo: (offset) => virtualizedListRef.current?.scrollTo(offset), scrollToEnd: () => virtualizedListRef.current?.scrollToEnd(), scrollToIndex: (params) => virtualizedListRef.current?.scrollToIndex(params), scrollToItem: (params) => virtualizedListRef.current?.scrollToItem(params), getScrollIndex: () => virtualizedListRef.current?.getScrollIndex() ?? 0, getScrollState: () => virtualizedListRef.current?.getScrollState() ?? { scrollTop: 0, scrollHeight: 0, innerHeight: 0, }, }), [], ); const getScrollState = useCallback( () => virtualizedListRef.current?.getScrollState() ?? { scrollTop: 0, scrollHeight: 0, innerHeight: 0, }, [], ); const scrollBy = useCallback((delta: number) => { virtualizedListRef.current?.scrollBy(delta); }, []); const { scrollbarColor, flashScrollbar, scrollByWithAnimation } = useAnimatedScrollbar(hasFocus, scrollBy); const smoothScrollState = useRef<{ active: boolean; start: number; from: number; to: number; duration: number; timer: NodeJS.Timeout | null; }>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null }); const stopSmoothScroll = useCallback(() => { if (smoothScrollState.current.timer) { clearInterval(smoothScrollState.current.timer); smoothScrollState.current.timer = null; } smoothScrollState.current.active = false; }, []); useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]); const smoothScrollTo = useCallback( ( targetScrollTop: number, duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200, ) => { stopSmoothScroll(); const scrollState = virtualizedListRef.current?.getScrollState() ?? { scrollTop: 0, scrollHeight: 0, innerHeight: 0, }; const { scrollTop: rawStartScrollTop, scrollHeight, innerHeight, } = scrollState; const maxScrollTop = Math.max(0, scrollHeight - innerHeight); const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop); let effectiveTarget = targetScrollTop; if ( targetScrollTop === SCROLL_TO_ITEM_END || targetScrollTop >= maxScrollTop ) { effectiveTarget = maxScrollTop; } const clampedTarget = Math.max( 0, Math.min(maxScrollTop, effectiveTarget), ); if (duration === 0) { if ( targetScrollTop === SCROLL_TO_ITEM_END || targetScrollTop >= maxScrollTop ) { virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER); } else { virtualizedListRef.current?.scrollTo(Math.round(clampedTarget)); } flashScrollbar(); return; } smoothScrollState.current = { active: true, start: Date.now(), from: startScrollTop, to: clampedTarget, duration, timer: setInterval(() => { const now = Date.now(); const elapsed = now - smoothScrollState.current.start; const progress = Math.min(elapsed / duration, 1); // Ease-in-out const t = progress; const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; const current = smoothScrollState.current.from + (smoothScrollState.current.to - smoothScrollState.current.from) * ease; if (progress >= 1) { if ( targetScrollTop === SCROLL_TO_ITEM_END || targetScrollTop >= maxScrollTop ) { virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER); } else { virtualizedListRef.current?.scrollTo(Math.round(current)); } stopSmoothScroll(); flashScrollbar(); } else { virtualizedListRef.current?.scrollTo(Math.round(current)); } }, ANIMATION_FRAME_DURATION_MS), }; }, [stopSmoothScroll, flashScrollbar], ); useKeypress( (key: Key) => { if (keyMatchers[Command.SCROLL_UP](key)) { stopSmoothScroll(); scrollByWithAnimation(-1); return true; } else if (keyMatchers[Command.SCROLL_DOWN](key)) { stopSmoothScroll(); scrollByWithAnimation(1); return true; } else if ( keyMatchers[Command.PAGE_UP](key) || keyMatchers[Command.PAGE_DOWN](key) ) { const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1; const scrollState = getScrollState(); const maxScroll = Math.max( 0, scrollState.scrollHeight - scrollState.innerHeight, ); const current = smoothScrollState.current.active ? smoothScrollState.current.to : Math.min(scrollState.scrollTop, maxScroll); const innerHeight = scrollState.innerHeight; smoothScrollTo(current + direction * innerHeight); return true; } else if (keyMatchers[Command.SCROLL_HOME](key)) { smoothScrollTo(0); return true; } else if (keyMatchers[Command.SCROLL_END](key)) { smoothScrollTo(SCROLL_TO_ITEM_END); return true; } return false; }, { isActive: hasFocus }, ); const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]); const scrollableEntry = useMemo( () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ref: containerRef as React.RefObject, getScrollState, scrollBy: scrollByWithAnimation, scrollTo: smoothScrollTo, hasFocus: hasFocusCallback, flashScrollbar, }), [ getScrollState, hasFocusCallback, flashScrollbar, scrollByWithAnimation, smoothScrollTo, ], ); useScrollable(scrollableEntry, true); return ( ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const ScrollableListWithForwardRef = forwardRef(ScrollableList) as ( props: ScrollableListProps & { ref?: React.Ref> }, ) => React.ReactElement; export { ScrollableListWithForwardRef as ScrollableList };