From d29b1a89dbb03cd8cd18b879d8a0136c1a7e31a7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 2 Jul 2026 22:54:36 -0400 Subject: [PATCH] Tune TUI wheel scroll acceleration Co-authored-by: Orca --- .../pane-terminal-mouse-wheel.test.ts | 110 ++++++++++++ .../pane-manager/pane-terminal-mouse-wheel.ts | 169 +++++++++++++++++- tests/e2e/terminal-tui-wheel-reports.spec.ts | 134 ++++++++++++++ 3 files changed, 405 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 0d515ce8ec1..30c4da90f70 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, attachTerminalMouseWheelMultiplier, + createTerminalTuiMouseWheelAccelerationState, normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount, shouldMultiplyTerminalMouseWheel } from './pane-terminal-mouse-wheel' @@ -88,6 +90,49 @@ describe('terminal mouse wheel multiplier', () => { expect(normalizeTerminalTuiMouseWheelMultiplier(20)).toBe(10) }) + it('keeps deliberate TUI wheel ticks precise even with a fast max', () => { + const state = createTerminalTuiMouseWheelAccelerationState() + + const reports = [0, 200, 400, 600].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp }, 5, state) + ) + + expect(reports).toEqual([1, 1, 1, 1]) + }) + + it('keeps the one-report setting precise even during fast TUI wheel bursts', () => { + const state = createTerminalTuiMouseWheelAccelerationState() + + const reports = [0, 50, 100, 150, 200, 250].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp }, 1, state) + ) + + expect(reports).toEqual([1, 1, 1, 1, 1, 1]) + }) + + it('accelerates repeated same-direction TUI wheel ticks with a front-loaded curve', () => { + const state = createTerminalTuiMouseWheelAccelerationState() + + const reports = [0, 50, 100, 150, 200, 250].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp }, 5, state) + ) + + expect(reports).toEqual([1, 5, 5, 5, 5, 5]) + }) + + it('resets TUI wheel acceleration when the user changes direction', () => { + const state = createTerminalTuiMouseWheelAccelerationState() + + const reports = [ + resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp: 0 }, 5, state), + resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp: 50 }, 5, state), + resolveTerminalTuiMouseWheelReportCount({ deltaY: -12, timeStamp: 100 }, 5, state), + resolveTerminalTuiMouseWheelReportCount({ deltaY: -12, timeStamp: 150 }, 5, state) + ] + + expect(reports).toEqual([1, 5, 1, 5]) + }) + it('multiplies discrete wheel events when mouse reporting is active', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement())).toBe(true) }) @@ -183,4 +228,69 @@ describe('terminal mouse wheel multiplier', () => { expect(dispatched.map((entry) => entry.deltaY)).toEqual([1]) expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) }) + + it('paces accelerated TUI wheel reports after the immediate first report', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const scheduledFrames: (() => void)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target + }, + { + getTuiMouseWheelMultiplier: () => 3, + scheduleWheelReplayFrame: (callback) => scheduledFrames.push(callback) + } + ) + const firstEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(firstEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(firstEvent, 'timeStamp', { + configurable: true, + value: 0 + }) + const secondEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(secondEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(secondEvent, 'timeStamp', { + configurable: true, + value: 50 + }) + + expect(handlers[0]?.(firstEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(1) + + expect(handlers[0]?.(secondEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(2) + expect(scheduledFrames).toHaveLength(1) + + scheduledFrames.shift()?.() + expect(dispatched).toHaveLength(4) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index b8654f7c3ce..fd6a34ceec4 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -6,6 +6,8 @@ const DOM_DELTA_PIXEL = 0 const DOM_DELTA_LINE = 1 const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50 const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100 +const TUI_WHEEL_ACCELERATION_WINDOW_MS = 140 +const TUI_WHEEL_REPORTS_PER_FRAME = 4 export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1 export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 @@ -15,6 +17,7 @@ type TerminalWheelTarget = Pick number | undefined + scheduleWheelReplayFrame?: (callback: () => void) => void } type ReplayedWheelEvent = WheelEvent & { @@ -26,6 +29,40 @@ type WheelEventWithLegacyDelta = WheelEvent & { wheelDeltaY?: number } +export type TerminalTuiMouseWheelAccelerationState = { + lastDirection: -1 | 0 | 1 + lastInputAt: number | null + streak: number +} + +type TerminalTuiMouseWheelReplayState = { + acceleration: TerminalTuiMouseWheelAccelerationState + drainScheduled: boolean + pendingDirection: -1 | 0 | 1 + pendingEvent: WheelEvent | null + pendingReports: number + pendingTarget: EventTarget | null +} + +export function createTerminalTuiMouseWheelAccelerationState(): TerminalTuiMouseWheelAccelerationState { + return { + lastDirection: 0, + lastInputAt: null, + streak: 0 + } +} + +function createTerminalTuiMouseWheelReplayState(): TerminalTuiMouseWheelReplayState { + return { + acceleration: createTerminalTuiMouseWheelAccelerationState(), + drainScheduled: false, + pendingDirection: 0, + pendingEvent: null, + pendingReports: 0, + pendingTarget: null + } +} + function isReplayedWheelEvent(event: WheelEvent): boolean { return (event as ReplayedWheelEvent)[REPLAYED_WHEEL_EVENT_PROPERTY] === true } @@ -61,6 +98,21 @@ function isDiscreteWheelEvent(event: WheelEvent): boolean { return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN } +function wheelDirection(event: Pick): -1 | 1 { + return event.deltaY < 0 ? -1 : 1 +} + +function wheelInputTime(event: Pick): number { + if (typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)) { + return event.timeStamp + } + return performance.now() +} + +function easeOutCubic(value: number): number { + return 1 - Math.pow(1 - value, 3) +} + function cloneWheelReportEvent(event: WheelEvent): WheelEvent { const clone = new WheelEvent(event.type, { bubbles: event.bubbles, @@ -88,6 +140,34 @@ function cloneWheelReportEvent(event: WheelEvent): WheelEvent { return clone } +export function resolveTerminalTuiMouseWheelReportCount( + event: Pick, + maxReports: number, + state: TerminalTuiMouseWheelAccelerationState +): number { + const normalizedMaxReports = normalizeTerminalTuiMouseWheelMultiplier(maxReports) + const direction = wheelDirection(event) + const currentInputAt = wheelInputTime(event) + const elapsedMs = state.lastInputAt === null ? null : currentInputAt - state.lastInputAt + const isAccelerating = + state.lastDirection === direction && + elapsedMs !== null && + elapsedMs >= 0 && + elapsedMs <= TUI_WHEEL_ACCELERATION_WINDOW_MS + + state.streak = isAccelerating ? state.streak + 1 : 0 + state.lastDirection = direction + state.lastInputAt = currentInputAt + + if (!isAccelerating || normalizedMaxReports <= 1 || elapsedMs === null) { + return 1 + } + + const cadence = 1 - elapsedMs / TUI_WHEEL_ACCELERATION_WINDOW_MS + const acceleratedReports = 1 + Math.round((normalizedMaxReports - 1) * easeOutCubic(cadence)) + return Math.min(normalizedMaxReports, Math.max(1, acceleratedReports)) +} + export function shouldMultiplyTerminalMouseWheel( event: WheelEvent, terminalElement: HTMLElement | null | undefined @@ -117,10 +197,79 @@ export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefin ) } +function defaultScheduleWheelReplayFrame(callback: () => void): void { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => callback()) + return + } + setTimeout(callback, 0) +} + +function drainTerminalTuiWheelReports( + state: TerminalTuiMouseWheelReplayState, + scheduleWheelReplayFrame: (callback: () => void) => void, + reportsThisFrame: number +): void { + const target = state.pendingTarget + const event = state.pendingEvent + if (!target || !event || state.pendingReports <= 0) { + state.drainScheduled = false + return + } + + const reportsToDispatch = Math.min(reportsThisFrame, state.pendingReports) + for (let i = 0; i < reportsToDispatch; i++) { + target.dispatchEvent(cloneWheelReportEvent(event)) + } + state.pendingReports -= reportsToDispatch + + if (state.pendingReports <= 0) { + state.drainScheduled = false + state.pendingDirection = 0 + state.pendingEvent = null + state.pendingTarget = null + return + } + + scheduleWheelReplayFrame(() => { + drainTerminalTuiWheelReports(state, scheduleWheelReplayFrame, TUI_WHEEL_REPORTS_PER_FRAME) + }) +} + +function queueTerminalTuiWheelReports( + state: TerminalTuiMouseWheelReplayState, + target: EventTarget, + event: WheelEvent, + reportCount: number, + scheduleWheelReplayFrame: (callback: () => void) => void +): void { + const direction = wheelDirection(event) + if (state.pendingDirection !== 0 && state.pendingDirection !== direction) { + state.pendingReports = 0 + } + + state.pendingDirection = direction + state.pendingEvent = event + state.pendingTarget = target + state.pendingReports += reportCount + + if (state.drainScheduled) { + return + } + + state.drainScheduled = true + // Why: the first report should land immediately, while larger accelerated + // batches are paced across frames so fast scrolling does not feel bursty. + queueMicrotask(() => { + drainTerminalTuiWheelReports(state, scheduleWheelReplayFrame, 1) + }) +} + export function attachTerminalMouseWheelMultiplier( terminal: TerminalWheelTarget, options: TerminalMouseWheelMultiplierOptions = {} ): void { + const replayState = createTerminalTuiMouseWheelReplayState() terminal.attachCustomWheelEventHandler((event) => { if (!shouldMultiplyTerminalMouseWheel(event, terminal.element)) { return true @@ -134,14 +283,18 @@ export function attachTerminalMouseWheelMultiplier( // Why: xterm dampens small pixel deltas before emitting mouse reports; // line-mode replays make each notched wheel tick produce immediate reports. - queueMicrotask(() => { - const multiplier = normalizeTerminalTuiMouseWheelMultiplier( - options.getTuiMouseWheelMultiplier?.() - ) - for (let i = 0; i < multiplier; i++) { - target.dispatchEvent(cloneWheelReportEvent(event)) - } - }) + const reportCount = resolveTerminalTuiMouseWheelReportCount( + event, + normalizeTerminalTuiMouseWheelMultiplier(options.getTuiMouseWheelMultiplier?.()), + replayState.acceleration + ) + queueTerminalTuiWheelReports( + replayState, + target, + event, + reportCount, + options.scheduleWheelReplayFrame ?? defaultScheduleWheelReplayFrame + ) return false }) diff --git a/tests/e2e/terminal-tui-wheel-reports.spec.ts b/tests/e2e/terminal-tui-wheel-reports.spec.ts index 0e7ae648422..6289823aa0a 100644 --- a/tests/e2e/terminal-tui-wheel-reports.spec.ts +++ b/tests/e2e/terminal-tui-wheel-reports.spec.ts @@ -9,6 +9,10 @@ type WheelReportSample = { reports: string[] } +type TimedWheelReportSample = WheelReportSample & { + elapsedMs: number +} + const PHYSICAL_MOUSE_WHEEL_DELTA = -120 async function probeSmallMouseWheelReports( @@ -91,6 +95,105 @@ async function probeSmallMouseWheelReports( ) } +async function probeTimedSmallMouseWheelReports( + page: Page, + options: { + drainWaitMs: number + intervalMs: number + ticks: number + } +): Promise<{ + reports: string[] + samples: TimedWheelReportSample[] +}> { + return page.evaluate( + async ({ drainWaitMs, intervalMs, physicalMouseWheelDelta, tickCount }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const reports: string[] = [] + const disposable = pane.terminal.onData((data) => reports.push(data)) + try { + await new Promise((resolve) => pane.terminal.write('\x1b[?1003h\x1b[?1006h', resolve)) + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) + if (!pane.terminal.element.classList.contains('enable-mouse-events')) { + throw new Error('Mouse reporting mode did not activate') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + + const rect = screen.getBoundingClientRect() + const cellHeight = + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + const scrollSensitivity = Number(pane.terminal.options.scrollSensitivity ?? 1) + // Why: this is a notched mouse wheel event that Chromium can surface as a + // small pixel delta; xterm's <50px damping accumulates it for four ticks. + const deltaY = (cellHeight * 0.28) / (scrollSensitivity * 0.3) + const samples: TimedWheelReportSample[] = [] + const startedAt = performance.now() + + for (let i = 0; i < tickCount; i += 1) { + const before = reports.length + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, cellHeight * 4), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: physicalMouseWheelDelta + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: physicalMouseWheelDelta + }) + pane.terminal.element.dispatchEvent(event) + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + samples.push({ + elapsedMs: performance.now() - startedAt, + reportCount: reports.length, + reportDelta: reports.length - before, + reports: [...reports] + }) + } + + await new Promise((resolve) => setTimeout(resolve, drainWaitMs)) + + return { + reports: [...reports], + samples + } + } finally { + disposable.dispose() + } + }, + { + drainWaitMs: options.drainWaitMs, + intervalMs: options.intervalMs, + physicalMouseWheelDelta: PHYSICAL_MOUSE_WHEEL_DELTA, + tickCount: options.ticks + } + ) +} + test.describe('terminal TUI wheel reports', () => { test('notched mouse wheel ticks produce immediate mouse-reporting TUI scroll reports', async ({ orcaPage @@ -111,4 +214,35 @@ test.describe('terminal TUI wheel reports', () => { ).toEqual([1, 1, 1, 1]) expect(samples.at(-1)?.reports.join('')).toContain('\x1b[<65;') }) + + test('fast notched mouse wheel ticks accelerate while slow ticks stay precise', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 5 }) + ) + + const slow = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 120, + intervalMs: 220, + ticks: 5 + }) + await orcaPage.waitForTimeout(220) + const fast = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 220, + intervalMs: 40, + ticks: 5 + }) + + expect(slow.reports.length, `slow SGR mouse reports: ${JSON.stringify(slow.samples)}`).toBe(5) + expect( + fast.samples.map((sample) => sample.reportDelta), + `fast per-tick SGR mouse reports: ${JSON.stringify(fast.samples)}` + ).toEqual([1, 5, 5, 5, 5]) + expect(fast.reports.length, `fast SGR mouse reports: ${JSON.stringify(fast.samples)}`).toBe(21) + }) })