mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
fix(ui): add accelerated scrolling on alternate buffer mode (#23940)
Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
@@ -14,6 +14,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import type { MouseEvent } from '../hooks/useMouse.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
|
||||
vi.mock('../utils/terminalCapabilityManager.js', () => ({
|
||||
terminalCapabilityManager: {
|
||||
isGhosttyTerminal: vi.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useMouse hook
|
||||
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
|
||||
@@ -78,6 +85,7 @@ describe('ScrollProvider', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -520,4 +528,157 @@ describe('ScrollProvider', () => {
|
||||
|
||||
expect(scrollBy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Scroll Acceleration', () => {
|
||||
it('accelerates scroll for non-Ghostty terminals during rapid scrolling', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 1000,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
|
||||
await render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
const mouseEvent: MouseEvent = {
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
};
|
||||
|
||||
// Perform 60 rapid scrolls (within 50ms of each other)
|
||||
for (let i = 0; i < 60; i++) {
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback(mouseEvent);
|
||||
}
|
||||
// Advance time by 10ms for each scroll
|
||||
vi.advanceTimersByTime(10);
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// We sum all calls to scrollBy as they might have been flushed individually due to advanceTimersByTime
|
||||
const totalDelta = scrollBy.mock.calls.reduce(
|
||||
(sum, call) => sum + call[0],
|
||||
0,
|
||||
);
|
||||
expect(totalDelta).toBeGreaterThan(60);
|
||||
expect(totalDelta).toBe(150);
|
||||
});
|
||||
|
||||
it('does not accelerate for Ghostty terminals even during rapid scrolling', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 1000,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
await render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
const mouseEvent: MouseEvent = {
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
};
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback(mouseEvent);
|
||||
}
|
||||
vi.advanceTimersByTime(10);
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// No acceleration means 60 scrolls = delta 60
|
||||
const totalDelta = scrollBy.mock.calls.reduce(
|
||||
(sum, call) => sum + call[0],
|
||||
0,
|
||||
);
|
||||
expect(totalDelta).toBe(60);
|
||||
});
|
||||
|
||||
it('resets acceleration count if scrolling is slow', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 1000,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
|
||||
await render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
const mouseEvent: MouseEvent = {
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
button: 'none',
|
||||
};
|
||||
|
||||
// Perform scrolls with 100ms gap (greater than 50ms threshold)
|
||||
for (let i = 0; i < 60; i++) {
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback(mouseEvent);
|
||||
}
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// No acceleration because gaps were too large
|
||||
const totalDelta = scrollBy.mock.calls.reduce(
|
||||
(sum, call) => sum + call[0],
|
||||
0,
|
||||
);
|
||||
expect(totalDelta).toBe(60);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} 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;
|
||||
@@ -125,8 +126,33 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollMomentumRef = useRef({
|
||||
count: 0,
|
||||
lastTime: 0,
|
||||
});
|
||||
|
||||
const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => {
|
||||
const delta = direction === 'up' ? -1 : 1;
|
||||
let multiplier = 1;
|
||||
const now = Date.now();
|
||||
|
||||
if (!terminalCapabilityManager.isGhosttyTerminal()) {
|
||||
const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime;
|
||||
// 50ms threshold to consider scrolls consecutive
|
||||
if (timeSinceLastScroll < 50) {
|
||||
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;
|
||||
|
||||
const delta = (direction === 'up' ? -1 : 1) * multiplier;
|
||||
const candidates = findScrollableCandidates(
|
||||
mouseEvent,
|
||||
scrollablesRef.current,
|
||||
@@ -142,15 +168,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
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, pendingDelta + delta);
|
||||
pendingScrollsRef.current.set(candidate.id, totalDelta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (direction === 'down' && canScrollDown) {
|
||||
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
|
||||
pendingScrollsRef.current.set(candidate.id, totalDelta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user