From 5f3a4646bef61a3d6bc4b3e2e93c085d47bbf76e Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:49:55 +0900 Subject: [PATCH] Move non-state methods into MouseService Fixes #5756 --- src/browser/CoreBrowserTerminal.ts | 1 + src/browser/TestUtils.test.ts | 5 +- src/browser/services/MouseService.ts | 145 ++++++++++++++- src/browser/services/Services.ts | 1 + src/common/TestUtils.test.ts | 11 +- src/common/services/MouseStateService.test.ts | 174 +++--------------- src/common/services/MouseStateService.ts | 142 ++------------ src/common/services/Services.ts | 38 +--- 8 files changed, 190 insertions(+), 327 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index de2d76a36..828013e9e 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -1095,6 +1095,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._setup(); super.reset(); + this._mouseService?.reset(); this._selectionService?.reset(); this._decorationService.reset(); diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 4f38faa2e..485900e41 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -393,9 +393,8 @@ export class MockMouseService implements IMouseService { throw new Error('Not implemented'); } - public bindMouse(): void { - throw new Error('Not implemented'); - } + public bindMouse(): void { } + public reset(): void { } } export class MockRenderService implements IRenderService { diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index d21b3debf..ddb457365 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -5,7 +5,7 @@ import { addDisposableListener } from 'browser/Dom'; import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; -import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, IDisposable } from 'common/Types'; +import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from 'common/Types'; import { C0 } from 'common/data/EscapeSequences'; import { toDisposable } from 'common/Lifecycle'; import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services'; @@ -21,6 +21,9 @@ interface IMouseBindContext { export class MouseService implements IMouseService { public serviceBrand: undefined; + private _lastEvent: ICoreMouseEvent | null = null; + private _wheelPartialScroll: number = 0; + constructor( @IRenderService private readonly _renderService: IRenderService, @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService, @@ -123,7 +126,7 @@ export class MouseService implements IMouseService { if (deltaY === 0) { return false; } - const lines = this._mouseStateService.consumeWheelEvent( + const lines = this._consumeWheelEvent( ev as WheelEvent, this._renderService?.dimensions?.device?.cell?.height, this._coreBrowserService?.dpr @@ -145,7 +148,7 @@ export class MouseService implements IMouseService { return false; } - return this._mouseStateService.triggerMouseEvent({ + return this._triggerMouseEvent({ col: pos.col, row: pos.row, x: pos.x, @@ -241,7 +244,7 @@ export class MouseService implements IMouseService { return false; } - const lines = this._mouseStateService.consumeWheelEvent( + const lines = this._consumeWheelEvent( ev, this._renderService?.dimensions?.device?.cell?.height, this._coreBrowserService?.dpr @@ -261,13 +264,18 @@ export class MouseService implements IMouseService { } } + public reset(): void { + this._lastEvent = null; + this._wheelPartialScroll = 0; + } + 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._mouseStateService.explainEvents(events)); + this._logService.debug('Binding to mouse events:', this._explainEvents(events)); } element.classList.add('enable-mouse-events'); this._selectionService.disable(); @@ -317,4 +325,131 @@ export class MouseService implements IMouseService { } } + 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; + } + } diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index 2471d5718..47c46ec66 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -62,6 +62,7 @@ export interface IMouseService { serviceBrand: undefined; bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void; + reset(): void; } export interface IMouseServiceTarget { element: HTMLElement; diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index cd5798d4f..0884a4ab4 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -71,17 +71,14 @@ export class MockMouseStateService implements IMouseStateService { public areMouseEventsActive: boolean = false; public activeEncoding: string = ''; public activeProtocol: string = ''; + public isDefaultEncoding: boolean = true; + public isPixelEncoding: boolean = false; public addEncoding(name: string): void { } public addProtocol(name: string): void { } public reset(): void { } - public triggerMouseEvent(event: ICoreMouseEvent): boolean { return false; } public onProtocolChange: IEvent = new Emitter().event; - public explainEvents(events: CoreMouseEventType): { [event: string]: boolean } { - throw new Error('Method not implemented.'); - } - public consumeWheelEvent(ev: WheelEvent, cellHeight: number, dpr: number): number { - return 1; - } + public restrictMouseEvent(event: ICoreMouseEvent): boolean { return true; } + public encodeMouseEvent(event: ICoreMouseEvent): string { return ''; } public setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void { } public allowCustomWheelEvent(ev: WheelEvent): boolean { return true; } } diff --git a/src/common/services/MouseStateService.test.ts b/src/common/services/MouseStateService.test.ts index ce8c1f10d..06c619fdd 100644 --- a/src/common/services/MouseStateService.test.ts +++ b/src/common/services/MouseStateService.test.ts @@ -3,14 +3,8 @@ * @license MIT */ import { MouseStateService } from 'common/services/MouseStateService'; -import { MockCoreService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; import { assert } from 'chai'; -import { ICoreMouseEvent, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types'; - -// needed mock services -const bufferService = new MockBufferService(300, 100); -const coreService = new MockCoreService(); -const optionsService = new MockOptionsService(); +import { ICoreMouseEvent, CoreMouseEventType } from 'common/Types'; function toBytes(s: string | undefined): number[] { if (!s) { @@ -25,20 +19,20 @@ function toBytes(s: string | undefined): number[] { describe('MouseStateService', () => { it('init', () => { - const cms = new MouseStateService(bufferService, coreService, optionsService); + const cms = new MouseStateService(); assert.equal(cms.activeEncoding, 'DEFAULT'); assert.equal(cms.activeProtocol, 'NONE'); }); it('default protocols - NONE, X10, VT200, DRAG, ANY', () => { - const cms = new MouseStateService(bufferService, coreService, optionsService); + const cms = new MouseStateService(); assert.deepEqual(Object.keys((cms as any)._protocols), ['NONE', 'X10', 'VT200', 'DRAG', 'ANY']); }); it('default encodings - DEFAULT, SGR', () => { - const cms = new MouseStateService(bufferService, coreService, optionsService); + const cms = new MouseStateService(); assert.deepEqual(Object.keys((cms as any)._encodings), ['DEFAULT', 'SGR', 'SGR_PIXELS']); }); it('protocol/encoding setter, reset', () => { - const cms = new MouseStateService(bufferService, coreService, optionsService); + const cms = new MouseStateService(); cms.activeEncoding = 'SGR'; cms.activeProtocol = 'ANY'; assert.equal(cms.activeEncoding, 'SGR'); @@ -50,19 +44,19 @@ describe('MouseStateService', () => { assert.throws(() => { cms.activeProtocol = 'xyz'; }, 'unknown protocol "xyz"'); }); it('addEncoding', () => { - const cms = new MouseStateService(bufferService, coreService, optionsService); + const cms = new MouseStateService(); cms.addEncoding('XYZ', (e: ICoreMouseEvent) => ''); cms.activeEncoding = 'XYZ'; assert.equal(cms.activeEncoding, 'XYZ'); }); it('addProtocol', () => { - const cms = new MouseStateService(bufferService, coreService, optionsService); + const cms = new MouseStateService(); cms.addProtocol('XYZ', { events: CoreMouseEventType.NONE, restrict: (e: ICoreMouseEvent) => false }); cms.activeProtocol = 'XYZ'; assert.equal(cms.activeProtocol, 'XYZ'); }); it('onProtocolChange', () => { - const cms = new MouseStateService(bufferService, coreService, optionsService); + const cms = new MouseStateService(); const wantedEvents: CoreMouseEventType[] = []; cms.onProtocolChange(events => wantedEvents.push(events)); cms.activeProtocol = 'NONE'; @@ -73,140 +67,22 @@ describe('MouseStateService', () => { CoreMouseEventType.DOWN | CoreMouseEventType.UP | CoreMouseEventType.WHEEL | CoreMouseEventType.DRAG | CoreMouseEventType.MOVE ]); }); - describe('triggerMouseEvent', () => { - let cms: MouseStateService; - let reports: string[]; - beforeEach(() => { - cms = new MouseStateService(bufferService, coreService, optionsService); - reports = []; - coreService.triggerDataEvent = (data: string, userInput?: boolean) => reports.push(data); - coreService.triggerBinaryEvent = (data: string) => reports.push(data); - }); - it('NONE', () => { - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false); - }); - it('X10', () => { - cms.activeProtocol = 'X10'; - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false); - }); - it('VT200', () => { - cms.activeProtocol = 'VT200'; - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false); - }); - it('DRAG', () => { - cms.activeProtocol = 'DRAG'; - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true); - }); - it('ANY', () => { - cms.activeProtocol = 'ANY'; - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), true); - // should not report in any case - // invalid button + action combinations - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.MOVE }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.DOWN }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.UP }), false); - // invalid coords - assert.equal(cms.triggerMouseEvent({ col: -1, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false); - assert.equal(cms.triggerMouseEvent({ col: 500, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: -1, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 500, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false); - }); - describe('coords', () => { - it('DEFAULT encoding', () => { - cms.activeProtocol = 'ANY'; - for (let i = 0; i < bufferService.cols; ++i) { - assert.equal(cms.triggerMouseEvent({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - if (i > 222) { - // supress mouse reports if we are out of addressible range (max. 222) - assert.deepEqual(toBytes(reports.pop()), []); - } else { - assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]); - } - } - }); - it('SGR encoding', () => { - cms.activeProtocol = 'ANY'; - cms.activeEncoding = 'SGR'; - for (let i = 0; i < bufferService.cols; ++i) { - assert.equal(cms.triggerMouseEvent({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - assert.deepEqual(reports.pop(), `\x1b[<0;${i + 1};1M`); - } - }); - it('SGR_PIXELS encoding', () => { - cms.activeProtocol = 'ANY'; - cms.activeEncoding = 'SGR_PIXELS'; - for (let i = 0; i < 500; ++i) { - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: i, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - assert.deepEqual(reports.pop(), `\x1b[<0;${i};0M`); - } - }); - }); - it('eventCodes with modifiers (DEFAULT encoding)', () => { - // TODO: implement AUX button tests - cms.activeProtocol = 'ANY'; - cms.activeEncoding = 'DEFAULT'; - // all buttons + down + no modifer - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true); - assert.deepEqual(reports, ['\x1b[M !!', '\x1b[M!!!', '\x1b[M"!!', '\x1b[Ma!!']); - reports = []; - - // all buttons + up + no modifier - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true); - assert.deepEqual(reports, ['\x1b[M#!!', '\x1b[M#!!', '\x1b[M#!!', '\x1b[M`!!']); - reports = []; - - // all buttons + move + no modifier - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true); - assert.deepEqual(reports, ['\x1b[M@!!', '\x1b[MA!!', '\x1b[MB!!', '\x1b[MC!!']); - reports = []; - - // button none + move + modifiers - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: false, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: true }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: false }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: true }), true); - assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: true }), true); - assert.deepEqual(reports, ['\x1b[MS!!', '\x1b[MK!!', '\x1b[MG!!', '\x1b[M[!!', '\x1b[MO!!', '\x1b[M_!!']); - reports = []; - }); + it('restrictMouseEvent/encodeMouseEvent', () => { + const cms = new MouseStateService(); + const event: ICoreMouseEvent = { + col: 1, + row: 1, + x: 0, + y: 0, + button: 0, + action: 1, + ctrl: false, + alt: false, + shift: false + }; + cms.activeProtocol = 'ANY'; + cms.activeEncoding = 'DEFAULT'; + assert.equal(cms.restrictMouseEvent(event), true); + assert.deepEqual(toBytes(cms.encodeMouseEvent(event)), [0x1b, 0x5b, 0x4d, 0x21, 0x21, 0x21]); }); }); diff --git a/src/common/services/MouseStateService.ts b/src/common/services/MouseStateService.ts index 8a2629482..21420460a 100644 --- a/src/common/services/MouseStateService.ts +++ b/src/common/services/MouseStateService.ts @@ -2,7 +2,7 @@ * Copyright (c) 2019 The xterm.js authors. All rights reserved. * @license MIT */ -import { IBufferService, ICoreService, IMouseStateService, IOptionsService } from 'common/services/Services'; +import { IMouseStateService } from 'common/services/Services'; import { ICoreMouseProtocol, ICoreMouseEvent, CoreMouseEncoding, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; @@ -173,19 +173,14 @@ export class MouseStateService extends Disposable implements IMouseStateService private _encodings: { [name: string]: CoreMouseEncoding } = {}; private _activeProtocol: string = ''; private _activeEncoding: string = ''; - private _lastEvent: ICoreMouseEvent | null = null; - private _wheelPartialScroll: number = 0; private _customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined; private readonly _onProtocolChange = this._register(new Emitter()); public readonly onProtocolChange = this._onProtocolChange.event; - constructor( - @IBufferService private readonly _bufferService: IBufferService, - @ICoreService private readonly _coreService: ICoreService, - @IOptionsService private readonly _optionsService: IOptionsService - ) { + constructor() { super(); + // register default protocols and encodings for (const name of Object.keys(DEFAULT_PROTOCOLS)) this.addProtocol(name, DEFAULT_PROTOCOLS[name]); for (const name of Object.keys(DEFAULT_ENCODINGS)) this.addEncoding(name, DEFAULT_ENCODINGS[name]); @@ -231,42 +226,6 @@ export class MouseStateService extends Disposable implements IMouseStateService public reset(): void { this.activeProtocol = 'NONE'; this.activeEncoding = 'DEFAULT'; - this._lastEvent = null; - this._wheelPartialScroll = 0; - } - - /** - * Processes a wheel event, accounting for partial scrolls for trackpad, mouse scrolls. - * This prevents hyper-sensitive scrolling in alt buffer. - */ - public 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; } public setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void { @@ -277,98 +236,19 @@ export class MouseStateService extends Disposable implements IMouseStateService return this._customWheelEventHandler ? this._customWheelEventHandler(ev) !== false : true; } - 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; + public restrictMouseEvent(e: ICoreMouseEvent): boolean { + return this._protocols[this._activeProtocol].restrict(e); } - /** - * 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 bowser component should be omitted. - * - * Note: The method will change values of the given event object - * to fullfill protocol and encoding restrictions. - */ - public 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._activeEncoding === 'SGR_PIXELS') - ) { - return false; - } - - // apply protocol restrictions - if (!this._protocols[this._activeProtocol].restrict(e)) { - return false; - } - - // encode report and send - const report = this._encodings[this._activeEncoding](e); - if (report) { - // always send DEFAULT as binary data - if (this._activeEncoding === 'DEFAULT') { - this._coreService.triggerBinaryEvent(report); - } else { - this._coreService.triggerDataEvent(report, true); - } - } - - this._lastEvent = e; - - return true; + public encodeMouseEvent(e: ICoreMouseEvent): string { + return this._encodings[this._activeEncoding](e); } - public 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) - }; + public get isDefaultEncoding(): boolean { + return this._activeEncoding === 'DEFAULT'; } - 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; + public get isPixelEncoding(): boolean { + return this._activeEncoding === 'SGR_PIXELS'; } } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 5b298d720..a15dc23dc 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -43,43 +43,17 @@ export interface IMouseStateService { addProtocol(name: string, protocol: ICoreMouseProtocol): void; addEncoding(name: string, encoding: CoreMouseEncoding): void; reset(): void; - - /** - * 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 bowser component should be omitted. - * - * Note: The method will change values of the given event object - * to fullfill protocol and encoding restrictions. - */ - triggerMouseEvent(event: ICoreMouseEvent): boolean; + setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void; + allowCustomWheelEvent(ev: WheelEvent): boolean; /** * Event to announce changes in mouse tracking. */ onProtocolChange: IEvent; - - /** - * Human readable version of mouse events. - */ - explainEvents(events: CoreMouseEventType): { [event: string]: boolean }; - - /** - * Process wheel event taking partial scroll into account. - */ - consumeWheelEvent(ev: WheelEvent, cellHeight?: number, dpr?: number): number; - - /** - * Sets a custom wheel event handler that can veto wheel processing. - */ - setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void; - - /** - * Checks whether a wheel event should be processed. - */ - allowCustomWheelEvent(ev: WheelEvent): boolean; + restrictMouseEvent(event: ICoreMouseEvent): boolean; + encodeMouseEvent(event: ICoreMouseEvent): string; + readonly isDefaultEncoding: boolean; + readonly isPixelEncoding: boolean; } export const ICoreService = createDecorator('CoreService');