From 8c1c88ed30fbdb9242f86ece21794cf1b6568cf1 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Wed, 1 Apr 2026 14:24:02 -0400 Subject: [PATCH 1/3] Added enhancements to scroll momentum --- .../src/ui/contexts/ScrollProvider.test.tsx | 2 +- .../cli/src/ui/contexts/ScrollProvider.tsx | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx index 4455b669191..2791b7256aa 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx @@ -579,7 +579,7 @@ describe('ScrollProvider', () => { 0, ); expect(totalDelta).toBeGreaterThan(60); - expect(totalDelta).toBe(150); + expect(totalDelta).toBe(149); }); it('does not accelerate for Ghostty terminals even during rapid scrolling', async () => { diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx index 16b63416b62..d4b2cfc3863 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -90,6 +90,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ next.delete(id); return next; }); + pendingScrollsRef.current.delete(id); }, []); const scrollablesRef = useRef(scrollables); @@ -118,10 +119,23 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ for (const [id, delta] of pendingScrollsRef.current.entries()) { const entry = scrollablesRef.current.get(id); if (entry) { - entry.scrollBy(delta); + const truncatedDelta = Math.trunc(delta); + if (truncatedDelta !== 0) { + entry.scrollBy(truncatedDelta); + } + + const remainder = delta - truncatedDelta; + // Keep the fractional remainder for the next scroll event to ensure + // smooth accumulation across flushes. + if (Math.abs(remainder) > 0.001) { + pendingScrollsRef.current.set(id, remainder); + } else { + pendingScrollsRef.current.delete(id); + } + } else { + pendingScrollsRef.current.delete(id); } } - pendingScrollsRef.current.clear(); }, 0); } }, []); @@ -129,6 +143,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 +152,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 +169,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( @@ -168,7 +187,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ const canScrollUp = effectiveScrollTop > 0.001; const canScrollDown = effectiveScrollTop < scrollHeight - innerHeight - 0.001; - const totalDelta = Math.round(pendingDelta + delta); + const totalDelta = pendingDelta + delta; if (direction === 'up' && canScrollUp) { pendingScrollsRef.current.set(candidate.id, totalDelta); From b5714fdb1a88dfe22b26c50b2d11da51d2fe73e2 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Thu, 2 Apr 2026 17:54:11 -0400 Subject: [PATCH 2/3] Addressed comments --- .../cli/src/ui/contexts/ScrollProvider.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx index d4b2cfc3863..f5ee4f44886 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -41,6 +41,11 @@ interface ScrollContextType { const ScrollContext = createContext(null); +/** + * The minimum fractional scroll delta to track. + */ +const SCROLL_STATIC_FRICTION = 0.001; + const findScrollableCandidates = ( mouseEvent: MouseEvent, scrollables: Map, @@ -119,15 +124,15 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ for (const [id, delta] of pendingScrollsRef.current.entries()) { const entry = scrollablesRef.current.get(id); if (entry) { - const truncatedDelta = Math.trunc(delta); - if (truncatedDelta !== 0) { - entry.scrollBy(truncatedDelta); + const roundedDelta = Math.round(delta); + if (roundedDelta !== 0) { + entry.scrollBy(roundedDelta); } - const remainder = delta - truncatedDelta; + const remainder = delta - roundedDelta; // Keep the fractional remainder for the next scroll event to ensure // smooth accumulation across flushes. - if (Math.abs(remainder) > 0.001) { + if (Math.abs(remainder) > SCROLL_STATIC_FRICTION) { pendingScrollsRef.current.set(id, remainder); } else { pendingScrollsRef.current.delete(id); @@ -184,9 +189,10 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ const effectiveScrollTop = scrollTop + pendingDelta; // Epsilon to handle floating point inaccuracies. - const canScrollUp = effectiveScrollTop > 0.001; + const canScrollUp = effectiveScrollTop > SCROLL_STATIC_FRICTION; const canScrollDown = - effectiveScrollTop < scrollHeight - innerHeight - 0.001; + effectiveScrollTop < + scrollHeight - innerHeight - SCROLL_STATIC_FRICTION; const totalDelta = pendingDelta + delta; if (direction === 'up' && canScrollUp) { From b43a1f613cc968f0cd66a9ff0947c878f57916e8 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Fri, 3 Apr 2026 15:40:41 -0400 Subject: [PATCH 3/3] Changed scrolling architecture to use a float value instead of deltas --- .../src/ui/contexts/ScrollProvider.test.tsx | 2 +- .../cli/src/ui/contexts/ScrollProvider.tsx | 103 +++++++++++++----- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx index 2791b7256aa..4455b669191 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx @@ -579,7 +579,7 @@ describe('ScrollProvider', () => { 0, ); expect(totalDelta).toBeGreaterThan(60); - expect(totalDelta).toBe(149); + expect(totalDelta).toBe(150); }); it('does not accelerate for Ghostty terminals even during rapid scrolling', async () => { diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx index f5ee4f44886..9cbe3154d8d 100644 --- a/packages/cli/src/ui/contexts/ScrollProvider.tsx +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -46,6 +46,19 @@ const ScrollContext = createContext(null); */ 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, @@ -95,7 +108,8 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ next.delete(id); return next; }); - pendingScrollsRef.current.delete(id); + trueScrollRef.current.delete(id); + pendingFlushRef.current.delete(id); }, []); const scrollablesRef = useRef(scrollables); @@ -103,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<{ @@ -121,24 +138,43 @@ 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) { - const roundedDelta = Math.round(delta); - if (roundedDelta !== 0) { - entry.scrollBy(roundedDelta); + 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 remainder = delta - roundedDelta; - // Keep the fractional remainder for the next scroll event to ensure - // smooth accumulation across flushes. - if (Math.abs(remainder) > SCROLL_STATIC_FRICTION) { - pendingScrollsRef.current.set(id, remainder); - } else { - pendingScrollsRef.current.delete(id); + 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 { - pendingScrollsRef.current.delete(id); + trueScrollRef.current.delete(id); } } }, 0); @@ -185,24 +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 > SCROLL_STATIC_FRICTION; + let trueScroll = trueScrollRef.current.get(candidate.id); + if (!trueScroll || trueScroll.expectedScrollTop !== scrollTop) { + trueScroll = { floatValue: scrollTop, expectedScrollTop: scrollTop }; + } + + const maxScroll = Math.max(0, scrollHeight - innerHeight); + const canScrollUp = trueScroll.floatValue > SCROLL_STATIC_FRICTION; const canScrollDown = - effectiveScrollTop < - scrollHeight - innerHeight - SCROLL_STATIC_FRICTION; - const totalDelta = pendingDelta + delta; + trueScroll.floatValue < maxScroll - SCROLL_STATIC_FRICTION; - if (direction === 'up' && canScrollUp) { - pendingScrollsRef.current.set(candidate.id, totalDelta); - scheduleFlush(); - return true; - } + 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, + }); - if (direction === 'down' && canScrollDown) { - pendingScrollsRef.current.set(candidate.id, totalDelta); + pendingFlushRef.current.add(candidate.id); scheduleFlush(); return true; }