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 5fa784b9f6b..0e96ca19847 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 @@ -258,6 +258,36 @@ describe('terminal mouse wheel multiplier', () => { expect(reports).toEqual([0, 0, 0, 1]) }) + it('suppresses a decaying trackpad-like momentum tail after active scrolling', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, 28, 20, 12, 6, 3].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 2, 2, 0, 0, 0, 0]) + }) + + it('suppresses alternating trackpad-like momentum after direction changes', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, -16, -20, -24, -18, -10, 6, -4].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 2, 1, 1, 2, 0, 0, 0, 0]) + }) + it('resets pending fractional distance when the user changes direction', () => { const state = createTerminalTuiMouseWheelDistanceState() 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 index 68304474e78..905bb6b81ec 100644 --- 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 @@ -11,6 +11,10 @@ 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_TRACKPAD_MOMENTUM_TAIL_DECAY_RATIO = 0.85 +const TUI_TRACKPAD_TAIL_IDLE_MS = 120 +const TUI_TRACKPAD_TAIL_REENGAGE_DISTANCE_ROWS = 1 +const TUI_TRACKPAD_TAIL_REENGAGE_RATIO = 1.5 const TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT = 6 const TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT = 9 @@ -40,6 +44,10 @@ export type TerminalTuiMouseWheelDistanceState = { lastInputAt: number | null pendingDirection: -1 | 0 | 1 pendingRows: number + trackpadLastDirection: -1 | 0 | 1 + trackpadLastDistanceRows: number | null + trackpadLastInputAt: number | null + trackpadTailActive: boolean } export function createTerminalTuiMouseWheelDistanceState(): TerminalTuiMouseWheelDistanceState { @@ -48,7 +56,11 @@ export function createTerminalTuiMouseWheelDistanceState(): TerminalTuiMouseWhee lastDistanceRows: null, lastInputAt: null, pendingDirection: 0, - pendingRows: 0 + pendingRows: 0, + trackpadLastDirection: 0, + trackpadLastDistanceRows: null, + trackpadLastInputAt: null, + trackpadTailActive: false } } @@ -92,6 +104,13 @@ function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean { return hasDiscreteLegacyWheelDelta(event) } +function isTrackpadLikePixelWheelEvent(event: TerminalTuiWheelEventInput): boolean { + return ( + (event.deltaMode ?? DOM_DELTA_PIXEL) === DOM_DELTA_PIXEL && + !isDiscreteTerminalTuiWheelEvent(event) + ) +} + function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { if (typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)) { return event.timeStamp @@ -99,6 +118,13 @@ function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { return null } +function resetTrackpadMomentumState(state: TerminalTuiMouseWheelDistanceState): void { + state.trackpadLastDirection = 0 + state.trackpadLastDistanceRows = null + state.trackpadLastInputAt = null + state.trackpadTailActive = false +} + function normalizeCellHeight(cellHeight: number | undefined): number { if (typeof cellHeight === 'number' && Number.isFinite(cellHeight) && cellHeight > 0) { return cellHeight @@ -184,6 +210,60 @@ function resolveBurstWheelDistanceRows( return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS) } +function shouldSuppressTrackpadMomentumTail( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): boolean { + if (!isTrackpadLikePixelWheelEvent(event)) { + resetTrackpadMomentumState(state) + return false + } + + const currentInputAt = wheelInputTime(event) + const elapsedMs = + currentInputAt === null || state.trackpadLastInputAt === null + ? null + : currentInputAt - state.trackpadLastInputAt + if (elapsedMs !== null && elapsedMs > TUI_TRACKPAD_TAIL_IDLE_MS) { + resetTrackpadMomentumState(state) + } + + const direction = resolveTerminalWheelDirection(event) + const lastDistanceRows = state.trackpadLastDistanceRows + const isDecayingSameDirection = + state.trackpadLastDirection === direction && + lastDistanceRows !== null && + distanceRows < lastDistanceRows * TUI_TRACKPAD_MOMENTUM_TAIL_DECAY_RATIO + const isStrongNewGesture = + lastDistanceRows !== null && + distanceRows >= TUI_TRACKPAD_TAIL_REENGAGE_DISTANCE_ROWS && + distanceRows > lastDistanceRows * TUI_TRACKPAD_TAIL_REENGAGE_RATIO + + if (state.trackpadTailActive && !isStrongNewGesture) { + state.trackpadLastDirection = direction + state.trackpadLastDistanceRows = distanceRows + state.trackpadLastInputAt = currentInputAt + state.pendingRows = 0 + return true + } + + if (isDecayingSameDirection) { + state.trackpadTailActive = true + state.trackpadLastDirection = direction + state.trackpadLastDistanceRows = distanceRows + state.trackpadLastInputAt = currentInputAt + state.pendingRows = 0 + return true + } + + state.trackpadTailActive = false + state.trackpadLastDirection = direction + state.trackpadLastDistanceRows = distanceRows + state.trackpadLastInputAt = currentInputAt + return false +} + export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER @@ -212,6 +292,10 @@ export function resolveTerminalTuiMouseWheelReportCount( state.pendingDirection = direction const distanceRows = resolveWheelDistanceRows(event, metrics) + if (shouldSuppressTrackpadMomentumTail(event, state, distanceRows)) { + return 0 + } + const rows = Math.min( TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT,