Handle TUI trackpad wheel reversal

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 00:50:52 -04:00
co-authored by Orca
parent ab602bfa27
commit fa310b5658
3 changed files with 91 additions and 7 deletions
@@ -243,6 +243,21 @@ describe('terminal mouse wheel multiplier', () => {
expect(reports).toEqual([0, 0, 0, 1])
})
it('does not burst-boost rapid trackpad-like pixel deltas', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [0, 16, 32, 48].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 4, deltaMode: DOM_DELTA_PIXEL, timeStamp },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([0, 0, 0, 1])
})
it('resets pending fractional distance when the user changes direction', () => {
const state = createTerminalTuiMouseWheelDistanceState()
@@ -269,7 +284,7 @@ describe('terminal mouse wheel multiplier', () => {
expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false)
})
it('leaves trackpad-like pixel scrolling one-to-one', () => {
it('handles trackpad-like TUI pixel scrolling while mouse reporting is active', () => {
expect(
shouldMultiplyTerminalMouseWheel(
wheelEvent({
@@ -278,7 +293,7 @@ describe('terminal mouse wheel multiplier', () => {
}),
terminalElement()
)
).toBe(false)
).toBe(true)
})
it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => {
@@ -358,6 +373,58 @@ describe('terminal mouse wheel multiplier', () => {
expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false)
})
it('replays trackpad-like TUI pixel scrolling with responsive direction reversal', async () => {
vi.stubGlobal('WheelEvent', TestWheelEvent)
const handlers: ((event: WheelEvent) => boolean)[] = []
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,
rows: 24
},
{ getTuiMouseWheelMultiplier: () => 1 }
)
const events = [
[4, 0],
[4, 16],
[4, 32],
[4, 48],
[-4, 64],
[-4, 80],
[-4, 96],
[-4, 112]
].map(([deltaY, timeStamp]) => {
const event = new TestWheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaMode: DOM_DELTA_PIXEL,
deltaY
}) as WheelEvent
Object.defineProperty(event, 'timeStamp', {
configurable: true,
value: timeStamp
})
return event
})
for (const event of events) {
expect(handlers[0]?.(event)).toBe(false)
await Promise.resolve()
}
expect(dispatched.map((entry) => entry.deltaY)).toEqual([1, -1])
})
it('drains resolved TUI wheel reports without a frame-rate cap', async () => {
vi.stubGlobal('WheelEvent', TestWheelEvent)
const handlers: ((event: WheelEvent) => boolean)[] = []
@@ -1,7 +1,6 @@
import type { Terminal } from '@xterm/xterm'
import {
createTerminalTuiMouseWheelDistanceState,
isDiscreteTerminalTuiWheelEvent,
normalizeTerminalTuiMouseWheelMultiplier,
resolveTerminalTuiMouseWheelReportCount,
resolveTerminalWheelDirection
@@ -110,8 +109,7 @@ export function shouldMultiplyTerminalMouseWheel(
isReplayedWheelEvent(event) ||
!terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) ||
event.deltaY === 0 ||
event.shiftKey ||
!isDiscreteTerminalTuiWheelEvent(event)
event.shiftKey
) {
return false
}
@@ -67,6 +67,11 @@ function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | n
return null
}
function hasDiscreteLegacyWheelDelta(event: TerminalTuiWheelEventInput): boolean {
const legacyDelta = legacyVerticalWheelDelta(event)
return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN
}
export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean {
if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) {
return true
@@ -76,8 +81,15 @@ export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInpu
return true
}
const legacyDelta = legacyVerticalWheelDelta(event)
return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN
return hasDiscreteLegacyWheelDelta(event)
}
function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean {
if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) {
return true
}
return hasDiscreteLegacyWheelDelta(event)
}
function wheelInputTime(event: TerminalTuiWheelEventInput): number | null {
@@ -130,6 +142,13 @@ function resolveBurstWheelDistanceRows(
state: TerminalTuiMouseWheelDistanceState,
distanceRows: number
): number {
if (!canBurstBoostWheelEvent(event)) {
state.fastStreak = 0
state.lastDistanceRows = null
state.lastInputAt = null
return 0
}
const currentInputAt = wheelInputTime(event)
if (currentInputAt === null) {
state.fastStreak = 0