feat(ui): added enhancements to scroll momentum (#24447)

This commit is contained in:
Dev Randalpura
2026-04-13 14:48:55 -04:00
committed by GitHub
parent 0d6d5d90b9
commit a05c5ed56a
+90 -20
View File
@@ -41,6 +41,24 @@ interface ScrollContextType {
const ScrollContext = createContext<ScrollContextType | null>(null); const ScrollContext = createContext<ScrollContextType | null>(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 = ( const findScrollableCandidates = (
mouseEvent: MouseEvent, mouseEvent: MouseEvent,
scrollables: Map<string, ScrollableEntry>, scrollables: Map<string, ScrollableEntry>,
@@ -90,6 +108,8 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
next.delete(id); next.delete(id);
return next; return next;
}); });
trueScrollRef.current.delete(id);
pendingFlushRef.current.delete(id);
}, []); }, []);
const scrollablesRef = useRef(scrollables); const scrollablesRef = useRef(scrollables);
@@ -97,7 +117,10 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
scrollablesRef.current = scrollables; scrollablesRef.current = scrollables;
}, [scrollables]); }, [scrollables]);
const pendingScrollsRef = useRef(new Map<string, number>()); const trueScrollRef = useRef(
new Map<string, { floatValue: number; expectedScrollTop: number }>(),
);
const pendingFlushRef = useRef(new Set<string>());
const flushScheduledRef = useRef(false); const flushScheduledRef = useRef(false);
const dragStateRef = useRef<{ const dragStateRef = useRef<{
@@ -115,13 +138,45 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
flushScheduledRef.current = true; flushScheduledRef.current = true;
setTimeout(() => { setTimeout(() => {
flushScheduledRef.current = false; 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); const entry = scrollablesRef.current.get(id);
if (entry) { const trueScroll = trueScrollRef.current.get(id);
entry.scrollBy(delta);
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); }, 0);
} }
}, []); }, []);
@@ -129,6 +184,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
const scrollMomentumRef = useRef({ const scrollMomentumRef = useRef({
count: 0, count: 0,
lastTime: 0, lastTime: 0,
lastDirection: null as 'up' | 'down' | null,
}); });
const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => { const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => {
@@ -137,8 +193,11 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (!terminalCapabilityManager.isGhosttyTerminal()) { if (!terminalCapabilityManager.isGhosttyTerminal()) {
const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime; const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime;
const isSameDirection =
scrollMomentumRef.current.lastDirection === direction;
// 50ms threshold to consider scrolls consecutive // 50ms threshold to consider scrolls consecutive
if (timeSinceLastScroll < 50) { if (timeSinceLastScroll < 50 && isSameDirection) {
scrollMomentumRef.current.count += 1; scrollMomentumRef.current.count += 1;
// Accelerate up to 3x, starting after 5 consecutive scrolls. // Accelerate up to 3x, starting after 5 consecutive scrolls.
// Each consecutive scroll increases the multiplier by 0.1. // 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.lastTime = now;
scrollMomentumRef.current.lastDirection = direction;
const delta = (direction === 'up' ? -1 : 1) * multiplier; const delta = (direction === 'up' ? -1 : 1) * multiplier;
const candidates = findScrollableCandidates( const candidates = findScrollableCandidates(
@@ -161,23 +221,33 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
for (const candidate of candidates) { for (const candidate of candidates) {
const { scrollTop, scrollHeight, innerHeight } = const { scrollTop, scrollHeight, innerHeight } =
candidate.getScrollState(); candidate.getScrollState();
const pendingDelta = pendingScrollsRef.current.get(candidate.id) || 0;
const effectiveScrollTop = scrollTop + pendingDelta;
// Epsilon to handle floating point inaccuracies. let trueScroll = trueScrollRef.current.get(candidate.id);
const canScrollUp = effectiveScrollTop > 0.001; if (!trueScroll || trueScroll.expectedScrollTop !== scrollTop) {
const canScrollDown = trueScroll = { floatValue: scrollTop, expectedScrollTop: scrollTop };
effectiveScrollTop < scrollHeight - innerHeight - 0.001;
const totalDelta = Math.round(pendingDelta + delta);
if (direction === 'up' && canScrollUp) {
pendingScrollsRef.current.set(candidate.id, totalDelta);
scheduleFlush();
return true;
} }
if (direction === 'down' && canScrollDown) { const maxScroll = Math.max(0, scrollHeight - innerHeight);
pendingScrollsRef.current.set(candidate.id, totalDelta); 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(); scheduleFlush();
return true; return true;
} }