mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Tune TUI wheel scroll acceleration
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user