mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(ui): added enhancements to scroll momentum (#24447)
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user