Refine TUI wheel burst scrolling

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 00:18:30 -04:00
co-authored by Orca
parent d29b1a89db
commit ab602bfa27
5 changed files with 662 additions and 195 deletions
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER,
attachTerminalMouseWheelMultiplier,
createTerminalTuiMouseWheelAccelerationState,
createTerminalTuiMouseWheelDistanceState,
normalizeTerminalTuiMouseWheelMultiplier,
resolveTerminalTuiMouseWheelReportCount,
shouldMultiplyTerminalMouseWheel
@@ -90,47 +90,175 @@ 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()
it('keeps deliberate TUI wheel ticks precise at the one-report setting', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [0, 200, 400, 600].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp }, 5, state)
const reports = [0, 200, 400, 600].map(() =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([1, 1, 1, 1])
})
it('keeps the one-report setting precise even during fast TUI wheel bursts', () => {
const state = createTerminalTuiMouseWheelAccelerationState()
it('scales notched TUI wheel ticks by the configured multiplier', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [0, 50, 100, 150, 200, 250].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp }, 1, state)
const reports = [0, 50, 100, 150].map(() =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 },
5,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([1, 1, 1, 1, 1, 1])
expect(reports).toEqual([5, 5, 5, 5])
})
it('accelerates repeated same-direction TUI wheel ticks with a front-loaded curve', () => {
const state = createTerminalTuiMouseWheelAccelerationState()
it('keeps paced 1x TUI wheel ticks precise', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [0, 50, 100, 150, 200, 250].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount({ deltaY: 12, timeStamp }, 5, state)
const reports = [0, 80, 160, 240].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([1, 5, 5, 5, 5, 5])
expect(reports).toEqual([1, 1, 1, 1])
})
it('resets TUI wheel acceleration when the user changes direction', () => {
const state = createTerminalTuiMouseWheelAccelerationState()
it('adds a burst boost for very fast 1x TUI wheel scrolling', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [0, 16, 32, 48, 64].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([1, 1, 3, 3, 4])
})
it('uses a hotter compressed wheel distance curve for larger TUI wheel movements', () => {
const state = createTerminalTuiMouseWheelDistanceState()
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)
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 16, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 },
1,
state,
{ cellHeight: 16 }
),
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 16 * 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 12 },
1,
state,
{ cellHeight: 16 }
)
]
expect(reports).toEqual([1, 5, 1, 5])
expect(reports).toEqual([1, 6])
})
it('caps a single accelerated TUI wheel event before it becomes a huge jump', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 16 * 200, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 200 },
1,
state,
{ cellHeight: 16 }
)
expect(reports).toBe(6)
})
it('lets aggressive repeated TUI wheel events exceed the single-event cap', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [0, 16, 32, 48, 64].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount(
{
deltaY: 16 * 200,
deltaMode: DOM_DELTA_PIXEL,
timeStamp,
wheelDeltaY: -120 * 200
},
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([6, 6, 8, 8, 9])
})
it('does not carry burst boost into a decaying momentum tail', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [
[200, 0],
[200, 16],
[200, 32],
[80, 48],
[20, 64],
[5, 80]
].map(([rows, timeStamp]) =>
resolveTerminalTuiMouseWheelReportCount(
{
deltaY: 16 * rows,
deltaMode: DOM_DELTA_PIXEL,
timeStamp,
wheelDeltaY: -120 * rows
},
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([6, 6, 8, 6, 6, 4])
})
it('retains fractional trackpad distance until it reaches a full row', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [4, 4, 4, 4].map((deltaY) =>
resolveTerminalTuiMouseWheelReportCount({ deltaY, deltaMode: DOM_DELTA_PIXEL }, 1, state, {
cellHeight: 16
})
)
expect(reports).toEqual([0, 0, 0, 1])
})
it('resets pending fractional distance when the user changes direction', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [
resolveTerminalTuiMouseWheelReportCount({ deltaY: 4, deltaMode: DOM_DELTA_PIXEL }, 1, state, {
cellHeight: 16
}),
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: -12, deltaMode: DOM_DELTA_PIXEL },
1,
state,
{ cellHeight: 16 }
)
]
expect(reports).toEqual([0, 0])
})
it('multiplies discrete wheel events when mouse reporting is active', () => {
@@ -204,7 +332,8 @@ describe('terminal mouse wheel multiplier', () => {
attachCustomWheelEventHandler: (handler) => {
handlers.push(handler)
},
element: target
element: target,
rows: 24
},
{ getTuiMouseWheelMultiplier: () => 1 }
)
@@ -229,10 +358,9 @@ describe('terminal mouse wheel multiplier', () => {
expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false)
})
it('paces accelerated TUI wheel reports after the immediate first report', async () => {
it('drains resolved TUI wheel reports without a frame-rate cap', 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'
@@ -245,11 +373,11 @@ describe('terminal mouse wheel multiplier', () => {
attachCustomWheelEventHandler: (handler) => {
handlers.push(handler)
},
element: target
element: target,
rows: 24
},
{
getTuiMouseWheelMultiplier: () => 3,
scheduleWheelReplayFrame: (callback) => scheduledFrames.push(callback)
getTuiMouseWheelMultiplier: () => 3
}
)
const firstEvent = new TestWheelEvent('wheel', {
@@ -283,14 +411,10 @@ describe('terminal mouse wheel multiplier', () => {
expect(handlers[0]?.(firstEvent)).toBe(false)
await Promise.resolve()
expect(dispatched).toHaveLength(1)
expect(dispatched).toHaveLength(3)
expect(handlers[0]?.(secondEvent)).toBe(false)
await Promise.resolve()
expect(dispatched).toHaveLength(2)
expect(scheduledFrames).toHaveLength(1)
scheduledFrames.shift()?.()
expect(dispatched).toHaveLength(4)
expect(dispatched).toHaveLength(6)
})
})
@@ -1,42 +1,39 @@
import type { Terminal } from '@xterm/xterm'
import {
createTerminalTuiMouseWheelDistanceState,
isDiscreteTerminalTuiWheelEvent,
normalizeTerminalTuiMouseWheelMultiplier,
resolveTerminalTuiMouseWheelReportCount,
resolveTerminalWheelDirection
} from './pane-terminal-tui-wheel-reports'
import type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports'
export {
TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER,
TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX,
TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN,
createTerminalTuiMouseWheelDistanceState,
normalizeTerminalTuiMouseWheelMultiplier,
resolveTerminalTuiMouseWheelReportCount
} from './pane-terminal-tui-wheel-reports'
export type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports'
const XTERM_MOUSE_REPORTING_CLASS = 'enable-mouse-events'
const REPLAYED_WHEEL_EVENT_PROPERTY = '__orcaReplayedTerminalWheelEvent'
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
export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10
type TerminalWheelTarget = Pick<Terminal, 'attachCustomWheelEventHandler' | 'element'>
type TerminalWheelTarget = Pick<Terminal, 'attachCustomWheelEventHandler' | 'element' | 'rows'>
type TerminalMouseWheelMultiplierOptions = {
getTuiMouseWheelMultiplier?: () => number | undefined
scheduleWheelReplayFrame?: (callback: () => void) => void
}
type ReplayedWheelEvent = WheelEvent & {
[REPLAYED_WHEEL_EVENT_PROPERTY]?: boolean
}
type WheelEventWithLegacyDelta = WheelEvent & {
wheelDelta?: number
wheelDeltaY?: number
}
export type TerminalTuiMouseWheelAccelerationState = {
lastDirection: -1 | 0 | 1
lastInputAt: number | null
streak: number
}
type TerminalTuiMouseWheelReplayState = {
acceleration: TerminalTuiMouseWheelAccelerationState
distance: TerminalTuiMouseWheelDistanceState
drainScheduled: boolean
pendingDirection: -1 | 0 | 1
pendingEvent: WheelEvent | null
@@ -44,17 +41,9 @@ type TerminalTuiMouseWheelReplayState = {
pendingTarget: EventTarget | null
}
export function createTerminalTuiMouseWheelAccelerationState(): TerminalTuiMouseWheelAccelerationState {
return {
lastDirection: 0,
lastInputAt: null,
streak: 0
}
}
function createTerminalTuiMouseWheelReplayState(): TerminalTuiMouseWheelReplayState {
return {
acceleration: createTerminalTuiMouseWheelAccelerationState(),
distance: createTerminalTuiMouseWheelDistanceState(),
drainScheduled: false,
pendingDirection: 0,
pendingEvent: null,
@@ -74,45 +63,6 @@ function markReplayedWheelEvent(event: WheelEvent): void {
})
}
function legacyVerticalWheelDelta(event: WheelEvent): number | null {
const wheelEvent = event as WheelEventWithLegacyDelta
if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) {
return wheelEvent.wheelDeltaY
}
if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) {
return wheelEvent.wheelDelta
}
return null
}
function isDiscreteWheelEvent(event: WheelEvent): boolean {
if (event.deltaMode !== DOM_DELTA_PIXEL) {
return true
}
if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) {
return true
}
const legacyDelta = legacyVerticalWheelDelta(event)
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,
@@ -140,32 +90,16 @@ 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
function resolveTerminalWheelCellHeight(terminal: TerminalWheelTarget): number | undefined {
if (typeof terminal.element?.querySelector !== 'function') {
return undefined
}
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))
const screen = terminal.element?.querySelector<HTMLElement>('.xterm-screen')
const rect = screen?.getBoundingClientRect()
if (!rect || rect.height <= 0 || terminal.rows <= 0) {
return undefined
}
return rect.height / terminal.rows
}
export function shouldMultiplyTerminalMouseWheel(
@@ -177,7 +111,7 @@ export function shouldMultiplyTerminalMouseWheel(
!terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) ||
event.deltaY === 0 ||
event.shiftKey ||
!isDiscreteWheelEvent(event)
!isDiscreteTerminalTuiWheelEvent(event)
) {
return false
}
@@ -185,31 +119,7 @@ export function shouldMultiplyTerminalMouseWheel(
return true
}
export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER
}
return Math.round(
Math.min(
TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX,
Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value)
)
)
}
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 {
function drainTerminalTuiWheelReports(state: TerminalTuiMouseWheelReplayState): void {
const target = state.pendingTarget
const event = state.pendingEvent
if (!target || !event || state.pendingReports <= 0) {
@@ -217,33 +127,28 @@ function drainTerminalTuiWheelReports(
return
}
const reportsToDispatch = Math.min(reportsThisFrame, state.pendingReports)
for (let i = 0; i < reportsToDispatch; i++) {
const reportsToDispatch = state.pendingReports
for (let i = 0; i < reportsToDispatch; i += 1) {
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)
})
state.pendingReports = 0
state.drainScheduled = false
state.pendingDirection = 0
state.pendingEvent = null
state.pendingTarget = null
}
function queueTerminalTuiWheelReports(
state: TerminalTuiMouseWheelReplayState,
target: EventTarget,
event: WheelEvent,
reportCount: number,
scheduleWheelReplayFrame: (callback: () => void) => void
reportCount: number
): void {
const direction = wheelDirection(event)
if (reportCount <= 0) {
return
}
const direction = resolveTerminalWheelDirection(event)
if (state.pendingDirection !== 0 && state.pendingDirection !== direction) {
state.pendingReports = 0
}
@@ -258,10 +163,10 @@ function queueTerminalTuiWheelReports(
}
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.
// Why: dispatch after xterm returns from the original wheel handler, but do
// not frame-cap reports; fullscreen TUIs need the full wheel distance.
queueMicrotask(() => {
drainTerminalTuiWheelReports(state, scheduleWheelReplayFrame, 1)
drainTerminalTuiWheelReports(state)
})
}
@@ -282,19 +187,17 @@ export function attachTerminalMouseWheelMultiplier(
}
// Why: xterm dampens small pixel deltas before emitting mouse reports;
// line-mode replays make each notched wheel tick produce immediate reports.
// line-mode replays let fullscreen TUIs receive one report per resolved row.
const reportCount = resolveTerminalTuiMouseWheelReportCount(
event,
normalizeTerminalTuiMouseWheelMultiplier(options.getTuiMouseWheelMultiplier?.()),
replayState.acceleration
)
queueTerminalTuiWheelReports(
replayState,
target,
event,
reportCount,
options.scheduleWheelReplayFrame ?? defaultScheduleWheelReplayFrame
replayState.distance,
{
cellHeight: resolveTerminalWheelCellHeight(terminal),
rows: terminal.rows
}
)
queueTerminalTuiWheelReports(replayState, target, event, reportCount)
return false
})
@@ -0,0 +1,206 @@
const DOM_DELTA_PIXEL = 0
const DOM_DELTA_LINE = 1
const DOM_DELTA_PAGE = 2
const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50
const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100
const LEGACY_MOUSE_WHEEL_DELTA_UNIT = 120
const DEFAULT_TERMINAL_CELL_HEIGHT = 16
const TUI_WHEEL_ACCELERATED_DISTANCE_GAIN = 1.6
const TUI_WHEEL_BURST_FULL_INTERVAL_MS = 16
const TUI_WHEEL_BURST_MAX_INTERVAL_MS = 45
const TUI_WHEEL_BURST_MAX_BONUS_ROWS = 3
const TUI_WHEEL_BURST_RAMP_EVENTS = 4
const TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO = 0.85
const TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT = 6
const TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT = 9
export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1
export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1
export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10
type WheelEventWithLegacyDelta = WheelEvent & {
wheelDelta?: number
wheelDeltaY?: number
}
type TerminalTuiWheelEventInput = Pick<WheelEvent, 'deltaY'> &
Partial<Pick<WheelEvent, 'deltaMode' | 'timeStamp'>> & {
wheelDelta?: number
wheelDeltaY?: number
}
type TerminalTuiMouseWheelMetrics = {
cellHeight?: number
rows?: number
}
export type TerminalTuiMouseWheelDistanceState = {
fastStreak: number
lastDistanceRows: number | null
lastInputAt: number | null
pendingDirection: -1 | 0 | 1
pendingRows: number
}
export function createTerminalTuiMouseWheelDistanceState(): TerminalTuiMouseWheelDistanceState {
return {
fastStreak: 0,
lastDistanceRows: null,
lastInputAt: null,
pendingDirection: 0,
pendingRows: 0
}
}
export function resolveTerminalWheelDirection(event: Pick<WheelEvent, 'deltaY'>): -1 | 1 {
return event.deltaY < 0 ? -1 : 1
}
function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | null {
const wheelEvent = event as WheelEventWithLegacyDelta
if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) {
return wheelEvent.wheelDeltaY
}
if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) {
return wheelEvent.wheelDelta
}
return null
}
export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean {
if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) {
return true
}
if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) {
return true
}
const legacyDelta = legacyVerticalWheelDelta(event)
return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN
}
function wheelInputTime(event: TerminalTuiWheelEventInput): number | null {
if (typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)) {
return event.timeStamp
}
return null
}
function normalizeCellHeight(cellHeight: number | undefined): number {
if (typeof cellHeight === 'number' && Number.isFinite(cellHeight) && cellHeight > 0) {
return cellHeight
}
return DEFAULT_TERMINAL_CELL_HEIGHT
}
function resolveWheelDistanceRows(
event: TerminalTuiWheelEventInput,
metrics: TerminalTuiMouseWheelMetrics
): number {
const deltaMode = event.deltaMode ?? DOM_DELTA_PIXEL
const deltaY = Math.abs(event.deltaY)
const rowsFromDelta =
deltaMode === DOM_DELTA_LINE
? deltaY
: deltaMode === DOM_DELTA_PAGE
? deltaY * Math.max(1, metrics.rows ?? 1)
: deltaY / normalizeCellHeight(metrics.cellHeight)
const legacyDelta = legacyVerticalWheelDelta(event)
const rowsFromLegacy =
legacyDelta === null ? 0 : Math.abs(legacyDelta) / LEGACY_MOUSE_WHEEL_DELTA_UNIT
const rows = Math.max(rowsFromDelta, rowsFromLegacy)
return isDiscreteTerminalTuiWheelEvent(event) ? Math.max(1, rows) : rows
}
function compressWheelDistanceRows(rows: number): number {
if (rows <= 1) {
return rows
}
return Math.min(
TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT,
1 + Math.log2(rows) * TUI_WHEEL_ACCELERATED_DISTANCE_GAIN
)
}
function resolveBurstWheelDistanceRows(
event: TerminalTuiWheelEventInput,
state: TerminalTuiMouseWheelDistanceState,
distanceRows: number
): number {
const currentInputAt = wheelInputTime(event)
if (currentInputAt === null) {
state.fastStreak = 0
state.lastDistanceRows = null
state.lastInputAt = null
return 0
}
const elapsedMs = state.lastInputAt === null ? null : currentInputAt - state.lastInputAt
const isMomentumTail =
state.lastDistanceRows !== null &&
distanceRows < state.lastDistanceRows * TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO
state.lastDistanceRows = distanceRows
state.lastInputAt = currentInputAt
if (
isMomentumTail ||
elapsedMs === null ||
elapsedMs < 0 ||
elapsedMs > TUI_WHEEL_BURST_MAX_INTERVAL_MS
) {
state.fastStreak = 0
return 0
}
const cadence =
elapsedMs <= TUI_WHEEL_BURST_FULL_INTERVAL_MS
? 1
: (TUI_WHEEL_BURST_MAX_INTERVAL_MS - elapsedMs) /
(TUI_WHEEL_BURST_MAX_INTERVAL_MS - TUI_WHEEL_BURST_FULL_INTERVAL_MS)
state.fastStreak = Math.min(TUI_WHEEL_BURST_RAMP_EVENTS, state.fastStreak + 1)
return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS)
}
export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER
}
return Math.round(
Math.min(
TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX,
Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value)
)
)
}
export function resolveTerminalTuiMouseWheelReportCount(
event: TerminalTuiWheelEventInput,
multiplier: number,
state: TerminalTuiMouseWheelDistanceState,
metrics: TerminalTuiMouseWheelMetrics = {}
): number {
const direction = resolveTerminalWheelDirection(event)
if (state.pendingDirection !== 0 && state.pendingDirection !== direction) {
state.fastStreak = 0
state.lastDistanceRows = null
state.lastInputAt = null
state.pendingRows = 0
}
state.pendingDirection = direction
const distanceRows = resolveWheelDistanceRows(event, metrics)
const rows =
Math.min(
TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT,
compressWheelDistanceRows(distanceRows) +
resolveBurstWheelDistanceRows(event, state, distanceRows)
) * normalizeTerminalTuiMouseWheelMultiplier(multiplier)
const totalRows = state.pendingRows + rows
const reports = Math.trunc(totalRows)
state.pendingRows = totalRows - reports
return reports
}
@@ -0,0 +1,61 @@
const ESC = '\x1b'
const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g')
let offset = 0
let pending = ''
function write(data) {
process.stdout.write(data)
}
function visibleRows() {
return Math.max(3, process.stdout.rows || 24)
}
function render() {
write(`${ESC}[H`)
write(`TUI_SCROLL_READY offset=${offset}${ESC}[K`)
for (let row = 1; row < visibleRows(); row += 1) {
write(`\r\nTUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}${ESC}[K`)
}
}
function cleanup() {
write(`${ESC}[?1003l${ESC}[?1006l${ESC}[?25h${ESC}[?1049l`)
process.exit(0)
}
process.stdin.setEncoding('utf8')
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
process.stdin.resume()
write(`${ESC}[?1049h${ESC}[?1003h${ESC}[?1006h${ESC}[?25l${ESC}[2J`)
render()
process.stdin.on('data', (chunk) => {
if (chunk.includes('\x03') || chunk.includes('q')) {
cleanup()
}
pending += chunk
let match
let lastIndex = 0
MOUSE_REPORT_PATTERN.lastIndex = 0
while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) {
offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1))
lastIndex = MOUSE_REPORT_PATTERN.lastIndex
}
if (lastIndex > 0) {
pending = pending.slice(lastIndex)
render()
return
}
pending = pending.slice(-32)
})
process.on('SIGINT', cleanup)
+182 -9
View File
@@ -1,7 +1,12 @@
import type { Page } from '@stablyai/playwright-test'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { waitForActiveTerminalManager } from './helpers/terminal'
import {
execInTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
type WheelReportSample = {
reportCount: number
@@ -14,6 +19,10 @@ type TimedWheelReportSample = WheelReportSample & {
}
const PHYSICAL_MOUSE_WHEEL_DELTA = -120
const VISIBLE_TUI_FIXTURE_PATH = path.join(
process.cwd(),
'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs'
)
async function probeSmallMouseWheelReports(
page: Page,
@@ -194,6 +203,82 @@ async function probeTimedSmallMouseWheelReports(
)
}
async function readVisibleTuiOffset(page: Page): Promise<number | null> {
return page.evaluate(() => {
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) {
return null
}
for (let row = 0; row < pane.terminal.rows; row += 1) {
const text = pane.terminal.buffer.active.getLine(row)?.translateToString(true) ?? ''
const match = /TUI_SCROLL_ROW_(\d+)/.exec(text)
if (match) {
return Number(match[1])
}
}
return null
})
}
async function dispatchTuiWheel(
page: Page,
options: {
deltaY: number
wheelDeltaY?: number
}
): Promise<void> {
await page.evaluate(({ deltaY, wheelDeltaY }) => {
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 screen = pane.terminal.element.querySelector<HTMLElement>('.xterm-screen')
if (!screen) {
throw new Error('Active terminal screen unavailable')
}
const rect = screen.getBoundingClientRect()
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + Math.min(rect.height - 1, 40),
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
deltaY
})
if (wheelDeltaY !== undefined) {
Object.defineProperty(event, 'wheelDeltaY', {
configurable: true,
value: wheelDeltaY
})
Object.defineProperty(event, 'wheelDelta', {
configurable: true,
value: wheelDeltaY
})
}
pane.terminal.element.dispatchEvent(event)
}, options)
}
test.describe('terminal TUI wheel reports', () => {
test('notched mouse wheel ticks produce immediate mouse-reporting TUI scroll reports', async ({
orcaPage
@@ -215,9 +300,92 @@ test.describe('terminal TUI wheel reports', () => {
expect(samples.at(-1)?.reports.join('')).toContain('\x1b[<65;')
})
test('fast notched mouse wheel ticks accelerate while slow ticks stay precise', async ({
test('fullscreen mouse-reporting TUI scroll distance follows wheel magnitude @headful', async ({
electronApp,
orcaPage
}) => {
await electronApp.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0]
if (!win) {
throw new Error('No BrowserWindow available')
}
if (win.isMinimized()) {
win.restore()
}
win.show()
win.focus()
win.setFullScreen(true)
})
await expect
.poll(() =>
electronApp.evaluate(
({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen() ?? false
)
)
.toBe(true)
await orcaPage.waitForTimeout(1200)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.evaluate(() =>
window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 })
)
const ptyId = await waitForActivePanePtyId(orcaPage)
await execInTerminal(orcaPage, ptyId, `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)}`)
await expect
.poll(() => readVisibleTuiOffset(orcaPage), {
timeout: 10_000,
message: 'visible fullscreen TUI did not render numbered rows'
})
.toBe(0)
await dispatchTuiWheel(orcaPage, {
deltaY: 10,
wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA
})
await expect
.poll(() => readVisibleTuiOffset(orcaPage), {
timeout: 5_000,
message: 'single notched wheel tick did not visibly scroll the TUI'
})
.toBe(1)
const cellHeight = await orcaPage.evaluate(() => {
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
const screen = pane?.terminal.element?.querySelector<HTMLElement>('.xterm-screen')
if (!pane?.terminal || !screen) {
throw new Error('Active terminal screen unavailable')
}
return screen.getBoundingClientRect().height / pane.terminal.rows
})
await dispatchTuiWheel(orcaPage, {
deltaY: cellHeight * 12,
wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA * 12
})
await expect
.poll(() => readVisibleTuiOffset(orcaPage), {
timeout: 5_000,
message: 'larger wheel movement did not visibly move the TUI farther'
})
.toBe(7)
})
test('TUI scroll setting scales notched mouse wheel reports', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
@@ -232,17 +400,22 @@ test.describe('terminal TUI wheel reports', () => {
ticks: 5
})
await orcaPage.waitForTimeout(220)
const fast = await probeTimedSmallMouseWheelReports(orcaPage, {
const paced = await probeTimedSmallMouseWheelReports(orcaPage, {
drainWaitMs: 220,
intervalMs: 40,
intervalMs: 80,
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)
slow.samples.map((sample) => sample.reportDelta),
`slow per-tick SGR mouse reports: ${JSON.stringify(slow.samples)}`
).toEqual([5, 5, 5, 5, 5])
expect(
paced.samples.map((sample) => sample.reportDelta),
`paced per-tick SGR mouse reports: ${JSON.stringify(paced.samples)}`
).toEqual([5, 5, 5, 5, 5])
expect(paced.reports.length, `paced SGR mouse reports: ${JSON.stringify(paced.samples)}`).toBe(
25
)
})
})