Files
xterm.js/src/browser/services/MouseService.ts
T

634 lines
22 KiB
TypeScript

/**
* 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<IDisposable>;
readonly mousedragListener: MutableDisposable<IDisposable>;
}
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<IDisposable>();
const mousedragListener = new MutableDisposable<IDisposable>();
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<IDisposable>();
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);
}
}
}