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

View File

@@ -41,6 +41,24 @@ interface ScrollContextType {
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 = (
mouseEvent: MouseEvent,
scrollables: Map<string, ScrollableEntry>,
@@ -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<string, number>());
const trueScrollRef = useRef(
new Map<string, { floatValue: number; expectedScrollTop: number }>(),
);
const pendingFlushRef = useRef(new Set<string>());
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;
}