From ab602bfa273bd9ffd07ed0bef6c10bb9e826a27a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 3 Jul 2026 00:18:30 -0400 Subject: [PATCH] Refine TUI wheel burst scrolling Co-authored-by: Orca --- .../pane-terminal-mouse-wheel.test.ts | 192 +++++++++++++--- .../pane-manager/pane-terminal-mouse-wheel.ts | 207 +++++------------- .../pane-terminal-tui-wheel-reports.ts | 206 +++++++++++++++++ .../fixtures/visible-tui-scroll-fixture.cjs | 61 ++++++ tests/e2e/terminal-tui-wheel-reports.spec.ts | 191 +++++++++++++++- 5 files changed, 662 insertions(+), 195 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts create mode 100644 tests/e2e/fixtures/visible-tui-scroll-fixture.cjs 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 30c4da90f70..3a15574255f 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,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) }) }) 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 fd6a34ceec4..ab192e393f7 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 @@ -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 +type TerminalWheelTarget = Pick 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): -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, @@ -140,32 +90,16 @@ 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 +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('.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 }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts new file mode 100644 index 00000000000..1b92edb390f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts @@ -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 & + Partial> & { + 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): -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 +} diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs new file mode 100644 index 00000000000..207aac49cfc --- /dev/null +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -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) diff --git a/tests/e2e/terminal-tui-wheel-reports.spec.ts b/tests/e2e/terminal-tui-wheel-reports.spec.ts index 6289823aa0a..a2718e2d0ab 100644 --- a/tests/e2e/terminal-tui-wheel-reports.spec.ts +++ b/tests/e2e/terminal-tui-wheel-reports.spec.ts @@ -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 { + 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 { + 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('.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('.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 + ) }) })