diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index 38645a0b..4a27c55e 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -13,7 +13,7 @@ import { CellData } from 'common/buffer/CellData'; import { Attributes } from 'common/buffer/Constants'; import { AttributeData } from 'common/buffer/AttributeData'; import { Params } from 'common/parser/Params'; -import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService } from 'common/TestUtils.test'; +import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService, MockCoreMouseService } from 'common/TestUtils.test'; import { IBufferService } from 'common/services/Services'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { clone } from 'common/Clone'; @@ -33,7 +33,7 @@ describe('InputHandler', () => { bufferService.buffer.x = 1; bufferService.buffer.y = 2; bufferService.buffer.ybase = 0; - const inputHandler = new InputHandler(terminal, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + const inputHandler = new InputHandler(terminal, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); // Save cursor position inputHandler.saveCursor(); assert.equal(bufferService.buffer.x, 1); @@ -52,7 +52,7 @@ describe('InputHandler', () => { describe('setCursorStyle', () => { it('should call Terminal.setOption with correct params', () => { const optionsService = new MockOptionsService(); - const inputHandler = new InputHandler(new MockInputHandlingTerminal(), new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), optionsService); + const inputHandler = new InputHandler(new MockInputHandlingTerminal(), new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService()); const collect = ' '; inputHandler.setCursorStyle(Params.fromArray([0]), collect); @@ -95,7 +95,7 @@ describe('InputHandler', () => { const terminal = new MockInputHandlingTerminal(); const collect = '?'; terminal.bracketedPasteMode = false; - const inputHandler = new InputHandler(terminal, new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + const inputHandler = new InputHandler(terminal, new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); // Set bracketed paste mode inputHandler.setMode(Params.fromArray([2004]), collect); assert.equal(terminal.bracketedPasteMode, true); @@ -114,7 +114,7 @@ describe('InputHandler', () => { it('insertChars', function(): void { const term = new Terminal(); const bufferService = new MockBufferService(80, 30); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); // insert some data in first and second line inputHandler.parse(Array(bufferService.cols - 9).join('a')); @@ -152,7 +152,7 @@ describe('InputHandler', () => { it('deleteChars', function(): void { const term = new Terminal(); const bufferService = new MockBufferService(80, 30); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); // insert some data in first and second line inputHandler.parse(Array(bufferService.cols - 9).join('a')); @@ -193,7 +193,7 @@ describe('InputHandler', () => { it('eraseInLine', function(): void { const term = new Terminal(); const bufferService = new MockBufferService(80, 30); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); // fill 6 lines to test 3 different states inputHandler.parse(Array(bufferService.cols + 1).join('a')); @@ -222,7 +222,7 @@ describe('InputHandler', () => { it('eraseInDisplay', function(): void { const term = new Terminal({cols: 80, rows: 7}); const bufferService = new MockBufferService(80, 7); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); // fill display with a's for (let i = 0; i < bufferService.rows; ++i) inputHandler.parse(Array(bufferService.cols + 1).join('a')); @@ -357,7 +357,7 @@ describe('InputHandler', () => { describe('print', () => { it('should not cause an infinite loop (regression test)', () => { const term = new Terminal(); - const inputHandler = new InputHandler(term, new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + const inputHandler = new InputHandler(term, new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); const container = new Uint32Array(10); container[0] = 0x200B; inputHandler.print(container, 0, 1); @@ -372,7 +372,7 @@ describe('InputHandler', () => { beforeEach(() => { term = new Terminal(); bufferService = new MockBufferService(80, 30); - handler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService()); + handler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); }); it('should handle DECSET/DECRST 47 (alt screen buffer)', () => { handler.parse('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST'); diff --git a/src/InputHandler.ts b/src/InputHandler.ts index cc767a16..6d486592 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -19,7 +19,7 @@ import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content import { CellData } from 'common/buffer/CellData'; import { AttributeData } from 'common/buffer/AttributeData'; import { IAttributeData, IDisposable } from 'common/Types'; -import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService } from 'common/services/Services'; +import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService, ICoreMouseService } from 'common/services/Services'; import { ISelectionService } from 'browser/services/Services'; /** @@ -134,6 +134,7 @@ export class InputHandler extends Disposable implements IInputHandler { private readonly _dirtyRowService: IDirtyRowService, private readonly _logService: ILogService, private readonly _optionsService: IOptionsService, + private readonly _coreMouseService: ICoreMouseService, private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()) { super(); @@ -1285,6 +1286,7 @@ export class InputHandler extends Disposable implements IInputHandler { // even if there is no button held down. // TODO: Why are params[0] compares nested within a switch for params[0]? + this._coreMouseService.activeProtocol = param === 9 ? 'X10' : param === 1000 ? 'VT200' : param === 1002 ? 'DRAG' : 'ANY'; this._terminal.x10Mouse = param === 9; this._terminal.vt200Mouse = param === 1000; @@ -1305,11 +1307,13 @@ export class InputHandler extends Disposable implements IInputHandler { break; case 1005: // utf8 ext mode mouse this._terminal.utfMouse = true; + this._coreMouseService.activeEncoding = 'UTF8'; // for wide terminals // simply encodes large values as utf8 characters break; case 1006: // sgr ext mode mouse this._terminal.sgrMouse = true; + this._coreMouseService.activeEncoding = 'SGR'; // for wide terminals // does not add 32 to fields // press: ^[[ this.scrollToBottom()); this._instantiationService.setService(ICoreService, this._coreService); this._coreService.onData(e => this._onData.fire(e)); + this._coreMouseService = this._instantiationService.createInstance(CoreMouseService); + this._instantiationService.setService(ICoreMouseService, this._coreMouseService); this._dirtyRowService = this._instantiationService.createInstance(DirtyRowService); this._instantiationService.setService(IDirtyRowService, this._dirtyRowService); this._logService = this._instantiationService.createInstance(LogService); @@ -309,7 +313,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._userScrolling = false; // Register input handler and refire/handle events - this._inputHandler = new InputHandler(this, this._bufferService, this._coreService, this._dirtyRowService, this._logService, this.optionsService); + this._inputHandler = new InputHandler(this, this._bufferService, this._coreService, this._dirtyRowService, this._logService, this.optionsService, this._coreMouseService); this._inputHandler.onCursorMove(() => this._onCursorMove.fire()); this._inputHandler.onLineFeed(() => this._onLineFeed.fire()); this.register(this._inputHandler); @@ -709,223 +713,85 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp } /** - * XTerm mouse events - * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking - * To better understand these - * the xterm code is very helpful: - * Relevant files: - * button.c, charproc.c, misc.c - * Relevant functions in xterm/button.c: - * BtnCode, EmitButtonCode, EditorButton, SendMousePosition + * mouse events + * FIXME: move event handler registration into browser MouseService */ public bindMouse(): void { const el = this.element; const self = this; - let pressed = 32; - // mouseup, mousedown, wheel - // left click: ^[[M 3<^[[M#3< - // wheel up: ^[[M`3> function sendButton(ev: MouseEvent | WheelEvent): void { - let button; let pos; - // get the xterm-style button - button = getButton(ev); - // get mouse coordinates pos = self._mouseService.getRawByteCoords(ev, self.screenElement, self.cols, self.rows); if (!pos) return; - sendEvent(button, pos); - + let but: ICoreMouseEvent['button']; + let action: ICoreMouseEvent['action']; + let code: number; switch ((ev).overrideType || ev.type) { - case 'mousedown': - pressed = button; - break; case 'mouseup': - // keep it at the left - // button, just in case. - pressed = 32; - break; - case 'wheel': - // nothing. don't - // interfere with - // `pressed`. - break; - } - } - - // motion example of a left click: - // ^[[M 3<^[[M@4<^[[M@5<^[[M@6<^[[M@7<^[[M#7< - function sendMove(ev: MouseEvent): void { - let button = pressed; - const pos = self._mouseService.getRawByteCoords(ev, self.screenElement, self.cols, self.rows); - if (!pos) return; - - // buttons marked as motions - // are incremented by 32 - button += 32; - - sendEvent(button, pos); - } - - // encode button and - // position to characters - function encode(data: number[], ch: number): void { - if (!self.utfMouse) { - if (ch === 255) { - data.push(0); - return; - } - if (ch > 127) ch = 127; - data.push(ch); - } else { - if (ch > 2047) { - data.push(2047); - return; - } - data.push(ch); - } - } - - // send a mouse event: - // regular/utf8: ^[[M Cb Cx Cy - // urxvt: ^[[ Cb ; Cx ; Cy M - // sgr: ^[[ Cb ; Cx ; Cy M/m - // vt300: ^[[ 24(1/3/5)~ [ Cx , Cy ] \r - // locator: CSI P e ; P b ; P r ; P c ; P p & w - function sendEvent(button: number, pos: {x: number, y: number}): void { - if (self._vt300Mouse) { - // NOTE: Unstable. - // http://www.vt100.net/docs/vt3xx-gp/chapter15.html - button &= 3; - pos.x -= 32; - pos.y -= 32; - let data = C0.ESC + '[24'; - if (button === 0) data += '1'; - else if (button === 1) data += '3'; - else if (button === 2) data += '5'; - else if (button === 3) return; - else data += '0'; - data += '~[' + pos.x + ',' + pos.y + ']\r'; - self._coreService.triggerDataEvent(data, true); - return; - } - - if (self._decLocator) { - // NOTE: Unstable. - button &= 3; - pos.x -= 32; - pos.y -= 32; - if (button === 0) button = 2; - else if (button === 1) button = 4; - else if (button === 2) button = 6; - else if (button === 3) button = 3; - self._coreService.triggerDataEvent(C0.ESC + '[' - + button - + ';' - + (button === 3 ? 4 : 0) - + ';' - + pos.y - + ';' - + pos.x - + ';' - // Not sure what page is meant to be - + (pos).page || 0 - + '&w', true); - return; - } - - if (self.urxvtMouse) { - pos.x -= 32; - pos.y -= 32; - pos.x++; - pos.y++; - self._coreService.triggerDataEvent(C0.ESC + '[' + button + ';' + pos.x + ';' + pos.y + 'M', true); - return; - } - - if (self.sgrMouse) { - pos.x -= 32; - pos.y -= 32; - self._coreService.triggerDataEvent(C0.ESC + '[<' - + (((button & 3) === 3 ? button & ~3 : button) - 32) - + ';' - + pos.x - + ';' - + pos.y - + ((button & 3) === 3 ? 'm' : 'M'), true); - return; - } - - const data: number[] = []; - - encode(data, button); - encode(data, pos.x); - encode(data, pos.y); - - self._coreService.triggerDataEvent(C0.ESC + '[M' + String.fromCharCode.apply(String, data), true); - } - - function getButton(ev: MouseEvent): number { - let button; - let shift; - let meta; - let ctrl; - let mod; - - // two low bits: - // 0 = left - // 1 = middle - // 2 = right - // 3 = release - // wheel up/down: - // 1, and 2 - with 64 added - switch ((ev).overrideType || ev.type) { - case 'mousedown': - button = ev.button !== null && ev.button !== undefined + action = 'up'; + code = ev.button !== null && ev.button !== undefined ? +ev.button : ev.which !== null && ev.which !== undefined ? ev.which - 1 : null; + but = code === 0 ? 'left' : code === 1 ? 'middle' : 'right'; break; - case 'mouseup': - button = 3; + case 'mousedown': + action = 'down'; + code = ev.button !== null && ev.button !== undefined + ? +ev.button + : ev.which !== null && ev.which !== undefined + ? ev.which - 1 + : null; + but = code === 0 ? 'left' : code === 1 ? 'middle' : 'right'; break; case 'DOMMouseScroll': - button = ev.detail < 0 - ? 64 - : 65; + but = 'wheel'; + action = ev.detail < 0 ? 'up' : 'down'; break; case 'wheel': - button = (ev).deltaY < 0 - ? 64 - : 65; + but = 'wheel'; + action = (ev).deltaY < 0 ? 'up' : 'down'; break; } + self._coreMouseService.triggerMouseEvent({ + col: pos.x - 33, // FIXME: why -33 here? + row: pos.y - 33, + button: but, + action, + ctrl: ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey + }); + return; + } - // next three bits are the modifiers: - // 4 = shift, 8 = meta, 16 = control - shift = ev.shiftKey ? 4 : 0; - meta = ev.metaKey ? 8 : 0; - ctrl = ev.ctrlKey ? 16 : 0; - mod = shift | meta | ctrl; + function sendMove(ev: MouseEvent): void { + const pos = self._mouseService.getRawByteCoords(ev, self.screenElement, self.cols, self.rows); + if (!pos) return; - // no mods - if (self.vt200Mouse) { - // ctrl only - mod &= ctrl; - } else if (!self.normalMouse) { - mod = 0; + let but: ICoreMouseEvent['button'] = 'none'; + if (ev.buttons !== undefined) { + but = ev.buttons & 1 ? 'left' : ev.buttons & 2 ? 'right' : ev.buttons & 4 ? 'middle' : 'none'; } - // increment to SP - button = (32 + (mod << 2)) + button; - - return button; + self._coreMouseService.triggerMouseEvent({ + col: pos.x - 33, + row: pos.y - 33, + button: but, + action: 'move', + ctrl: ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey + }); + return; } + this.register(addDisposableDomListener(el, 'mousedown', (ev: MouseEvent) => { // Prevent the focus on the textarea from getting lost @@ -988,10 +854,6 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp return this.cancel(ev); })); - // if (this.normalMouse) { - // on(this.document, 'mousemove', sendMove); - // } - this.register(addDisposableDomListener(el, 'wheel', (ev: WheelEvent) => { if (!this.mouseEvents) { // Convert wheel events into up/down events when the buffer does not have scrollback, this @@ -1790,6 +1652,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._setup(); this._bufferService.reset(); this._coreService.reset(); + this._coreMouseService.reset(); if (this._selectionService) { this._selectionService.reset(); } diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index ec36be2f..bbeb4de8 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -3,13 +3,13 @@ * @license MIT */ -import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IPartialTerminalOptions, IDirtyRowService } from 'common/services/Services'; +import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IPartialTerminalOptions, IDirtyRowService, ICoreMouseService } from 'common/services/Services'; import { IEvent, EventEmitter } from 'common/EventEmitter'; import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { BufferSet } from 'common/buffer/BufferSet'; -import { IDecPrivateModes } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType } from 'common/Types'; export class MockBufferService implements IBufferService { serviceBrand: any; @@ -29,6 +29,16 @@ export class MockBufferService implements IBufferService { reset(): void {} } +export class MockCoreMouseService implements ICoreMouseService { + activeEncoding: string = ''; + activeProtocol: string = ''; + addEncoding(name: string): void {} + addProtocol(name: string): void {} + reset(): void {} + triggerMouseEvent(event: ICoreMouseEvent): boolean { return false; } + onProtocolChange: IEvent = new EventEmitter().event; +} + export class MockCoreService implements ICoreService { serviceBrand: any; decPrivateModes: IDecPrivateModes = {} as any; diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index b25b50a5..598a6a9e 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -158,3 +158,64 @@ export interface IRowRange { start: number; end: number; } + +/** + * Interface for mouse events in the core. + */ +export interface ICoreMouseEvent { + /** column (zero based). */ + col: number; + /** row (zero based). */ + row: number; + /** + * Button the action occured. Due to restrictions of the tracking protocols + * it is not possible to report multiple buttons at once. + * Wheel is treated as a button. + * There are invalid combinations of buttons and actions possible + * (like move + wheel), those are silently ignored by the CoreMouseService. + */ + button: 'left' | 'middle' | 'right' | 'wheel' | 'none'; + action: 'up' | 'down' | 'move'; + /** + * Modifier states. + * Protocols will add/ignore those based on specific restrictions. + */ + ctrl?: boolean; + alt?: boolean; + shift?: boolean; +} + +/** + * CoreMouseEventType + * To be reported to the browser component which events a mouse + * protocol wants to be catched and forwarded as an ICoreMouseEvent + * to CoreMouseService. + * Known types: + * - mousedown: any mousedown event + * - mouseup: any mouseup event + * - wheel: any wheel event + * - mousedrag: any mousemove event while a button is pressed + * - mousemove: any mousemove event + */ +export type CoreMouseEventType = 'mousedown' | 'mouseup' | 'wheel' | 'mousemove' | 'mousedrag'; + +/** + * Mouse protocol interface. + * A mouse protocol can be registered and activated at the CoreMouseService. + * `events` should contain a list of needed events as a hint for the browser component + * to install/remove the appropriate event handlers. + * `restrict` applies further protocol specific restrictions like not allowed + * modifiers or filtering invalid event types. + */ +export interface ICoreMouseProtocol { + events: CoreMouseEventType[]; + restrict: (e: ICoreMouseEvent) => boolean; +} + +/** + * CoreMouseEncoding + * The tracking encoding can be registered and activated at the CoreMouseService. + * If a ICoreMouseEvent passes all procotol restrictions it will be encoded + * with the active encoding and sent out. + */ +export type CoreMouseEncoding = (event: ICoreMouseEvent) => string; diff --git a/src/common/services/CoreMouseService.test.ts b/src/common/services/CoreMouseService.test.ts new file mode 100644 index 00000000..b4131f00 --- /dev/null +++ b/src/common/services/CoreMouseService.test.ts @@ -0,0 +1,214 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { CoreMouseService } from 'common/services/CoreMouseService'; +import { MockCoreService, MockBufferService } from 'common/TestUtils.test'; +import { assert } from 'chai'; +import { ICoreMouseEvent, CoreMouseEventType } from 'common/Types'; + +// needed mock services +const bufferService = new MockBufferService(300, 100); +const coreService = new MockCoreService(); + +function toBytes(s: string | undefined): number[] { + if (!s) { + return []; + } + const res: number[] = []; + for (let i = 0; i < s.length; ++i) { + res.push(s.charCodeAt(i)); + } + return res; +} + +describe('CoreMouseService', () => { + it('init', () => { + const cms = new CoreMouseService(bufferService, coreService); + assert.equal(cms.activeEncoding, 'DEFAULT'); + assert.equal(cms.activeProtocol, 'NONE'); + }); + it('default protocols - NONE, X10, VT200, DRAG, ANY', () => { + const cms = new CoreMouseService(bufferService, coreService); + assert.deepEqual(Object.keys((cms as any)._protocols), ['NONE', 'X10', 'VT200', 'DRAG', 'ANY']); + }); + it('default encodings - DEFAULT, UTF8, SGR, URXVT', () => { + const cms = new CoreMouseService(bufferService, coreService); + assert.deepEqual(Object.keys((cms as any)._encodings), ['DEFAULT', 'UTF8', 'SGR', 'URXVT']); + }); + it('protocol/encoding setter, reset', () => { + const cms = new CoreMouseService(bufferService, coreService); + cms.activeEncoding = 'SGR'; + cms.activeProtocol = 'ANY'; + assert.equal(cms.activeEncoding, 'SGR'); + assert.equal(cms.activeProtocol, 'ANY'); + cms.reset(); + assert.equal(cms.activeEncoding, 'DEFAULT'); + assert.equal(cms.activeProtocol, 'NONE'); + assert.throws(() => { cms.activeEncoding = 'xyz'; }, 'unknown encoding "xyz"'); + assert.throws(() => { cms.activeProtocol = 'xyz'; }, 'unknown protocol "xyz"'); + }); + it('addEncoding', () => { + const cms = new CoreMouseService(bufferService, coreService); + cms.addEncoding('XYZ', (e: ICoreMouseEvent) => ''); + cms.activeEncoding = 'XYZ'; + assert.equal(cms.activeEncoding, 'XYZ'); + }); + it('addProtocol', () => { + const cms = new CoreMouseService(bufferService, coreService); + cms.addProtocol('XYZ', { events: [], restrict: (e: ICoreMouseEvent) => false }); + cms.activeProtocol = 'XYZ'; + assert.equal(cms.activeProtocol, 'XYZ'); + }); + it('onProtocolChange', () => { + const cms = new CoreMouseService(bufferService, coreService); + const wantedEvents: CoreMouseEventType[][] = []; + cms.onProtocolChange(events => wantedEvents.push(events)); + cms.activeProtocol = 'NONE'; + assert.deepEqual(wantedEvents, [[]]); + cms.activeProtocol = 'ANY'; + assert.deepEqual(wantedEvents, [[], ['mousedown', 'mouseup', 'wheel', 'mousemove']]); + }); + describe('triggerMouseEvent', () => { + let cms: CoreMouseService; + let reports: string[]; + beforeEach(() => { + cms = new CoreMouseService(bufferService, coreService); + reports = []; + coreService.triggerDataEvent = (data: string, userInput?: boolean) => reports.push(data); + }); + it('NONE', () => { + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'down' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'up' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'move' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'down' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'down' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'up' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move' }), false); + }); + it('X10', () => { + cms.activeProtocol = 'X10'; + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'up' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'move' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'up' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move' }), false); + }); + it('VT200', () => { + cms.activeProtocol = 'VT200'; + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'up' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'move' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'up' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move' }), false); + }); + it('DRAG', () => { + cms.activeProtocol = 'DRAG'; + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'up' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'move' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'up' }), true); + }); + it('ANY', () => { + cms.activeProtocol = 'ANY'; + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'up' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'move' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'down' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'up' }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move' }), true); + // should not report in any case + // invalid button + action combinations + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'move' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'down' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'up' }), false); + // invalid coords + assert.equal(cms.triggerMouseEvent({ col: -1, row: 0, button: 'left', action: 'down' }), false); + assert.equal(cms.triggerMouseEvent({ col: 500, row: 0, button: 'left', action: 'down' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: -1, button: 'left', action: 'down' }), false); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 500, button: 'left', action: '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, button: 'left', action: 'down' }), true); + // capped at 95 + if (i < 95) { + assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]); + } else { + assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, 0x7f, 0x21]); + } + } + }); + it('UTF8 encoding', () => { + cms.activeProtocol = 'ANY'; + cms.activeEncoding = 'UTF8'; + for (let i = 0; i < bufferService.cols; ++i) { + assert.equal(cms.triggerMouseEvent({ col: i, row: 0, button: 'left', action: 'down' }), true); + 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, button: 'left', action: 'down' }), true); + assert.deepEqual(reports.pop(), `\x1b[<0;${i + 1};1M`); + } + }); + it('URXVT', () => { + cms.activeProtocol = 'ANY'; + cms.activeEncoding = 'URXVT'; + for (let i = 0; i < bufferService.cols; ++i) { + assert.equal(cms.triggerMouseEvent({ col: i, row: 0, button: 'left', action: 'down' }), true); + assert.deepEqual(reports.pop(), `\x1b[32;${i + 1};1M`); + } + }); + }); + it('eventCodes with modifiers (DEFAULT encoding)', () => { + cms.activeProtocol = 'ANY'; + cms.activeEncoding = 'DEFAULT'; + // all buttons + down + no modifer + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'down', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'down', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'down', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'down', ctrl: false, alt: false, shift: false }), true); + assert.deepEqual(reports, ['\x1b[M !!', '\x1b[M!!!', '\x1b[M"!!', '\x1b[Ma!!']); + while (reports.pop()) { } + + // all buttons + up + no modifier + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'up', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'up', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'up', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'wheel', action: 'up', ctrl: false, alt: false, shift: false }), true); + assert.deepEqual(reports, ['\x1b[M#!!', '\x1b[M#!!', '\x1b[M#!!', '\x1b[M`!!']); + while (reports.pop()) { } + + // all buttons + move + no modifier + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'left', action: 'move', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'middle', action: 'move', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'right', action: 'move', ctrl: false, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move', ctrl: false, alt: false, shift: false }), true); + assert.deepEqual(reports, ['\x1b[M@!!', '\x1b[MA!!', '\x1b[MB!!', '\x1b[MC!!']); + while (reports.pop()) { } + + // button none + move + modifiers + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move', ctrl: true, alt: false, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move', ctrl: false, alt: true, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move', ctrl: false, alt: false, shift: true }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move', ctrl: true, alt: true, shift: false }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move', ctrl: false, alt: true, shift: true }), true); + assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: 'none', action: 'move', ctrl: true, alt: true, shift: true }), true); + assert.deepEqual(reports, ['\x1b[MS!!', '\x1b[MK!!', '\x1b[MG!!', '\x1b[M[!!', '\x1b[MO!!', '\x1b[M_!!']); + while (reports.pop()) { } + }); + }); +}); diff --git a/src/common/services/CoreMouseService.ts b/src/common/services/CoreMouseService.ts new file mode 100644 index 00000000..6549c002 --- /dev/null +++ b/src/common/services/CoreMouseService.ts @@ -0,0 +1,294 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { IBufferService, ICoreService, ICoreMouseService } from 'common/services/Services'; +import { EventEmitter, IEvent } from 'common/EventEmitter'; +import { ICoreMouseProtocol, ICoreMouseEvent, CoreMouseEncoding, CoreMouseEventType } from 'common/Types'; + +/** + * Supported default protocols. + */ +const DEFAULT_PROCOTOLS: {[key: string]: ICoreMouseProtocol} = { + /** + * NONE + * Events: none + * Modifiers: none + */ + NONE: { + events: [], + restrict: () => false + }, + /** + * X10 + * Events: mousedown + * Modifiers: none (TBD) + */ + X10: { + events: ['mousedown'], + restrict: (e: ICoreMouseEvent) => { + // no wheel (TBD), no move, no up + if (e.button === 'wheel' || e.action !== 'down') { + return false; + } + // no modifiers (TDB) + e.ctrl = false; + e.alt = false; + e.shift = false; + return true; + } + }, + /** + * VT200 + * Events: mousedown / mouseup / wheel + * Modifiers: CTRL (TBD) + */ + VT200: { + events: ['mousedown', 'mouseup', 'wheel'], + restrict: (e: ICoreMouseEvent) => { + // no move + if (e.action === 'move') { + return false; + } + // modifiers - only ctrl? + e.alt = false; + e.shift = false; + return true; + } + }, + /** + * DRAG + * Events: mousedown / mouseup / wheel / mousedrag + * Modifiers: CTRL | ALT | SHIFT + */ + DRAG: { + events: ['mousedown', 'mouseup', 'wheel', 'mousedrag'], + restrict: (e: ICoreMouseEvent) => { + // no move without button + if (e.action === 'move' && e.button === 'none') { + return false; + } + // modifiers unclear - let all pass for now + return true; + } + }, + /** + * ANY + * Events: all mouse related events + * Modifiers: CTRL | ALT | SHIFT + */ + ANY: { + events: ['mousedown', 'mouseup', 'wheel', 'mousemove'], + restrict: (e: ICoreMouseEvent) => true + } +}; + +/** + * Mapping of buttons and actions to event codes. (taken from xterm spec) + * More than 3 buttons are not supported. + */ +enum CODEMAP { + // buttons + left = 0, + middle = 1, + right = 2, + none = 3, + wheel = 64, + // actions + up = 0, + down = 1, + move = 32, + // modifiers + shift = 4, + alt = 8, + ctrl = 16 +} + +// helper for default encoders to generate the event code. +function eventCode(e: ICoreMouseEvent, isSGR: boolean): number { + const button = CODEMAP[e.button]; + const action = CODEMAP[e.action]; + const modifier = (e.ctrl ? CODEMAP.ctrl : 0) | (e.shift ? CODEMAP.shift : 0) | (e.alt ? CODEMAP.alt : 0); + let code = button | modifier; + if (e.button === 'wheel') { + code |= action; + } else { + if (e.action === 'move') { + code |= CODEMAP.move; + } else if (e.action === 'up' && !isSGR) { + // special case - only SGR can report button on release + // all others have to go with NONE + code |= CODEMAP.none; + } + } + return code; +} + +const S = String.fromCharCode; + +/** + * Supported default encodings. + */ +const DEFAULT_ENCODINGS: {[key: string]: CoreMouseEncoding} = { + /** + * DEFAULT - CSI M Pb Px Py + * Single byte encoding for coords and event code. + * Can encode values up to 223. The Encoding of higher + * values is not UTF-8 compatible (and currently limited + * to 95 in xterm.js). + */ + DEFAULT: (e: ICoreMouseEvent) => { + let params = [eventCode(e, false) + 32, e.col + 32, e.row + 32]; + // FIXME: we are currently limited to ASCII range + params = params.map(v => (v > 127) ? 127 : v); + // FIXED: params = params.map(v => (v > 255) ? 0 : value); + return `\x1b[M${S(params[0])}${S(params[1])}${S(params[2])}`; + }, + /** + * UTF8 - CSI M Pb Px Py + * Same as DEFAULT, but with optional 2-byte UTF8 + * encoding for values > 223 (can encode up to 2015). + */ + UTF8: (e: ICoreMouseEvent) => { + let params = [eventCode(e, false) + 32, e.col + 32, e.row + 32]; + // limit to 2-byte UTF8 + params = params.map(v => (v > 2047) ? 0 : v); + return `\x1b[M${S(params[0])}${S(params[1])}${S(params[2])}`; + }, + /** + * SGR - CSI < Pb ; Px ; Py M|m + * No encoding limitation. + * Can report button on release and works with a well formed sequence. + */ + SGR: (e: ICoreMouseEvent) => { + const final = (e.action === 'up' && e.button !== 'wheel') ? 'm' : 'M'; + return `\x1b[<${eventCode(e, true)};${e.col};${e.row}${final}`; + }, + /** + * URXVT - CSI Pb ; Px ; Py M + * Same button encoding as default, decimal encoding for coords. + * Ambiguity with other sequences, should not be used. + */ + URXVT: (e: ICoreMouseEvent) => { + return `\x1b[${eventCode(e, false) + 32};${e.col};${e.row}M`; + } +}; + +/** + * CoreMouseService + * + * Provides mouse tracking reports with different protocols and encodings. + * - protocols: NONE (default), X10, VT200, DRAG, ANY + * - encodings: DEFAULT, SGR, UTF8, URXVT + * + * Custom protocols/encodings can be added by `addProtocol` / `addEncoding`. + * To activate a protocol/encoding, set `activeProtocol` / `activeEncoding`. + * Switching a protocol will send a notification event `onProtocolChange` + * with a list of needed events to track. + * + * The service handles the mouse tracking state and decides whether to send + * a tracking report to the backend based on protocol and encoding limitations. + * To send a mouse event call `triggerMouseEvent`. + */ +export class CoreMouseService implements ICoreMouseService { + private _protocols: {[name: string]: ICoreMouseProtocol} = {}; + private _encodings: {[name: string]: CoreMouseEncoding} = {}; + private _activeProtocol: string = ''; + private _activeEncoding: string = ''; + private _onProtocolChange = new EventEmitter(); + + constructor( + @IBufferService private readonly _bufferService: IBufferService, + @ICoreService private readonly _coreService: ICoreService + ) { + // register default protocols and encodings + Object.keys(DEFAULT_PROCOTOLS).forEach(name => this.addProtocol(name, DEFAULT_PROCOTOLS[name])); + Object.keys(DEFAULT_ENCODINGS).forEach(name => this.addEncoding(name, DEFAULT_ENCODINGS[name])); + // call reset to set defaults + this.reset(); + } + + public addProtocol(name: string, protocol: ICoreMouseProtocol): void { + this._protocols[name] = protocol; + } + + public addEncoding(name: string, encoding: CoreMouseEncoding): void { + this._encodings[name] = encoding; + } + + public get activeProtocol(): string { + return this._activeProtocol; + } + + public set activeProtocol(name: string) { + if (!this._protocols[name]) { + throw new Error(`unknown protocol "${name}"`); + } + this._activeProtocol = name; + this._onProtocolChange.fire(this._protocols[name].events); + } + + public get activeEncoding(): string { + return this._activeEncoding; + } + + public set activeEncoding(name: string) { + if (!this._encodings[name]) { + throw new Error(`unknown encoding "${name}"`); + } + this._activeEncoding = name; + } + + public reset(): void { + this.activeProtocol = 'NONE'; + this.activeEncoding = 'DEFAULT'; + } + + /** + * Event to announce changes in mouse tracking. + */ + public get onProtocolChange(): IEvent { + return this._onProtocolChange.event; + } + + /** + * 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(event: ICoreMouseEvent): boolean { + // range check for col/row + if (event.col < 0 || event.col >= this._bufferService.cols + || event.row < 0 || event.row >= this._bufferService.rows) { + return false; + } + + // filter nonsense combinations of button + action + if (event.button === 'wheel' && event.action === 'move') { + return false; + } + if (event.button === 'none' && event.action !== 'move') { + return false; + } + + // report 1-based coords + event.col++; + event.row++; + + // apply protocol restrictions + if (!this._protocols[this._activeProtocol].restrict(event)) { + return false; + } + + // encode report and send + const report = this._encodings[this._activeEncoding](event); + this._coreService.triggerDataEvent(report, true); + + return true; + } +} diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 1af55e9a..8c3a7eb8 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -5,7 +5,7 @@ import { IEvent } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IDecPrivateModes } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; export const IBufferService = createDecorator('BufferService'); @@ -23,6 +23,32 @@ export interface IBufferService { reset(): void; } +export const ICoreMouseService = createDecorator('CoreMouseService'); +export interface ICoreMouseService { + activeProtocol: string; + activeEncoding: string; + 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; + + /** + * Event to announce changes in mouse tracking. + */ + onProtocolChange: IEvent; +} + export const ICoreService = createDecorator('CoreService'); export interface ICoreService { serviceBrand: any;