/** * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ import { addDisposableListener } from '../Dom'; import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from '../../common/services/Services'; import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from '../../common/Types'; import { C0 } from '../../common/data/EscapeSequences'; import { DisposableStore, MutableDisposable } from '../../common/Lifecycle'; import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services'; import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch'; type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>; export const enum MouseEventCssClasses { ENABLE_MOUSE_EVENTS = 'enable-mouse-events' } interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; readonly requestedEvents: RequestedMouseEvents; readonly mouseupListener: MutableDisposable; readonly mousedragListener: MutableDisposable; } export class MouseService implements IMouseService { public serviceBrand: undefined; private _lastEvent: ICoreMouseEvent | null = null; private _wheelPartialScroll: number = 0; private _touchScrollAccumulator: number = 0; private _altMouseCursor: AltMouseCursorController | undefined; constructor( @IRenderService private readonly _renderService: IRenderService, @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService, @IMouseStateService private readonly _mouseStateService: IMouseStateService, @ICoreService private readonly _coreService: ICoreService, @IBufferService private readonly _bufferService: IBufferService, @IOptionsService private readonly _optionsService: IOptionsService, @ISelectionService private readonly _selectionService: ISelectionService, @ILogService private readonly _logService: ILogService, @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService ) { } public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void { const { element, document } = target; /** * Event listener state handling. * We listen to the onProtocolChange event of MouseStateService and put * requested listeners in `requestedEvents`. With this the listeners * have all bits to do the event listener juggling. * Note: 'mousedown' currently is "always on" and not managed * by onProtocolChange. */ const requestedEvents: RequestedMouseEvents = { mouseup: null, wheel: null, mousedrag: null, mousemove: null }; const mouseupListener = new MutableDisposable(); const mousedragListener = new MutableDisposable(); register(mouseupListener); register(mousedragListener); const ctx: IMouseBindContext = { target, focus, requestedEvents, mouseupListener, mousedragListener }; const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = { mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent), wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent), mousedrag: (ev: Event) => this._handleMouseDrag(ctx, ev as MouseEvent), mousemove: (ev: Event) => this._handleMouseMove(ctx, ev as MouseEvent) }; this._altMouseCursor = new AltMouseCursorController( element, document, () => this._mouseStateService.areMouseEventsActive && !!this._optionsService.rawOptions.mouseEventsRequireAlt ); register(this._altMouseCursor); register(this._mouseStateService.onProtocolChange(events => { this._handleProtocolChange(ctx, eventListeners, events); })); register(this._optionsService.onSpecificOptionChange('mouseEventsRequireAlt', () => { this._syncMouseModeState(element); this._altMouseCursor?.sync(); })); // force initial onProtocolChange so we dont miss early mouse requests this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol; /** * "Always on" event listeners. */ register(addDisposableListener(element, 'mousedown', (ev: MouseEvent) => this._handleMouseDown(ctx, ev))); register(addDisposableListener(element, 'wheel', (ev: WheelEvent) => this._handlePassiveWheel(ctx, ev), { passive: false })); register(Gesture.addTarget(target.screenElement)); register(addDisposableListener(target.screenElement, GestureEventType.START, () => this._handleTouchStart())); register(addDisposableListener(target.screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleTouchChange(ctx, e))); } private _sendEvent(ctx: IMouseBindContext, ev: MouseEvent | WheelEvent): boolean { // Get mouse coordinates const pos = this._mouseCoordsService.getMouseReportCoords(ev as MouseEvent, ctx.target.screenElement); if (!pos) { return false; } let but: CoreMouseButton; let action: CoreMouseAction | undefined; switch ((ev as MouseEvent & { overrideType?: string }).overrideType || ev.type) { case 'mousemove': action = CoreMouseAction.MOVE; if (ev.buttons === undefined) { // buttons is not supported on macOS, try to get a value from button instead but = CoreMouseButton.NONE; if (ev.button !== undefined) { but = ev.button < 3 ? ev.button : CoreMouseButton.NONE; } } else { // according to MDN buttons only reports up to button 5 (AUX2) but = ev.buttons & 1 ? CoreMouseButton.LEFT : ev.buttons & 4 ? CoreMouseButton.MIDDLE : ev.buttons & 2 ? CoreMouseButton.RIGHT : CoreMouseButton.NONE; // fallback to NONE } break; case 'mouseup': action = CoreMouseAction.UP; but = ev.button < 3 ? ev.button : CoreMouseButton.NONE; break; case 'mousedown': action = CoreMouseAction.DOWN; but = ev.button < 3 ? ev.button : CoreMouseButton.NONE; break; case 'wheel': if (!this._mouseStateService.allowCustomWheelEvent(ev as WheelEvent)) { return false; } const deltaY = (ev as WheelEvent).deltaY; if (deltaY === 0) { return false; } const lines = this._consumeWheelEvent( ev as WheelEvent, this._renderService?.dimensions?.device?.cell?.height, this._coreBrowserService?.dpr ); if (lines === 0) { return false; } action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN; but = CoreMouseButton.WHEEL; break; default: // dont handle other event types by accident return false; } // exit if we cannot determine valid button/action values // do nothing for higher buttons than wheel if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) { return false; } if (but !== CoreMouseButton.WHEEL && this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive && !ev.altKey) { return false; } // Alt is only used locally to gate mouse passthrough; do not forward it to the // application (e.g. tmux ignores alt-modified mouse reports). const stripAltFromReport = but !== CoreMouseButton.WHEEL && this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive; return this._triggerMouseEvent({ col: pos.col, row: pos.row, x: pos.x, y: pos.y, button: but, action, ctrl: ev.ctrlKey, alt: stripAltFromReport ? false : ev.altKey, shift: ev.shiftKey }); } private _handleMouseUp(ctx: IMouseBindContext, ev: MouseEvent): void { this._sendEvent(ctx, ev); if (!ev.buttons) { // if no other button is held remove global handlers ctx.mouseupListener.clear(); ctx.mousedragListener.clear(); } } private _handleWheel(ctx: IMouseBindContext, ev: WheelEvent): false { this._sendEvent(ctx, ev); ev.preventDefault(); ev.stopPropagation(); return false; } private _handleMouseDrag(ctx: IMouseBindContext, ev: MouseEvent): void { // deal only with move while a button is held if (ev.buttons) { this._sendEvent(ctx, ev); } } private _handleMouseMove(ctx: IMouseBindContext, ev: MouseEvent): void { // deal only with move without any button if (!ev.buttons) { this._sendEvent(ctx, ev); } } private _handleMouseDown(ctx: IMouseBindContext, ev: MouseEvent): void { ev.preventDefault(); ctx.focus(); // Don't send the mouse button to the pty if mouse events are disabled or // if the selection manager is having selection forced (ie. a modifier is // held). if (!this._mouseStateService.areMouseEventsActive || this._selectionService.shouldForceSelection(ev)) { return; } this._sendEvent(ctx, ev); // Register additional global handlers which should keep reporting outside // of the terminal element. // Note: Other emulators also do this for 'mousedown' while a button // is held, we currently limit 'mousedown' to the terminal only. // Use the element's current document in case it moved to another window after open. const { element, document: targetDocument } = ctx.target; const listenerDocument = element.ownerDocument ?? targetDocument; if (ctx.requestedEvents.mouseup) { ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { ctx.mousedragListener.value = addDisposableListener(listenerDocument, 'mousemove', ctx.requestedEvents.mousedrag); } } private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void { // do nothing, if app side handles wheel itself if (ctx.requestedEvents.wheel) { return; } if (!this._mouseStateService.allowCustomWheelEvent(ev)) { return false; } if (!this._bufferService.buffer.hasScrollback) { // Convert wheel events into up/down events when the buffer does not have scrollback, this // enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse // events are not enabled. // This used implementation used get the actual lines/partial lines scrolled from the // viewport but since moving to the new viewport implementation has been simplified to // simply send a single up or down sequence. // Do nothing if there's no vertical scroll const deltaY = ev.deltaY; if (deltaY === 0) { return false; } const lines = this._consumeWheelEvent( ev, this._renderService?.dimensions?.device?.cell?.height, this._coreBrowserService?.dpr ); if (lines === 0) { ev.preventDefault(); ev.stopPropagation(); return false; } // Construct and send sequences const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B'); this._coreService.triggerDataEvent(sequence, true); ev.preventDefault(); ev.stopPropagation(); return false; } } private _handleTouchStart(): void { this._touchScrollAccumulator = 0; } private _handleTouchChange(ctx: IMouseBindContext, e: IGestureEvent): void { e.preventDefault(); e.stopPropagation(); // When mouse protocol has wheel events active, send as mouse wheel events. if (ctx.requestedEvents.wheel) { this._handleTouchScrollAsWheel(ctx, e); return; } // When in alt buffer (no scrollback), send up/down key sequences. if (!this._bufferService.buffer.hasScrollback) { this._handleTouchScrollAsKeys(e); return; } // Normal scrollback: delegate to viewport scrolling when available. ctx.target.handleTouchScroll?.(e.translationY); } private _handleTouchScrollAsKeys(e: IGestureEvent): void { const cellHeight = this._renderService?.dimensions.css.cell.height; if (!cellHeight) { return; } this._touchScrollAccumulator -= e.translationY; const lines = Math.trunc(this._touchScrollAccumulator / cellHeight); if (lines === 0) { return; } this._touchScrollAccumulator -= lines * cellHeight; const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (lines < 0 ? 'A' : 'B'); for (let i = 0; i < Math.abs(lines); i++) { this._coreService.triggerDataEvent(sequence, true); } } private _handleTouchScrollAsWheel(ctx: IMouseBindContext, e: IGestureEvent): void { const cellHeight = this._renderService?.dimensions.css.cell.height; if (!cellHeight) { return; } this._touchScrollAccumulator -= e.translationY; const lines = Math.trunc(this._touchScrollAccumulator / cellHeight); if (lines === 0) { return; } this._touchScrollAccumulator -= lines * cellHeight; const pos = this._mouseCoordsService.getMouseReportCoords(e, ctx.target.screenElement); if (!pos) { return; } for (let i = 0; i < Math.abs(lines); i++) { this._triggerMouseEvent({ col: pos.col, row: pos.row, x: pos.x, y: pos.y, button: CoreMouseButton.WHEEL, action: lines < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }); } } public reset(): void { this._lastEvent = null; this._wheelPartialScroll = 0; this._touchScrollAccumulator = 0; } private _syncMouseModeState(element: HTMLElement): void { if (this._mouseStateService.areMouseEventsActive) { if (this._optionsService.rawOptions.mouseEventsRequireAlt) { this._altMouseCursor?.resetClass(); this._selectionService.enable(); } else { element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); this._selectionService.disable(); } } else { element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); this._selectionService.enable(); } } private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { const { element } = ctx.target; const { requestedEvents } = ctx; // apply global changes on events if (events) { if (this._optionsService.rawOptions.logLevel === 'debug') { this._logService.debug('Binding to mouse events:', this._explainEvents(events)); } } else { this._logService.debug('Unbinding from mouse events.'); } this._syncMouseModeState(element); this._altMouseCursor?.sync(); // add/remove handlers from requestedEvents if (!(events & CoreMouseEventType.MOVE)) { if (requestedEvents.mousemove) { element.removeEventListener('mousemove', requestedEvents.mousemove); } requestedEvents.mousemove = null; } else if (!requestedEvents.mousemove) { element.addEventListener('mousemove', eventListeners.mousemove); requestedEvents.mousemove = eventListeners.mousemove; } if (!(events & CoreMouseEventType.WHEEL)) { if (requestedEvents.wheel) { element.removeEventListener('wheel', requestedEvents.wheel); } requestedEvents.wheel = null; } else if (!requestedEvents.wheel) { element.addEventListener('wheel', eventListeners.wheel, { passive: false }); requestedEvents.wheel = eventListeners.wheel; } if (!(events & CoreMouseEventType.UP)) { ctx.mouseupListener.clear(); requestedEvents.mouseup = null; } else { requestedEvents.mouseup ??= eventListeners.mouseup; } if (!(events & CoreMouseEventType.DRAG)) { ctx.mousedragListener.clear(); requestedEvents.mousedrag = null; } else { requestedEvents.mousedrag ??= eventListeners.mousedrag; } } private _applyScrollModifier(amount: number, ev: WheelEvent): number { // Multiply the scroll speed when the modifier key is pressed if (ev.altKey || ev.ctrlKey || ev.shiftKey) { return amount * this._optionsService.rawOptions.fastScrollSensitivity * this._optionsService.rawOptions.scrollSensitivity; } return amount * this._optionsService.rawOptions.scrollSensitivity; } /** * Processes a wheel event, accounting for partial scrolls for trackpad, mouse scrolls. * This prevents hyper-sensitive scrolling in alt buffer. */ private _consumeWheelEvent(ev: WheelEvent, cellHeight?: number, dpr?: number): number { // Do nothing if it's not a vertical scroll event if (ev.deltaY === 0 || ev.shiftKey) { return 0; } if (cellHeight === undefined || dpr === undefined) { return 0; } const targetWheelEventPixels = cellHeight / dpr; let amount = this._applyScrollModifier(ev.deltaY, ev); if (ev.deltaMode === WheelEvent.DOM_DELTA_PIXEL) { amount /= (targetWheelEventPixels + 0.0); // Prevent integer division const isLikelyTrackpad = Math.abs(ev.deltaY) < 50; if (isLikelyTrackpad) { amount *= 0.3; } this._wheelPartialScroll += amount; amount = Math.floor(Math.abs(this._wheelPartialScroll)) * (this._wheelPartialScroll > 0 ? 1 : -1); this._wheelPartialScroll %= 1; } else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) { amount *= this._bufferService.rows; } return amount; } /** * Triggers a mouse event to be sent. * * Returns true if the event passed all protocol restrictions and a report * was sent, otherwise false. The return value may be used to decide whether * the default event action in the browser component should be omitted. * * Note: The method will change values of the given event object * to fulfill protocol and encoding restrictions. */ private _triggerMouseEvent(e: ICoreMouseEvent): boolean { // range check for col/row if (e.col < 0 || e.col >= this._bufferService.cols || e.row < 0 || e.row >= this._bufferService.rows) { return false; } // filter nonsense combinations of button + action if (e.button === CoreMouseButton.WHEEL && e.action === CoreMouseAction.MOVE) { return false; } if (e.button === CoreMouseButton.NONE && e.action !== CoreMouseAction.MOVE) { return false; } if (e.button !== CoreMouseButton.WHEEL && (e.action === CoreMouseAction.LEFT || e.action === CoreMouseAction.RIGHT)) { return false; } // report 1-based coords e.col++; e.row++; // debounce move events at grid or pixel level if (e.action === CoreMouseAction.MOVE && this._lastEvent && this._equalEvents(this._lastEvent, e, this._mouseStateService.isPixelEncoding) ) { return false; } // apply protocol restrictions if (!this._mouseStateService.restrictMouseEvent(e)) { return false; } // encode report and send const report = this._mouseStateService.encodeMouseEvent(e); if (report) { if (this._mouseStateService.isDefaultEncoding) { this._coreService.triggerBinaryEvent(report); } else { this._coreService.triggerDataEvent(report, true); } } this._lastEvent = e; return true; } private _explainEvents(events: CoreMouseEventType): { [event: string]: boolean } { return { down: !!(events & CoreMouseEventType.DOWN), up: !!(events & CoreMouseEventType.UP), drag: !!(events & CoreMouseEventType.DRAG), move: !!(events & CoreMouseEventType.MOVE), wheel: !!(events & CoreMouseEventType.WHEEL) }; } private _equalEvents(e1: ICoreMouseEvent, e2: ICoreMouseEvent, pixels: boolean): boolean { if (pixels) { if (e1.x !== e2.x) return false; if (e1.y !== e2.y) return false; } else { if (e1.col !== e2.col) return false; if (e1.row !== e2.row) return false; } if (e1.button !== e2.button) return false; if (e1.action !== e2.action) return false; if (e1.ctrl !== e2.ctrl) return false; if (e1.alt !== e2.alt) return false; if (e1.shift !== e2.shift) return false; return true; } } /** * Toggles MouseEventCssClasses.ENABLE_MOUSE_EVENTS on the terminal element while alt is held when * `mouseEventsRequireAlt` is active. DOM listeners are only registered while active. */ export class AltMouseCursorController implements IDisposable { private readonly _listeners = new MutableDisposable(); constructor( private readonly _element: HTMLElement, private readonly _document: Document, private readonly _isActive: () => boolean ) { } public dispose(): void { this._listeners.dispose(); } public sync(): void { this._listeners.clear(); if (!this._isActive()) { return; } const store = new DisposableStore(); const syncFromModifier = (ev: KeyboardEvent | MouseEvent): void => this.syncFromModifier(ev); store.add(addDisposableListener(this._document, 'keydown', syncFromModifier)); store.add(addDisposableListener(this._document, 'keyup', syncFromModifier)); store.add(addDisposableListener(this._element, 'mousemove', syncFromModifier)); const targetWindow = this._element.ownerDocument?.defaultView; if (targetWindow) { store.add(addDisposableListener(targetWindow, 'blur', () => { if (this._isActive()) { this.resetClass(); } })); } this._listeners.value = store; } public resetClass(): void { this._updateClass(false); } public syncFromModifier(ev: KeyboardEvent | MouseEvent): void { if (!this._isActive()) { return; } this._updateClass(ev.getModifierState('Alt')); } private _updateClass(altHeld: boolean): void { if (altHeld) { this._element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); } else { this._element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); } } }