Download packages/cli/src/ui/contexts/ScrollProvider.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 13.2 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/contexts/ScrollProvider.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/contexts/ScrollProvider.tsx
-
curl -L -o ScrollProvider.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/contexts/ScrollProvider.tsx
13.2 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import type React from 'react'; | |
| import { | |
| createContext, | |
| useCallback, | |
| useContext, | |
| useEffect, | |
| useMemo, | |
| useRef, | |
| useState, | |
| } from 'react'; | |
| import { getBoundingBox, type DOMElement } from 'ink'; | |
| import { useMouse, type MouseEvent } from '../hooks/useMouse.js'; | |
| import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; | |
| export interface ScrollState { | |
| scrollTop: number; | |
| scrollHeight: number; | |
| innerHeight: number; | |
| } | |
| export interface ScrollableEntry { | |
| id: string; | |
| ref: React.RefObject<DOMElement>; | |
| getScrollState: () => ScrollState; | |
| scrollBy: (delta: number) => void; | |
| scrollTo?: (scrollTop: number, duration?: number) => void; | |
| hasFocus: () => boolean; | |
| flashScrollbar: () => void; | |
| } | |
| interface ScrollContextType { | |
| register: (entry: ScrollableEntry) => void; | |
| unregister: (id: string) => void; | |
| } | |
| 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>, | |
| ) => { | |
| const candidates: Array<ScrollableEntry & { area: number }> = []; | |
| for (const entry of scrollables.values()) { | |
| if (!entry.ref.current) { | |
| continue; | |
| } | |
| const boundingBox = getBoundingBox(entry.ref.current); | |
| if (!boundingBox) continue; | |
| const { x, y, width, height } = boundingBox; | |
| const isInside = | |
| mouseEvent.col >= x && | |
| mouseEvent.col < x + width + 1 && // Intentionally add one to width to include scrollbar. | |
| mouseEvent.row >= y && | |
| mouseEvent.row < y + height; | |
| if (isInside) { | |
| candidates.push({ ...entry, area: width * height }); | |
| } | |
| } | |
| // Sort by smallest area first | |
| candidates.sort((a, b) => a.area - b.area); | |
| return candidates; | |
| }; | |
| export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ | |
| children, | |
| }) => { | |
| const [scrollables, setScrollables] = useState( | |
| new Map<string, ScrollableEntry>(), | |
| ); | |
| const register = useCallback((entry: ScrollableEntry) => { | |
| setScrollables((prev) => new Map(prev).set(entry.id, entry)); | |
| }, []); | |
| const unregister = useCallback((id: string) => { | |
| setScrollables((prev) => { | |
| const next = new Map(prev); | |
| next.delete(id); | |
| return next; | |
| }); | |
| trueScrollRef.current.delete(id); | |
| pendingFlushRef.current.delete(id); | |
| }, []); | |
| const scrollablesRef = useRef(scrollables); | |
| useEffect(() => { | |
| scrollablesRef.current = scrollables; | |
| }, [scrollables]); | |
| const trueScrollRef = useRef( | |
| new Map<string, { floatValue: number; expectedScrollTop: number }>(), | |
| ); | |
| const pendingFlushRef = useRef(new Set<string>()); | |
| const flushScheduledRef = useRef(false); | |
| const dragStateRef = useRef<{ | |
| active: boolean; | |
| id: string | null; | |
| offset: number; | |
| }>({ | |
| active: false, | |
| id: null, | |
| offset: 0, | |
| }); | |
| const scheduleFlush = useCallback(() => { | |
| if (!flushScheduledRef.current) { | |
| flushScheduledRef.current = true; | |
| setTimeout(() => { | |
| flushScheduledRef.current = false; | |
| const ids = Array.from(pendingFlushRef.current); | |
| pendingFlushRef.current.clear(); | |
| for (const id of ids) { | |
| const entry = scrollablesRef.current.get(id); | |
| 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); | |
| } | |
| } | |
| }, 0); | |
| } | |
| }, []); | |
| const scrollMomentumRef = useRef({ | |
| count: 0, | |
| lastTime: 0, | |
| lastDirection: null as 'up' | 'down' | null, | |
| }); | |
| const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => { | |
| let multiplier = 1; | |
| const now = Date.now(); | |
| if (!terminalCapabilityManager.isGhosttyTerminal()) { | |
| const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime; | |
| const isSameDirection = | |
| scrollMomentumRef.current.lastDirection === direction; | |
| // 50ms threshold to consider scrolls consecutive | |
| 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. | |
| multiplier = Math.min( | |
| 3, | |
| 1 + Math.max(0, scrollMomentumRef.current.count - 5) * 0.1, | |
| ); | |
| } else { | |
| scrollMomentumRef.current.count = 0; | |
| } | |
| } | |
| scrollMomentumRef.current.lastTime = now; | |
| scrollMomentumRef.current.lastDirection = direction; | |
| const delta = (direction === 'up' ? -1 : 1) * multiplier; | |
| const candidates = findScrollableCandidates( | |
| mouseEvent, | |
| scrollablesRef.current, | |
| ); | |
| for (const candidate of candidates) { | |
| const { scrollTop, scrollHeight, innerHeight } = | |
| candidate.getScrollState(); | |
| 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 = | |
| 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; | |
| } | |
| } | |
| return false; | |
| }; | |
| const handleLeftPress = (mouseEvent: MouseEvent) => { | |
| // Check for scrollbar interaction first | |
| for (const entry of scrollablesRef.current.values()) { | |
| if (!entry.ref.current || !entry.hasFocus()) { | |
| continue; | |
| } | |
| const boundingBox = getBoundingBox(entry.ref.current); | |
| if (!boundingBox) continue; | |
| const { x, y, width, height } = boundingBox; | |
| // Check if click is on the scrollbar column (x + width) | |
| // The findScrollableCandidates logic implies scrollbar is at x + width. | |
| if ( | |
| mouseEvent.col === x + width && | |
| mouseEvent.row >= y && | |
| mouseEvent.row < y + height | |
| ) { | |
| const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState(); | |
| if (scrollHeight <= innerHeight) continue; | |
| const thumbHeight = Math.max( | |
| 1, | |
| Math.floor((innerHeight / scrollHeight) * innerHeight), | |
| ); | |
| const maxScrollTop = scrollHeight - innerHeight; | |
| const maxThumbY = innerHeight - thumbHeight; | |
| if (maxThumbY <= 0) continue; | |
| const currentThumbY = Math.round( | |
| (scrollTop / maxScrollTop) * maxThumbY, | |
| ); | |
| const absoluteThumbTop = y + currentThumbY; | |
| const absoluteThumbBottom = absoluteThumbTop + thumbHeight; | |
| const isTop = mouseEvent.row === y; | |
| const isBottom = mouseEvent.row === y + height - 1; | |
| const hitTop = isTop ? absoluteThumbTop : absoluteThumbTop - 1; | |
| const hitBottom = isBottom | |
| ? absoluteThumbBottom | |
| : absoluteThumbBottom + 1; | |
| const isThumbClick = | |
| mouseEvent.row >= hitTop && mouseEvent.row < hitBottom; | |
| let offset = 0; | |
| const relativeMouseY = mouseEvent.row - y; | |
| if (isThumbClick) { | |
| offset = relativeMouseY - currentThumbY; | |
| } else { | |
| // Track click - Jump to position | |
| // Center the thumb on the mouse click | |
| const targetThumbY = Math.max( | |
| 0, | |
| Math.min(maxThumbY, relativeMouseY - Math.floor(thumbHeight / 2)), | |
| ); | |
| const newScrollTop = Math.round( | |
| (targetThumbY / maxThumbY) * maxScrollTop, | |
| ); | |
| if (entry.scrollTo) { | |
| entry.scrollTo(newScrollTop); | |
| } else { | |
| entry.scrollBy(newScrollTop - scrollTop); | |
| } | |
| offset = relativeMouseY - targetThumbY; | |
| } | |
| // Start drag (for both thumb and track clicks) | |
| dragStateRef.current = { | |
| active: true, | |
| id: entry.id, | |
| offset, | |
| }; | |
| return true; | |
| } | |
| } | |
| const candidates = findScrollableCandidates( | |
| mouseEvent, | |
| scrollablesRef.current, | |
| ); | |
| if (candidates.length > 0) { | |
| // The first candidate is the innermost one. | |
| candidates[0].flashScrollbar(); | |
| // We don't consider just flashing the scrollbar as handling the event | |
| // in a way that should prevent other handlers (like drag warning) | |
| // from checking it, although for left-press it doesn't matter much. | |
| // But returning false is safer. | |
| return false; | |
| } | |
| return false; | |
| }; | |
| const handleMove = (mouseEvent: MouseEvent) => { | |
| const state = dragStateRef.current; | |
| if (!state.active || !state.id) return false; | |
| const entry = scrollablesRef.current.get(state.id); | |
| if (!entry || !entry.ref.current) { | |
| state.active = false; | |
| return false; | |
| } | |
| const boundingBox = getBoundingBox(entry.ref.current); | |
| if (!boundingBox) return false; | |
| const { y } = boundingBox; | |
| const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState(); | |
| const thumbHeight = Math.max( | |
| 1, | |
| Math.floor((innerHeight / scrollHeight) * innerHeight), | |
| ); | |
| const maxScrollTop = scrollHeight - innerHeight; | |
| const maxThumbY = innerHeight - thumbHeight; | |
| if (maxThumbY <= 0) return false; | |
| const relativeMouseY = mouseEvent.row - y; | |
| // Calculate the target thumb position based on the mouse position and the offset. | |
| // We clamp it to the valid range [0, maxThumbY]. | |
| const targetThumbY = Math.max( | |
| 0, | |
| Math.min(maxThumbY, relativeMouseY - state.offset), | |
| ); | |
| const targetScrollTop = Math.round( | |
| (targetThumbY / maxThumbY) * maxScrollTop, | |
| ); | |
| if (entry.scrollTo) { | |
| entry.scrollTo(targetScrollTop, 0); | |
| } else { | |
| entry.scrollBy(targetScrollTop - scrollTop); | |
| } | |
| return true; | |
| }; | |
| const handleLeftRelease = () => { | |
| if (dragStateRef.current.active) { | |
| dragStateRef.current = { | |
| active: false, | |
| id: null, | |
| offset: 0, | |
| }; | |
| return true; | |
| } | |
| return false; | |
| }; | |
| useMouse( | |
| (event: MouseEvent) => { | |
| if (event.name === 'scroll-up') { | |
| return handleScroll('up', event); | |
| } else if (event.name === 'scroll-down') { | |
| return handleScroll('down', event); | |
| } else if (event.name === 'left-press') { | |
| return handleLeftPress(event); | |
| } else if (event.name === 'move') { | |
| return handleMove(event); | |
| } else if (event.name === 'left-release') { | |
| return handleLeftRelease(); | |
| } | |
| return false; | |
| }, | |
| { isActive: true }, | |
| ); | |
| const contextValue = useMemo( | |
| () => ({ register, unregister }), | |
| [register, unregister], | |
| ); | |
| return ( | |
| <ScrollContext.Provider value={contextValue}> | |
| {children} | |
| </ScrollContext.Provider> | |
| ); | |
| }; | |
| let nextId = 0; | |
| export const useScrollable = ( | |
| entry: Omit<ScrollableEntry, 'id'>, | |
| isActive: boolean, | |
| ) => { | |
| const context = useContext(ScrollContext); | |
| if (!context) { | |
| throw new Error('useScrollable must be used within a ScrollProvider'); | |
| } | |
| const [id] = useState(() => `scrollable-${nextId++}`); | |
| useEffect(() => { | |
| if (isActive) { | |
| context.register({ ...entry, id }); | |
| return () => { | |
| context.unregister(id); | |
| }; | |
| } | |
| return; | |
| }, [context, entry, id, isActive]); | |
| }; | |