diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx index 16b63416b6..9cbe3154d8 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -41,6 +41,24 @@ interface ScrollContextType { const ScrollContext = createContext(null); +/** + * The minimum fractional scroll delta to track. + */ +const SCROLL_STATIC_FRICTION = 0.001; + +/** + * Calculates a scroll top value clamped between 0 and the maximum possible + * scroll position for the given container dimensions. + */ +const getClampedScrollTop = ( + scrollTop: number, + scrollHeight: number, + innerHeight: number, +) => { + const maxScroll = Math.max(0, scrollHeight - innerHeight); + return Math.max(0, Math.min(scrollTop, maxScroll)); +}; + const findScrollableCandidates = ( mouseEvent: MouseEvent, scrollables: Map, @@ -90,6 +108,8 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ next.delete(id); return next; }); + trueScrollRef.current.delete(id); + pendingFlushRef.current.delete(id); }, []); const scrollablesRef = useRef(scrollables); @@ -97,7 +117,10 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ scrollablesRef.current = scrollables; }, [scrollables]); - const pendingScrollsRef = useRef(new Map()); + const trueScrollRef = useRef( + new Map(), + ); + const pendingFlushRef = useRef(new Set()); const flushScheduledRef = useRef(false); const dragStateRef = useRef<{ @@ -115,13 +138,45 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ flushScheduledRef.current = true; setTimeout(() => { flushScheduledRef.current = false; - for (const [id, delta] of pendingScrollsRef.current.entries()) { + const ids = Array.from(pendingFlushRef.current); + pendingFlushRef.current.clear(); + + for (const id of ids) { const entry = scrollablesRef.current.get(id); - if (entry) { - entry.scrollBy(delta); + const trueScroll = trueScrollRef.current.get(id); + + if (entry && trueScroll) { + const { scrollTop, scrollHeight, innerHeight } = + entry.getScrollState(); + + // Re-verify it hasn't become stale before flushing + if (trueScroll.expectedScrollTop !== scrollTop) { + trueScrollRef.current.set(id, { + floatValue: scrollTop, + expectedScrollTop: scrollTop, + }); + continue; + } + + const clampedFloat = getClampedScrollTop( + trueScroll.floatValue, + scrollHeight, + innerHeight, + ); + const roundedTarget = Math.round(clampedFloat); + + const deltaToApply = roundedTarget - scrollTop; + + if (deltaToApply !== 0) { + entry.scrollBy(deltaToApply); + trueScroll.expectedScrollTop = roundedTarget; + } + + trueScroll.floatValue = clampedFloat; + } else { + trueScrollRef.current.delete(id); } } - pendingScrollsRef.current.clear(); }, 0); } }, []); @@ -129,6 +184,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ const scrollMomentumRef = useRef({ count: 0, lastTime: 0, + lastDirection: null as 'up' | 'down' | null, }); const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => { @@ -137,8 +193,11 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ if (!terminalCapabilityManager.isGhosttyTerminal()) { const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime; + const isSameDirection = + scrollMomentumRef.current.lastDirection === direction; + // 50ms threshold to consider scrolls consecutive - if (timeSinceLastScroll < 50) { + if (timeSinceLastScroll < 50 && isSameDirection) { scrollMomentumRef.current.count += 1; // Accelerate up to 3x, starting after 5 consecutive scrolls. // Each consecutive scroll increases the multiplier by 0.1. @@ -151,6 +210,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ } } scrollMomentumRef.current.lastTime = now; + scrollMomentumRef.current.lastDirection = direction; const delta = (direction === 'up' ? -1 : 1) * multiplier; const candidates = findScrollableCandidates( @@ -161,23 +221,33 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ for (const candidate of candidates) { const { scrollTop, scrollHeight, innerHeight } = candidate.getScrollState(); - const pendingDelta = pendingScrollsRef.current.get(candidate.id) || 0; - const effectiveScrollTop = scrollTop + pendingDelta; - // Epsilon to handle floating point inaccuracies. - const canScrollUp = effectiveScrollTop > 0.001; - const canScrollDown = - effectiveScrollTop < scrollHeight - innerHeight - 0.001; - const totalDelta = Math.round(pendingDelta + delta); - - if (direction === 'up' && canScrollUp) { - pendingScrollsRef.current.set(candidate.id, totalDelta); - scheduleFlush(); - return true; + let trueScroll = trueScrollRef.current.get(candidate.id); + if (!trueScroll || trueScroll.expectedScrollTop !== scrollTop) { + trueScroll = { floatValue: scrollTop, expectedScrollTop: scrollTop }; } - if (direction === 'down' && canScrollDown) { - pendingScrollsRef.current.set(candidate.id, totalDelta); + const maxScroll = Math.max(0, scrollHeight - innerHeight); + const canScrollUp = trueScroll.floatValue > SCROLL_STATIC_FRICTION; + const canScrollDown = + trueScroll.floatValue < maxScroll - SCROLL_STATIC_FRICTION; + + if ( + (direction === 'up' && canScrollUp) || + (direction === 'down' && canScrollDown) + ) { + const clampedFloat = getClampedScrollTop( + trueScroll.floatValue + delta, + scrollHeight, + innerHeight, + ); + + trueScrollRef.current.set(candidate.id, { + floatValue: clampedFloat, + expectedScrollTop: trueScroll.expectedScrollTop, + }); + + pendingFlushRef.current.add(candidate.id); scheduleFlush(); return true; }