diff --git a/demo/client/components/window/cellInspectorWindow.ts b/demo/client/components/window/cellInspectorWindow.ts index b7a50bf6d..8bc28a974 100644 --- a/demo/client/components/window/cellInspectorWindow.ts +++ b/demo/client/components/window/cellInspectorWindow.ts @@ -85,7 +85,7 @@ export class CellInspectorWindow extends BaseWindow implements IControlWindow { terminal.element.addEventListener('mousemove', (e: MouseEvent) => { const core = (terminal as any)._core; - const coords = core._mouseService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows); + const coords = core._mouseCoordsService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows); if (!coords) { this._clearDisplay(); return; diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 8ad8409f4..6793266f6 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -36,16 +36,17 @@ import { CharSizeService } from 'browser/services/CharSizeService'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; import { CoreBrowserService } from 'browser/services/CoreBrowserService'; import { LinkProviderService } from 'browser/services/LinkProviderService'; +import { MouseCoordsService } from 'browser/services/MouseCoordsService'; import { MouseService } from 'browser/services/MouseService'; import { RenderService } from 'browser/services/RenderService'; import { SelectionService } from 'browser/services/SelectionService'; -import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; +import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; import { ThemeService } from 'browser/services/ThemeService'; import { KeyboardService } from 'browser/services/KeyboardService'; import { channels, color, rgb } from 'common/Color'; import { CoreTerminal } from 'common/CoreTerminal'; import * as Browser from 'common/Platform'; -import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types'; +import { ColorRequestType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { C0, C1ESCAPED } from 'common/data/EscapeSequences'; @@ -87,6 +88,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // Optional browser services private _charSizeService: ICharSizeService | undefined; private _coreBrowserService: ICoreBrowserService | undefined; + private _mouseCoordsService: IMouseCoordsService | undefined; private _mouseService: IMouseService | undefined; private _renderService: IRenderService | undefined; private _themeService: IThemeService | undefined; @@ -543,8 +545,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView); this._helperContainer.appendChild(this._compositionView); - this._mouseService = this._instantiationService.createInstance(MouseService); - this._instantiationService.setService(IMouseService, this._mouseService); + this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService); + this._instantiationService.setService(IMouseCoordsService, this._mouseCoordsService); const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement)); @@ -579,6 +581,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { linkifier )); this._instantiationService.setService(ISelectionService, this._selectionService); + this._mouseService = this._instantiationService.createInstance(MouseService); + this._mouseService.setCustomWheelEventHandler(this._customWheelEventHandler); + this._instantiationService.setService(IMouseService, this._mouseService); this._register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent))); this._register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire())); this._register(this._selectionService.onRequestRedraw(e => this._renderService!.handleSelectionChanged(e.start, e.end, e.columnSelectMode))); @@ -638,285 +643,17 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // Listen for mouse events and translate // them into terminal mouse protocols. - this.bindMouse(); + this._mouseService.bindMouse({ + element: this.element!, + screenElement: this.screenElement!, + document: this._document! + }, disposable => this._register(disposable), () => this.focus()); } private _createRenderer(): IRenderer { return this._instantiationService.createInstance(DomRenderer, this, this._document!, this.element!, this.screenElement!, this._viewportElement!, this._helperContainer!, this.linkifier!); } - /** - * Bind certain mouse events to the terminal. - * By default only 3 button + wheel up/down is ativated. For higher buttons - * no mouse report will be created. Typically the standard actions will be active. - * - * There are several reasons not to enable support for higher buttons/wheel: - * - Button 4 and 5 are typically used for history back and forward navigation, - * there is no straight forward way to supress/intercept those standard actions. - * - Support for higher buttons does not work in some platform/browser combinations. - * - Left/right wheel was not tested. - * - Emulators vary in mouse button support, typically only 3 buttons and - * wheel up/down work reliable. - * - * TODO: Move mouse event code into its own file. - */ - public bindMouse(): void { - const self = this; - const el = this.element!; - - // send event to CoreMouseService - function sendEvent(ev: MouseEvent | WheelEvent): boolean { - // Get mouse coordinates - const pos = self._mouseService?.getMouseReportCoords(ev, self.screenElement!); - if (!pos) { - return false; - } - - let but: CoreMouseButton; - let action: CoreMouseAction | undefined; - switch ((ev as any).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 (self._customWheelEventHandler && self._customWheelEventHandler(ev as WheelEvent) === false) { - return false; - } - const deltaY = (ev as WheelEvent).deltaY; - if (deltaY === 0) { - return false; - } - const lines = self.coreMouseService.consumeWheelEvent( - ev as WheelEvent, - self._renderService?.dimensions?.device?.cell?.height, - self._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; - } - - return self.coreMouseService.triggerMouseEvent({ - col: pos.col, - row: pos.row, - x: pos.x, - y: pos.y, - button: but, - action, - ctrl: ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey - }); - } - - /** - * Event listener state handling. - * We listen to the onProtocolChange event of CoreMouseService 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: { [key: string]: ((ev: MouseEvent | WheelEvent) => void) | null } = { - mouseup: null, - wheel: null, - mousedrag: null, - mousemove: null - }; - const eventListeners: { [key: string]: (ev: any) => void | boolean } = { - mouseup: (ev: MouseEvent) => { - sendEvent(ev); - if (!ev.buttons) { - // if no other button is held remove global handlers - this._document!.removeEventListener('mouseup', requestedEvents.mouseup!); - if (requestedEvents.mousedrag) { - this._document!.removeEventListener('mousemove', requestedEvents.mousedrag); - } - } - }, - wheel: (ev: WheelEvent) => { - sendEvent(ev); - ev.preventDefault(); - ev.stopPropagation(); - return false; - }, - mousedrag: (ev: MouseEvent) => { - // deal only with move while a button is held - if (ev.buttons) { - sendEvent(ev); - } - }, - mousemove: (ev: MouseEvent) => { - // deal only with move without any button - if (!ev.buttons) { - sendEvent(ev); - } - } - }; - this._register(this.coreMouseService.onProtocolChange(events => { - // apply global changes on events - if (events) { - if (this.optionsService.rawOptions.logLevel === 'debug') { - this._logService.debug('Binding to mouse events:', this.coreMouseService.explainEvents(events)); - } - this.element!.classList.add('enable-mouse-events'); - this._selectionService!.disable(); - } else { - this._logService.debug('Unbinding from mouse events.'); - this.element!.classList.remove('enable-mouse-events'); - this._selectionService!.enable(); - } - - // add/remove handlers from requestedEvents - - if (!(events & CoreMouseEventType.MOVE)) { - el.removeEventListener('mousemove', requestedEvents.mousemove!); - requestedEvents.mousemove = null; - } else if (!requestedEvents.mousemove) { - el.addEventListener('mousemove', eventListeners.mousemove); - requestedEvents.mousemove = eventListeners.mousemove; - } - - if (!(events & CoreMouseEventType.WHEEL)) { - el.removeEventListener('wheel', requestedEvents.wheel!); - requestedEvents.wheel = null; - } else if (!requestedEvents.wheel) { - el.addEventListener('wheel', eventListeners.wheel, { passive: false }); - requestedEvents.wheel = eventListeners.wheel; - } - - if (!(events & CoreMouseEventType.UP)) { - this._document!.removeEventListener('mouseup', requestedEvents.mouseup!); - requestedEvents.mouseup = null; - } else { - requestedEvents.mouseup ??= eventListeners.mouseup; - } - - if (!(events & CoreMouseEventType.DRAG)) { - this._document!.removeEventListener('mousemove', requestedEvents.mousedrag!); - requestedEvents.mousedrag = null; - } else { - requestedEvents.mousedrag ??= eventListeners.mousedrag; - } - })); - // force initial onProtocolChange so we dont miss early mouse requests - this.coreMouseService.activeProtocol = this.coreMouseService.activeProtocol; - - // Ensure document-level listeners are removed on dispose - this._register(toDisposable(() => { - if (requestedEvents.mouseup) { - this._document!.removeEventListener('mouseup', requestedEvents.mouseup); - } - if (requestedEvents.mousedrag) { - this._document!.removeEventListener('mousemove', requestedEvents.mousedrag); - } - })); - - /** - * "Always on" event listeners. - */ - this._register(addDisposableListener(el, 'mousedown', (ev: MouseEvent) => { - ev.preventDefault(); - this.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.coreMouseService.areMouseEventsActive || this._selectionService!.shouldForceSelection(ev)) { - return; - } - - sendEvent(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. - if (requestedEvents.mouseup) { - this._document!.addEventListener('mouseup', requestedEvents.mouseup); - } - if (requestedEvents.mousedrag) { - this._document!.addEventListener('mousemove', requestedEvents.mousedrag); - } - })); - - this._register(addDisposableListener(el, 'wheel', (ev: WheelEvent) => { - // do nothing, if app side handles wheel itself - if (requestedEvents.wheel) return; - - if (this._customWheelEventHandler && this._customWheelEventHandler(ev) === false) { - return false; - } - - if (!this.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 as WheelEvent).deltaY; - if (deltaY === 0) { - return false; - } - - const lines = self.coreMouseService.consumeWheelEvent( - ev as WheelEvent, - self._renderService?.dimensions?.device?.cell?.height, - self._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; - } - }, { passive: false })); - } - - /** * Tells the renderer to refresh terminal content between two rows (inclusive) at the next * opportunity. @@ -991,6 +728,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void { this._customWheelEventHandler = customWheelEventHandler; + this._mouseService?.setCustomWheelEventHandler(customWheelEventHandler); } public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { diff --git a/src/browser/Linkifier.ts b/src/browser/Linkifier.ts index 5e43782d4..31c91daee 100644 --- a/src/browser/Linkifier.ts +++ b/src/browser/Linkifier.ts @@ -7,7 +7,7 @@ import { IBufferCellPosition, ILink, ILinkDecorations, ILinkWithState, ILinkifie import { Disposable, dispose, toDisposable } from 'common/Lifecycle'; import { IDisposable } from 'common/Types'; import { IBufferService } from 'common/services/Services'; -import { ILinkProviderService, IMouseService, IRenderService } from './services/Services'; +import { ILinkProviderService, IMouseCoordsService, IRenderService } from './services/Services'; import { Emitter } from 'common/Event'; import { addDisposableListener } from 'browser/Dom'; @@ -30,7 +30,7 @@ export class Linkifier extends Disposable implements ILinkifier2 { constructor( private readonly _element: HTMLElement, - @IMouseService private readonly _mouseService: IMouseService, + @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService, @IRenderService private readonly _renderService: IRenderService, @IBufferService private readonly _bufferService: IBufferService, @ILinkProviderService private readonly _linkProviderService: ILinkProviderService @@ -60,7 +60,7 @@ export class Linkifier extends Disposable implements ILinkifier2 { private _handleMouseMove(event: MouseEvent): void { this._lastMouseEvent = event; - const position = this._positionFromMouseEvent(event, this._element, this._mouseService); + const position = this._positionFromMouseEvent(event, this._element); if (!position) { return; } @@ -222,7 +222,7 @@ export class Linkifier extends Disposable implements ILinkifier2 { return; } - const position = this._positionFromMouseEvent(event, this._element, this._mouseService); + const position = this._positionFromMouseEvent(event, this._element); if (!position) { return; } @@ -251,7 +251,7 @@ export class Linkifier extends Disposable implements ILinkifier2 { return; } - const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService); + const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element); if (!position) { return; @@ -312,7 +312,7 @@ export class Linkifier extends Disposable implements ILinkifier2 { this._clearCurrentLink(start, end); if (this._lastMouseEvent) { // re-eval previously active link after changes - const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService!); + const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element); if (position) { this._askForLink(position, false); } @@ -378,8 +378,8 @@ export class Linkifier extends Disposable implements ILinkifier2 { * Get the buffer position from a mouse event * @param event */ - private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement, mouseService: IMouseService): IBufferCellPosition | undefined { - const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows); + private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement): IBufferCellPosition | undefined { + const coords = this._mouseCoordsService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows); if (!coords) { return; } diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index c46ee0fb7..a682913e2 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -392,6 +392,14 @@ export class MockMouseService implements IMouseService { public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined { throw new Error('Not implemented'); } + + public setCustomWheelEventHandler(): void { + throw new Error('Not implemented'); + } + + public bindMouse(): void { + throw new Error('Not implemented'); + } } export class MockRenderService implements IRenderService { diff --git a/src/browser/services/MouseCoordsService.ts b/src/browser/services/MouseCoordsService.ts new file mode 100644 index 000000000..e2455fd2b --- /dev/null +++ b/src/browser/services/MouseCoordsService.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { getWindow } from 'browser/Dom'; +import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse'; +import { ICharSizeService, IMouseCoordsService, IRenderService } from 'browser/services/Services'; + +export class MouseCoordsService implements IMouseCoordsService { + public serviceBrand: undefined; + + constructor( + @ICharSizeService private readonly _charSizeService: ICharSizeService, + @IRenderService private readonly _renderService: IRenderService + ) { + } + + public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined { + return getCoords( + window, + event, + element, + colCount, + rowCount, + this._charSizeService.hasValidSize, + this._renderService.dimensions.css.cell.width, + this._renderService.dimensions.css.cell.height, + isSelection + ); + } + + public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined { + const coords = getCoordsRelativeToElement(getWindow(element), event, element); + if (!this._charSizeService.hasValidSize) { + return undefined; + } + coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1); + coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1); + return { + col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width), + row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height), + x: Math.floor(coords[0]), + y: Math.floor(coords[1]) + }; + } +} diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 3e382575e..f53f22a57 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -3,45 +3,324 @@ * @license MIT */ -import { getWindow } from 'browser/Dom'; -import { ICharSizeService, IRenderService, IMouseService } from './Services'; -import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse'; +import { addDisposableListener } from 'browser/Dom'; +import { IBufferService, ICoreMouseService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; +import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, IDisposable } from 'common/Types'; +import { C0 } from 'common/data/EscapeSequences'; +import { toDisposable } from 'common/Lifecycle'; +import { CustomWheelEventHandler } from 'browser/Types'; +import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services'; + +type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>; + +interface IMouseBindContext { + readonly target: IMouseServiceTarget; + readonly focus: () => void; + readonly requestedEvents: RequestedMouseEvents; +} export class MouseService implements IMouseService { public serviceBrand: undefined; + private _customWheelEventHandler: CustomWheelEventHandler | undefined; + constructor( @IRenderService private readonly _renderService: IRenderService, - @ICharSizeService private readonly _charSizeService: ICharSizeService + @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService, + @ICoreMouseService private readonly _coreMouseService: ICoreMouseService, + @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 getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined { - return getCoords( - window, - event, - element, - colCount, - rowCount, - this._charSizeService.hasValidSize, - this._renderService.dimensions.css.cell.width, - this._renderService.dimensions.css.cell.height, - isSelection - ); + 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 CoreMouseService 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 ctx: IMouseBindContext = { target, focus, requestedEvents }; + 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) + }; + register(this._coreMouseService.onProtocolChange(events => { + this._handleProtocolChange(ctx, eventListeners, events); + })); + // force initial onProtocolChange so we dont miss early mouse requests + this._coreMouseService.activeProtocol = this._coreMouseService.activeProtocol; + + // Ensure document-level listeners are removed on dispose + register(toDisposable(() => { + if (requestedEvents.mouseup) { + document.removeEventListener('mouseup', requestedEvents.mouseup); + } + if (requestedEvents.mousedrag) { + document.removeEventListener('mousemove', requestedEvents.mousedrag); + } + })); + + /** + * "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 })); } - public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined { - const coords = getCoordsRelativeToElement(getWindow(element), event, element); - if (!this._charSizeService.hasValidSize) { - return undefined; + 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; } - coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1); - coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1); - return { - col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width), - row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height), - x: Math.floor(coords[0]), - y: Math.floor(coords[1]) - }; + + 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._customWheelEventHandler && this._customWheelEventHandler(ev as WheelEvent) === false) { + return false; + } + const deltaY = (ev as WheelEvent).deltaY; + if (deltaY === 0) { + return false; + } + const lines = this._coreMouseService.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; + } + + return this._coreMouseService.triggerMouseEvent({ + col: pos.col, + row: pos.row, + x: pos.x, + y: pos.y, + button: but, + action, + ctrl: ev.ctrlKey, + alt: 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 + if (ctx.requestedEvents.mouseup) { + ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup); + } + if (ctx.requestedEvents.mousedrag) { + ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); + } + } + } + + 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._coreMouseService.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. + if (ctx.requestedEvents.mouseup) { + ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup); + } + if (ctx.requestedEvents.mousedrag) { + ctx.target.document.addEventListener('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._customWheelEventHandler && this._customWheelEventHandler(ev) === false) { + 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._coreMouseService.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 _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { + const { element, document } = 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._coreMouseService.explainEvents(events)); + } + element.classList.add('enable-mouse-events'); + this._selectionService.disable(); + } else { + this._logService.debug('Unbinding from mouse events.'); + element.classList.remove('enable-mouse-events'); + this._selectionService.enable(); + } + + // 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)) { + if (requestedEvents.mouseup) { + document.removeEventListener('mouseup', requestedEvents.mouseup); + } + requestedEvents.mouseup = null; + } else { + requestedEvents.mouseup ??= eventListeners.mouseup; + } + + if (!(events & CoreMouseEventType.DRAG)) { + if (requestedEvents.mousedrag) { + document.removeEventListener('mousemove', requestedEvents.mousedrag); + } + requestedEvents.mousedrag = null; + } else { + requestedEvents.mousedrag ??= eventListeners.mousedrag; + } + } + + public setCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler | undefined): void { + this._customWheelEventHandler = customWheelEventHandler; } } diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index a5d1d759b..e1b8510e9 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -8,7 +8,7 @@ import { getCoordsRelativeToElement } from 'browser/input/Mouse'; import { moveToCellSequence } from 'browser/input/MoveToCell'; import { SelectionModel } from 'browser/selection/SelectionModel'; import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; -import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services'; +import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services'; import { Disposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; import { IBufferLine, ICellData, IDisposable } from 'common/Types'; @@ -126,7 +126,7 @@ export class SelectionService extends Disposable implements ISelectionService { private readonly _linkifier: ILinkifier2, @IBufferService private readonly _bufferService: IBufferService, @ICoreService private readonly _coreService: ICoreService, - @IMouseService private readonly _mouseService: IMouseService, + @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService, @IOptionsService private readonly _optionsService: IOptionsService, @IRenderService private readonly _renderService: IRenderService, @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService @@ -395,7 +395,7 @@ export class SelectionService extends Disposable implements ISelectionService { * @param event The mouse event. */ private _getMouseBufferCoords(event: MouseEvent): [number, number] | undefined { - const coords = this._mouseService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true); + const coords = this._mouseCoordsService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true); if (!coords) { return undefined; } @@ -715,7 +715,7 @@ export class SelectionService extends Disposable implements ISelectionService { if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) { if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) { - const coordinates = this._mouseService.getCoords( + const coordinates = this._mouseCoordsService.getCoords( event, this._element, this._bufferService.cols, diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index 1c82b28bd..7f1c10576 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -4,7 +4,7 @@ */ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types'; -import { IColorSet, ILink, ReadonlyColorSet } from 'browser/Types'; +import { CustomWheelEventHandler, IColorSet, ILink, ReadonlyColorSet } from 'browser/Types'; import { ISelectionRedrawRequestEvent as ISelectionRequestRedrawEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; import { AllColorIndex, IDisposable, IKeyboardResult } from 'common/Types'; @@ -49,14 +49,27 @@ export interface ICoreBrowserService { readonly dpr: number; } -export const IMouseService = createDecorator('MouseService'); -export interface IMouseService { +export const IMouseCoordsService = createDecorator('MouseCoordsService'); +export interface IMouseCoordsService { serviceBrand: undefined; getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined; getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined; } +export const IMouseService = createDecorator('MouseService'); +export interface IMouseService { + serviceBrand: undefined; + + setCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler | undefined): void; + bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void; +} +export interface IMouseServiceTarget { + element: HTMLElement; + screenElement: HTMLElement; + document: Document; +} + export const IRenderService = createDecorator('RenderService'); export interface IRenderService extends IDisposable { serviceBrand: undefined;