Tune TUI wheel scroll acceleration

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-02 22:55:11 -04:00
co-authored by Orca
parent 087ad66aa2
commit d29b1a89db
3 changed files with 405 additions and 8 deletions
@@ -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)
})
})
@@ -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<Terminal, 'attachCustomWheelEventHandler' | 'ele
type TerminalMouseWheelMultiplierOptions = {
getTuiMouseWheelMultiplier?: () => 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<WheelEvent, 'deltaY'>): -1 | 1 {
return event.deltaY < 0 ? -1 : 1
}
function wheelInputTime(event: Pick<WheelEvent, 'timeStamp'>): 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<WheelEvent, 'deltaY' | 'timeStamp'>,
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
})
@@ -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<void>((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<HTMLElement>('.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)
})
})