Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 89 additions & 19 deletions packages/cli/src/ui/contexts/ScrollProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -90,14 +108,19 @@ 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);
useEffect(() => {
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<{
Expand All @@ -115,20 +138,53 @@ 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);
}
}, []);

const scrollMomentumRef = useRef({
count: 0,
lastTime: 0,
lastDirection: null as 'up' | 'down' | null,
});

const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => {
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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;
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 - 0.001;
const totalDelta = Math.round(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;
}
Expand Down
Loading