diff --git a/src/Clipboard.ts b/src/Clipboard.ts index 1ee232ee..f4b415de 100644 --- a/src/Clipboard.ts +++ b/src/Clipboard.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ISelectionManager } from 'browser/selection/Types'; +import { ISelectionService } from 'browser/services/Services'; /** * Prepares text to be pasted into the terminal by normalizing the line endings @@ -28,8 +28,8 @@ export function bracketTextForPaste(text: string, bracketedPasteMode: boolean): * Binds copy functionality to the given terminal. * @param ev The original copy event to be handled */ -export function copyHandler(ev: ClipboardEvent, selectionManager: ISelectionManager): void { - ev.clipboardData.setData('text/plain', selectionManager.selectionText); +export function copyHandler(ev: ClipboardEvent, selectionService: ISelectionService): void { + ev.clipboardData.setData('text/plain', selectionService.selectionText); // Prevent or the original text will be copied. ev.preventDefault(); } @@ -95,17 +95,17 @@ export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextA * Bind to right-click event and allow right-click copy and paste. * @param ev The original right click event to be handled. * @param textarea The terminal's textarea. - * @param selectionManager The terminal's selection manager. + * @param selectionService The terminal's selection manager. * @param shouldSelectWord If true and there is no selection the current word will be selected */ -export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, screenElement: HTMLElement, selectionManager: ISelectionManager, shouldSelectWord: boolean): void { +export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, screenElement: HTMLElement, selectionService: ISelectionService, shouldSelectWord: boolean): void { moveTextAreaUnderMouseCursor(ev, textarea, screenElement); - if (shouldSelectWord && !selectionManager.isClickInSelection(ev)) { - selectionManager.selectWordAtCursor(ev); + if (shouldSelectWord && !selectionService.isClickInSelection(ev)) { + selectionService.selectWordAtCursor(ev); } // Get textarea ready to copy from the context menu - textarea.value = selectionManager.selectionText; + textarea.value = selectionService.selectionText; textarea.select(); } diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 194d2e84..7250bdb5 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -9,7 +9,6 @@ import { C0, C1 } from 'common/data/EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets'; import { wcwidth } from 'common/CharWidth'; import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; -import { IDisposable } from 'xterm'; import { Disposable } from 'common/Lifecycle'; import { concat } from 'common/TypedArrayUtils'; import { StringToUtf32, stringFromCodePoint, utf32ToString, Utf8ToUtf32 } from 'common/input/TextDecoder'; @@ -20,6 +19,8 @@ import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags } from 'c import { CellData } from 'common/buffer/CellData'; import { AttributeData } from 'common/buffer/AttributeData'; import { ICoreService } from 'common/services/Services'; +import { ISelectionService } from 'browser/services/Services'; +import { IDisposable } from 'common/Types'; /** * Map collect to glevel. Used in `selectCharset`. @@ -112,6 +113,8 @@ export class InputHandler extends Disposable implements IInputHandler { private _utf8Decoder: Utf8ToUtf32 = new Utf8ToUtf32(); private _workCell: CellData = new CellData(); + private _selectionService: ISelectionService | undefined; + private _onCursorMove = new EventEmitter(); public get onCursorMove(): IEvent { return this._onCursorMove.event; } private _onLineFeed = new EventEmitter(); @@ -297,6 +300,11 @@ export class InputHandler extends Disposable implements IInputHandler { this._terminal = null; } + // TODO: When InputHandler moves into common, browser dependencies need to move out + public setBrowserServices(selectionService: ISelectionService): void { + this._selectionService = selectionService; + } + public parse(data: string): void { // Ensure the terminal is not disposed if (!this._terminal) { @@ -1299,7 +1307,7 @@ export class InputHandler extends Disposable implements IInputHandler { } else if (collect === '?') { switch (params[0]) { case 1: - this._terminal.applicationCursor = true; + this._coreService.decPrivateModes.applicationCursorKeys = true; break; case 2: this._terminal.setgCharset(0, DEFAULT_CHARSET); @@ -1347,8 +1355,8 @@ export class InputHandler extends Disposable implements IInputHandler { if (this._terminal.element) { this._terminal.element.classList.add('enable-mouse-events'); } - if (this._terminal.selectionManager) { - this._terminal.selectionManager.disable(); + if (this._selectionService) { + this._selectionService.disable(); } this._terminal.log('Binding to mouse events.'); break; @@ -1504,7 +1512,7 @@ export class InputHandler extends Disposable implements IInputHandler { } else if (collect === '?') { switch (params[0]) { case 1: - this._terminal.applicationCursor = false; + this._coreService.decPrivateModes.applicationCursorKeys = false; break; case 3: if (this._terminal.cols === 132 && this._terminal.savedCols) { @@ -1539,8 +1547,8 @@ export class InputHandler extends Disposable implements IInputHandler { if (this._terminal.element) { this._terminal.element.classList.remove('enable-mouse-events'); } - if (this._terminal.selectionManager) { - this._terminal.selectionManager.enable(); + if (this._selectionService) { + this._selectionService.enable(); } break; case 1004: // send focusin/focusout events @@ -1852,7 +1860,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (this._terminal.viewport) { this._terminal.viewport.syncScrollArea(); } - this._terminal.applicationCursor = false; + this._coreService.decPrivateModes.applicationCursorKeys = false; this._terminal.buffer.scrollTop = 0; this._terminal.buffer.scrollBottom = this._terminal.rows - 1; this._terminal.curAttrData = DEFAULT_ATTR_DATA.clone(); diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts deleted file mode 100644 index 6b11698f..00000000 --- a/src/SelectionManager.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { assert } from 'chai'; -import { SelectionManager, SelectionMode } from './SelectionManager'; -import { SelectionModel } from 'browser/selection/SelectionModel'; -import { ITerminal } from './Types'; -import { IBuffer } from 'common/buffer/Types'; -import { IBufferLine } from 'common/Types'; -import { MockTerminal } from './TestUtils.test'; -import { MockBufferService, MockOptionsService, MockCoreService } from 'common/TestUtils.test'; -import { BufferLine } from 'common/buffer/BufferLine'; -import { IBufferService, IOptionsService } from 'common/services/Services'; -import { MockCharSizeService, MockMouseService } from 'browser/TestUtils.test'; -import { CellData } from 'common/buffer/CellData'; - -class TestMockTerminal extends MockTerminal { - emit(event: string, data: any): void {} -} - -class TestSelectionManager extends SelectionManager { - constructor( - terminal: ITerminal, - bufferService: IBufferService, - optionsService: IOptionsService - ) { - super(terminal, null, new MockCharSizeService(10, 10), bufferService, new MockCoreService(), new MockMouseService(), optionsService); - } - - public get model(): SelectionModel { return this._model; } - - public set selectionMode(mode: SelectionMode) { this._activeSelectionMode = mode; } - - public selectLineAt(line: number): void { this._selectLineAt(line); } - public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); } - public areCoordsInSelection(coords: [number, number], start: [number, number], end: [number, number]): boolean { return this._areCoordsInSelection(coords, start, end); } - - // Disable DOM interaction - public enable(): void {} - public disable(): void {} - public refresh(): void {} -} - -describe('SelectionManager', () => { - let terminal: ITerminal; - let buffer: IBuffer; - let bufferService: IBufferService; - let optionsService: IOptionsService; - let selectionManager: TestSelectionManager; - - beforeEach(() => { - terminal = new TestMockTerminal(); - optionsService = new MockOptionsService(); - bufferService = new MockBufferService(20, 20, optionsService); - terminal.buffers = bufferService.buffers; - terminal.cols = 20; - terminal.rows = 20; - terminal.buffer = terminal.buffers.active; - buffer = terminal.buffer; - selectionManager = new TestSelectionManager(terminal, bufferService, optionsService); - }); - - function stringToRow(text: string): IBufferLine { - const result = new BufferLine(text.length); - for (let i = 0; i < text.length; i++) { - result.setCell(i, CellData.fromCharData([0, text.charAt(i), 1, text.charCodeAt(i)])); - } - return result; - } - - function stringArrayToRow(chars: string[]): IBufferLine { - const line = new BufferLine(chars.length); - chars.map((c, idx) => line.setCell(idx, CellData.fromCharData([0, c, 1, c.charCodeAt(0)]))); - return line; - } - - describe('_selectWordAt', () => { - it('should expand selection for normal width chars', () => { - buffer.lines.set(0, stringToRow('foo bar')); - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, 'foo'); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, 'foo'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, 'foo'); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([4, 0]); - assert.equal(selectionManager.selectionText, 'bar'); - selectionManager.selectWordAt([5, 0]); - assert.equal(selectionManager.selectionText, 'bar'); - selectionManager.selectWordAt([6, 0]); - assert.equal(selectionManager.selectionText, 'bar'); - }); - it('should expand selection for whitespace', () => { - buffer.lines.set(0, stringToRow('a b')); - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, 'a'); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([4, 0]); - assert.equal(selectionManager.selectionText, 'b'); - }); - it('should expand selection for wide characters', () => { - // Wide characters use a special format - const data: [number, string, number, number][] = [ - [null, '中', 2, '中'.charCodeAt(0)], - [null, '', 0, null], - [null, '文', 2, '文'.charCodeAt(0)], - [null, '', 0, null], - [null, ' ', 1, ' '.charCodeAt(0)], - [null, 'a', 1, 'a'.charCodeAt(0)], - [null, '中', 2, '中'.charCodeAt(0)], - [null, '', 0, null], - [null, '文', 2, '文'.charCodeAt(0)], - [null, '', 0, ''.charCodeAt(0)], - [null, 'b', 1, 'b'.charCodeAt(0)], - [null, ' ', 1, ' '.charCodeAt(0)], - [null, 'f', 1, 'f'.charCodeAt(0)], - [null, 'o', 1, 'o'.charCodeAt(0)], - [null, 'o', 1, 'o'.charCodeAt(0)] - ]; - const line = new BufferLine(data.length); - for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i])); - buffer.lines.set(0, line); - // Ensure wide characters take up 2 columns - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, '中文'); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, '中文'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, '中文'); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, '中文'); - selectionManager.selectWordAt([4, 0]); - assert.equal(selectionManager.selectionText, ' '); - // Ensure wide characters work when wrapped in normal width characters - selectionManager.selectWordAt([5, 0]); - assert.equal(selectionManager.selectionText, 'a中文b'); - selectionManager.selectWordAt([6, 0]); - assert.equal(selectionManager.selectionText, 'a中文b'); - selectionManager.selectWordAt([7, 0]); - assert.equal(selectionManager.selectionText, 'a中文b'); - selectionManager.selectWordAt([8, 0]); - assert.equal(selectionManager.selectionText, 'a中文b'); - selectionManager.selectWordAt([9, 0]); - assert.equal(selectionManager.selectionText, 'a中文b'); - selectionManager.selectWordAt([10, 0]); - assert.equal(selectionManager.selectionText, 'a中文b'); - selectionManager.selectWordAt([11, 0]); - assert.equal(selectionManager.selectionText, ' '); - // Ensure normal width characters work fine in a line containing wide characters - selectionManager.selectWordAt([12, 0]); - assert.equal(selectionManager.selectionText, 'foo'); - selectionManager.selectWordAt([13, 0]); - assert.equal(selectionManager.selectionText, 'foo'); - selectionManager.selectWordAt([14, 0]); - assert.equal(selectionManager.selectionText, 'foo'); - }); - it('should select up to non-path characters that are commonly adjacent to paths', () => { - buffer.lines.set(0, stringToRow('(cd)[ef]{gh}\'ij"')); - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, '(cd'); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, 'cd'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, 'cd'); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, 'cd)'); - selectionManager.selectWordAt([4, 0]); - assert.equal(selectionManager.selectionText, '[ef'); - selectionManager.selectWordAt([5, 0]); - assert.equal(selectionManager.selectionText, 'ef'); - selectionManager.selectWordAt([6, 0]); - assert.equal(selectionManager.selectionText, 'ef'); - selectionManager.selectWordAt([7, 0]); - assert.equal(selectionManager.selectionText, 'ef]'); - selectionManager.selectWordAt([8, 0]); - assert.equal(selectionManager.selectionText, '{gh'); - selectionManager.selectWordAt([9, 0]); - assert.equal(selectionManager.selectionText, 'gh'); - selectionManager.selectWordAt([10, 0]); - assert.equal(selectionManager.selectionText, 'gh'); - selectionManager.selectWordAt([11, 0]); - assert.equal(selectionManager.selectionText, 'gh}'); - selectionManager.selectWordAt([12, 0]); - assert.equal(selectionManager.selectionText, '\'ij'); - selectionManager.selectWordAt([13, 0]); - assert.equal(selectionManager.selectionText, 'ij'); - selectionManager.selectWordAt([14, 0]); - assert.equal(selectionManager.selectionText, 'ij'); - selectionManager.selectWordAt([15, 0]); - assert.equal(selectionManager.selectionText, 'ij"'); - }); - it('should expand upwards or downards for wrapped lines', () => { - buffer.lines.set(0, stringToRow(' foo')); - buffer.lines.set(1, stringToRow('bar ')); - buffer.lines.get(1).isWrapped = true; - selectionManager.selectWordAt([1, 1]); - assert.equal(selectionManager.selectionText, 'foobar'); - selectionManager.model.clearSelection(); - selectionManager.selectWordAt([18, 0]); - assert.equal(selectionManager.selectionText, 'foobar'); - }); - it('should expand both upwards and downwards for word wrapped over many lines', () => { - const expectedText = 'fooaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbccccccccccccccccccccbar'; - buffer.lines.set(0, stringToRow(' foo')); - buffer.lines.set(1, stringToRow('aaaaaaaaaaaaaaaaaaaa')); - buffer.lines.set(2, stringToRow('bbbbbbbbbbbbbbbbbbbb')); - buffer.lines.set(3, stringToRow('cccccccccccccccccccc')); - buffer.lines.set(4, stringToRow('bar ')); - buffer.lines.get(1).isWrapped = true; - buffer.lines.get(2).isWrapped = true; - buffer.lines.get(3).isWrapped = true; - buffer.lines.get(4).isWrapped = true; - selectionManager.selectWordAt([18, 0]); - assert.equal(selectionManager.selectionText, expectedText); - selectionManager.model.clearSelection(); - selectionManager.selectWordAt([10, 1]); - assert.equal(selectionManager.selectionText, expectedText); - selectionManager.model.clearSelection(); - selectionManager.selectWordAt([10, 2]); - assert.equal(selectionManager.selectionText, expectedText); - selectionManager.model.clearSelection(); - selectionManager.selectWordAt([10, 3]); - assert.equal(selectionManager.selectionText, expectedText); - selectionManager.model.clearSelection(); - selectionManager.selectWordAt([1, 4]); - assert.equal(selectionManager.selectionText, expectedText); - }); - describe('emoji', () => { - it('should treat a single emoji as a word when wrapped in spaces', () => { - buffer.lines.set(0, stringToRow(' ⚽ a')); // The a is here to prevent the space being trimmed in selectionText - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, '⚽'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, ' '); - }); - it('should treat multiple emojis as a word when wrapped in spaces', () => { - buffer.lines.set(0, stringToRow(' ⚽⚽ a')); // The a is here to prevent the space being trimmed in selectionText - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, '⚽⚽'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, '⚽⚽'); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, ' '); - }); - it('should treat emojis using the zero-width-joiner as a single word', () => { - // Note that the first 3 emojis include the invisible ZWJ char - buffer.lines.set(0, stringArrayToRow([ - ' ', '👨‍', '👩‍', '👧‍', '👦', ' ', 'a' - ])); // The a is here to prevent the space being trimmed in selectionText - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, ' '); - // ZWJ emojis do not combine in the terminal so the family emoji used here consumed 4 cells - // The selection text should retain ZWJ chars despite not combining on the terminal - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); - selectionManager.selectWordAt([4, 0]); - assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); - selectionManager.selectWordAt([5, 0]); - assert.equal(selectionManager.selectionText, ' '); - }); - it('should treat emojis and characters joined together as a word', () => { - buffer.lines.set(0, stringToRow(' ⚽ab cd⚽ ef⚽gh')); // The a is here to prevent the space being trimmed in selectionText - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, '⚽ab'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, '⚽ab'); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, '⚽ab'); - selectionManager.selectWordAt([4, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([5, 0]); - assert.equal(selectionManager.selectionText, 'cd⚽'); - selectionManager.selectWordAt([6, 0]); - assert.equal(selectionManager.selectionText, 'cd⚽'); - selectionManager.selectWordAt([7, 0]); - assert.equal(selectionManager.selectionText, 'cd⚽'); - selectionManager.selectWordAt([8, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([9, 0]); - assert.equal(selectionManager.selectionText, 'ef⚽gh'); - selectionManager.selectWordAt([10, 0]); - assert.equal(selectionManager.selectionText, 'ef⚽gh'); - selectionManager.selectWordAt([11, 0]); - assert.equal(selectionManager.selectionText, 'ef⚽gh'); - selectionManager.selectWordAt([12, 0]); - assert.equal(selectionManager.selectionText, 'ef⚽gh'); - selectionManager.selectWordAt([13, 0]); - assert.equal(selectionManager.selectionText, 'ef⚽gh'); - }); - it('should treat complex emojis and characters joined together as a word', () => { - // This emoji is the flag for England and is made up of: 1F3F4 E0067 E0062 E0065 E006E E0067 E007F - buffer.lines.set(0, stringArrayToRow([ - ' ', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'a', 'b', ' ', 'c', 'd', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', ' ', 'e', 'f', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'g', 'h', ' ', 'a' - ])); // The a is here to prevent the space being trimmed in selectionText - selectionManager.selectWordAt([0, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([1, 0]); - assert.equal(selectionManager.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); - selectionManager.selectWordAt([2, 0]); - assert.equal(selectionManager.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); - selectionManager.selectWordAt([3, 0]); - assert.equal(selectionManager.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); - selectionManager.selectWordAt([4, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([5, 0]); - assert.equal(selectionManager.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); - selectionManager.selectWordAt([6, 0]); - assert.equal(selectionManager.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); - selectionManager.selectWordAt([7, 0]); - assert.equal(selectionManager.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); - selectionManager.selectWordAt([8, 0]); - assert.equal(selectionManager.selectionText, ' '); - selectionManager.selectWordAt([9, 0]); - assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); - selectionManager.selectWordAt([10, 0]); - assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); - selectionManager.selectWordAt([11, 0]); - assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); - selectionManager.selectWordAt([12, 0]); - assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); - selectionManager.selectWordAt([13, 0]); - assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); - }); - }); - }); - - describe('_selectLineAt', () => { - it('should select the entire line', () => { - buffer.lines.set(0, stringToRow('foo bar')); - selectionManager.selectLineAt(0); - assert.equal(selectionManager.selectionText, 'foo bar', 'The selected text is correct'); - assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); - assert.deepEqual(selectionManager.model.finalSelectionEnd, [bufferService.cols, 0], 'The actual selection spans the entire column'); - }); - it('should select the entire wrapped line', () => { - buffer.lines.set(0, stringToRow('foo')); - const line2 = stringToRow('bar'); - line2.isWrapped = true; - buffer.lines.set(1, line2); - selectionManager.selectLineAt(0); - assert.equal(selectionManager.selectionText, 'foobar', 'The selected text is correct'); - assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); - assert.deepEqual(selectionManager.model.finalSelectionEnd, [bufferService.cols, 1], 'The actual selection spans the entire column'); - }); - }); - - describe('selectAll', () => { - it('should select the entire buffer, beyond the viewport', () => { - bufferService.resize(20, 5); - buffer.lines.set(0, stringToRow('1')); - buffer.lines.set(1, stringToRow('2')); - buffer.lines.set(2, stringToRow('3')); - buffer.lines.set(3, stringToRow('4')); - buffer.lines.set(4, stringToRow('5')); - selectionManager.selectAll(); - assert.equal(selectionManager.selectionText, '1\n2\n3\n4\n5'); - }); - }); - - describe('selectLines', () => { - it('should select a single line', () => { - buffer.lines.length = 3; - buffer.lines.set(0, stringToRow('1')); - buffer.lines.set(1, stringToRow('2')); - buffer.lines.set(2, stringToRow('3')); - selectionManager.selectLines(1, 1); - assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 1]); - assert.deepEqual(selectionManager.model.finalSelectionEnd, [bufferService.cols, 1]); - }); - it('should select multiple lines', () => { - buffer.lines.length = 5; - buffer.lines.set(0, stringToRow('1')); - buffer.lines.set(1, stringToRow('2')); - buffer.lines.set(2, stringToRow('3')); - buffer.lines.set(3, stringToRow('4')); - buffer.lines.set(4, stringToRow('5')); - selectionManager.selectLines(1, 3); - assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 1]); - assert.deepEqual(selectionManager.model.finalSelectionEnd, [bufferService.cols, 3]); - }); - it('should select the to the start when requesting a negative row', () => { - buffer.lines.length = 2; - buffer.lines.set(0, stringToRow('1')); - buffer.lines.set(1, stringToRow('2')); - selectionManager.selectLines(-1, 0); - assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); - assert.deepEqual(selectionManager.model.finalSelectionEnd, [bufferService.cols, 0]); - }); - it('should select the to the end when requesting beyond the final row', () => { - buffer.lines.length = 2; - buffer.lines.set(0, stringToRow('1')); - buffer.lines.set(1, stringToRow('2')); - selectionManager.selectLines(1, 2); - assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 1]); - assert.deepEqual(selectionManager.model.finalSelectionEnd, [bufferService.cols, 1]); - }); - }); - - describe('hasSelection', () => { - it('should return whether there is a selection', () => { - selectionManager.model.selectionStart = [0, 0]; - selectionManager.model.selectionStartLength = 0; - assert.equal(selectionManager.hasSelection, false); - selectionManager.model.selectionEnd = [0, 0]; - assert.equal(selectionManager.hasSelection, false); - selectionManager.model.selectionEnd = [1, 0]; - assert.equal(selectionManager.hasSelection, true); - selectionManager.model.selectionEnd = [0, 1]; - assert.equal(selectionManager.hasSelection, true); - selectionManager.model.selectionEnd = [1, 1]; - assert.equal(selectionManager.hasSelection, true); - }); - }); - - describe('column selection', () => { - it('should select a column of text', () => { - buffer.lines.length = 3; - buffer.lines.set(0, stringToRow('abcdefghij')); - buffer.lines.set(1, stringToRow('klmnopqrst')); - buffer.lines.set(2, stringToRow('uvwxyz')); - - selectionManager.selectionMode = SelectionMode.COLUMN; - selectionManager.model.selectionStart = [2, 0]; - selectionManager.model.selectionEnd = [4, 2]; - - assert.equal(selectionManager.selectionText, 'cd\nmn\nwx'); - }); - - it('should select a column of text without chopping up double width characters', () => { - buffer.lines.length = 3; - buffer.lines.set(0, stringToRow('a')); - buffer.lines.set(1, stringToRow('語')); - buffer.lines.set(2, stringToRow('b')); - - selectionManager.selectionMode = SelectionMode.COLUMN; - selectionManager.model.selectionStart = [0, 0]; - selectionManager.model.selectionEnd = [1, 2]; - - assert.equal(selectionManager.selectionText, 'a\n語\nb'); - }); - - it('should select a column of text with single character emojis', () => { - buffer.lines.length = 3; - buffer.lines.set(0, stringToRow('a')); - buffer.lines.set(1, stringToRow('☃')); - buffer.lines.set(2, stringToRow('c')); - - selectionManager.selectionMode = SelectionMode.COLUMN; - selectionManager.model.selectionStart = [0, 0]; - selectionManager.model.selectionEnd = [1, 2]; - - assert.equal(selectionManager.selectionText, 'a\n☃\nc'); - }); - - it('should select a column of text with double character emojis', () => { - // TODO the case this is testing works for me in the demo webapp, - // but doing it programmatically fails. - buffer.lines.length = 3; - buffer.lines.set(0, stringToRow('a ')); - buffer.lines.set(1, stringArrayToRow(['😁', ' '])); - buffer.lines.set(2, stringToRow('c ')); - - selectionManager.selectionMode = SelectionMode.COLUMN; - selectionManager.model.selectionStart = [0, 0]; - selectionManager.model.selectionEnd = [1, 2]; - - assert.equal(selectionManager.selectionText, 'a\n😁\nc'); - }); - }); - - describe('_areCoordsInSelection', () => { - it('should return whether coords are in the selection', () => { - assert.isFalse(selectionManager.areCoordsInSelection([0, 0], [2, 0], [2, 1])); - assert.isFalse(selectionManager.areCoordsInSelection([1, 0], [2, 0], [2, 1])); - assert.isTrue(selectionManager.areCoordsInSelection([2, 0], [2, 0], [2, 1])); - assert.isTrue(selectionManager.areCoordsInSelection([10, 0], [2, 0], [2, 1])); - assert.isTrue(selectionManager.areCoordsInSelection([0, 1], [2, 0], [2, 1])); - assert.isTrue(selectionManager.areCoordsInSelection([1, 1], [2, 0], [2, 1])); - assert.isFalse(selectionManager.areCoordsInSelection([2, 1], [2, 0], [2, 1])); - }); - }); -}); - diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index f2f02eec..f50b32a7 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -548,10 +548,12 @@ describe('Terminal', () => { }); describe('with macOptionIsMeta', () => { + let originalIsMac: boolean; beforeEach(() => { - term.browser.isMac = true; + originalIsMac = term.browser.isMac; term.options.macOptionIsMeta = true; }); + afterEach(() => term.browser.isMac = originalIsMac); it('should interfere with the alt key on keyDown', () => { evKeyDown.altKey = true; @@ -564,9 +566,12 @@ describe('Terminal', () => { }); describe('On Mac OS', () => { + let originalIsMac: boolean; beforeEach(() => { + originalIsMac = term.browser.isMac; term.browser.isMac = true; }); + afterEach(() => term.browser.isMac = originalIsMac); it('should not interfere with the alt key on keyDown', () => { evKeyDown.altKey = true; @@ -627,9 +632,12 @@ describe('Terminal', () => { }); describe('On MS Windows', () => { + let originalIsWindows: boolean; beforeEach(() => { - term.browser.isMSWindows = true; + originalIsWindows = term.browser.isWindows; + term.browser.isWindows = true; }); + afterEach(() => term.browser.isWindows = originalIsWindows); it('should not interfere with the alt + ctrl key on keyDown', () => { evKeyPress.altKey = true; diff --git a/src/Terminal.ts b/src/Terminal.ts index a9f11b4b..f8360117 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -30,7 +30,7 @@ import { C0 } from 'common/data/EscapeSequences'; import { InputHandler } from './InputHandler'; import { Renderer } from './renderer/Renderer'; import { Linkifier } from './Linkifier'; -import { SelectionManager } from './SelectionManager'; +import { SelectionService } from './browser/services/SelectionService'; import * as Browser from 'common/Platform'; import { addDisposableDomListener } from 'browser/Lifecycle'; import * as Strings from './browser/LocalizableStrings'; @@ -49,7 +49,7 @@ import { ColorManager } from 'browser/ColorManager'; import { RenderService } from 'browser/services/RenderService'; import { IOptionsService, IBufferService, ICoreService } from 'common/services/Services'; import { OptionsService } from 'common/services/OptionsService'; -import { ICharSizeService, IRenderService, IMouseService } from 'browser/services/Services'; +import { ICharSizeService, IRenderService, IMouseService, ISelectionService } from 'browser/services/Services'; import { CharSizeService } from 'browser/services/CharSizeService'; import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService'; import { Disposable } from 'common/Lifecycle'; @@ -113,12 +113,12 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // browser services private _charSizeService: ICharSizeService; - private _renderService: IRenderService; private _mouseService: IMouseService; + private _renderService: IRenderService; + private _selectionService: ISelectionService; // modes public applicationKeypad: boolean; - public applicationCursor: boolean; public originMode: boolean; public insertMode: boolean; public wraparoundMode: boolean; // defaults: xterm - true, vt100 - false @@ -175,7 +175,6 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp private _inputHandler: InputHandler; public soundManager: SoundManager; - public selectionManager: SelectionManager; public linkifier: ILinkifier; public viewport: IViewport; private _compositionHelper: ICompositionHelper; @@ -269,7 +268,6 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // modes this.applicationKeypad = false; - this.applicationCursor = false; this.originMode = false; this.insertMode = false; this.wraparoundMode = true; // defaults: xterm - true, vt100 - false @@ -303,16 +301,11 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._inputHandler.onLineFeed(() => this._onLineFeed.fire()); this.register(this._inputHandler); - this.selectionManager = this.selectionManager || null; + this._selectionService = this._selectionService || null; this.linkifier = this.linkifier || new Linkifier(this); this._mouseZoneManager = this._mouseZoneManager || null; this.soundManager = this.soundManager || new SoundManager(this); - if (this.selectionManager) { - this.selectionManager.clearSelection(); - this.selectionManager.initBuffersListeners(); - } - if (this.options.windowsMode) { this._windowsMode = applyWindowsMode(this); } @@ -481,7 +474,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp if (!this.hasSelection()) { return; } - copyHandler(event, this.selectionManager); + copyHandler(event, this._selectionService); })); const pasteHandlerWrapper = (event: ClipboardEvent) => pasteHandler(event, this.textarea, this.bracketedPasteMode, e => this._coreService.triggerDataEvent(e, true)); this.register(addDisposableDomListener(this.textarea, 'paste', pasteHandlerWrapper)); @@ -492,12 +485,12 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // Firefox doesn't appear to fire the contextmenu event on right click this.register(addDisposableDomListener(this.element, 'mousedown', (event: MouseEvent) => { if (event.button === 2) { - rightClickHandler(event, this.textarea, this.screenElement, this.selectionManager, this.options.rightClickSelectsWord); + rightClickHandler(event, this.textarea, this.screenElement, this._selectionService, this.options.rightClickSelectsWord); } })); } else { this.register(addDisposableDomListener(this.element, 'contextmenu', (event: MouseEvent) => { - rightClickHandler(event, this.textarea, this.screenElement, this.selectionManager, this.options.rightClickSelectsWord); + rightClickHandler(event, this.textarea, this.screenElement, this._selectionService, this.options.rightClickSelectsWord); })); } @@ -643,11 +636,15 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this.register(this.onFocus(() => this._renderService.onFocus())); this.register(this._renderService.onDimensionsChange(() => this.viewport.syncScrollArea())); - this.selectionManager = new SelectionManager(this, this.screenElement, this._charSizeService, this._bufferService, this._coreService, this._mouseService, this.optionsService); - this.register(this.selectionManager.onSelectionChange(() => this._onSelectionChange.fire())); - this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e))); - this.register(this.selectionManager.onRedrawRequest(e => this._renderService.onSelectionChanged(e.start, e.end, e.columnSelectMode))); - this.register(this.selectionManager.onLinuxMouseSelection(text => { + this._selectionService = new SelectionService( + (amount: number, suppressEvent: boolean) => this.scrollLines(amount, suppressEvent), + this.element, this.screenElement, this._charSizeService, this._bufferService, this._coreService, + this._mouseService, this.optionsService + ); + this.register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire())); + this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService.onMouseDown(e))); + this.register(this._selectionService.onRedrawRequest(e => this._renderService.onSelectionChanged(e.start, e.end, e.columnSelectMode))); + this.register(this._selectionService.onLinuxMouseSelection(text => { // If there's a new selection, put it into the textarea, focus and select it // in order to register it as a selection on the OS. This event is fired // only on Linux to enable middle click to paste selection. @@ -657,16 +654,16 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp })); this.register(this.onScroll(() => { this.viewport.syncScrollArea(); - this.selectionManager.refresh(); + this._selectionService.refresh(); })); - this.register(addDisposableDomListener(this._viewportElement, 'scroll', () => this.selectionManager.refresh())); + this.register(addDisposableDomListener(this._viewportElement, 'scroll', () => this._selectionService.refresh())); // apply mouse event classes set by escape codes before terminal was attached this.element.classList.toggle('enable-mouse-events', this.mouseEvents); if (this.mouseEvents) { - this.selectionManager.disable(); + this._selectionService.disable(); } else { - this.selectionManager.enable(); + this._selectionService.enable(); } if (this.options.screenReaderMode) { @@ -944,7 +941,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // 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.mouseEvents || this.selectionManager.shouldForceSelection(ev)) { + if (!this.mouseEvents || this._selectionService.shouldForceSelection(ev)) { return; } @@ -1013,7 +1010,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp } // Construct and send sequences - const sequence = C0.ESC + (this.applicationCursor ? 'O' : '[') + ( ev.deltaY < 0 ? 'A' : 'B'); + const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + ( ev.deltaY < 0 ? 'A' : 'B'); let data = ''; for (let i = 0; i < Math.abs(amount); i++) { data += sequence; @@ -1075,7 +1072,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * Change the cursor style for different selection modes */ public updateCursorStyle(ev: KeyboardEvent): void { - if (this.selectionManager && this.selectionManager.shouldColumnSelect(ev)) { + if (this._selectionService && this._selectionService.shouldColumnSelect(ev)) { this.element.classList.add('column-select'); } else { this.element.classList.remove('column-select'); @@ -1479,7 +1476,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * Gets whether the terminal has an active selection. */ public hasSelection(): boolean { - return this.selectionManager ? this.selectionManager.hasSelection : false; + return this._selectionService ? this._selectionService.hasSelection : false; } /** @@ -1489,7 +1486,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * @param length The length of the selection. */ public select(column: number, row: number, length: number): void { - this.selectionManager.setSelection(column, row, length); + this._selectionService.setSelection(column, row, length); } /** @@ -1497,19 +1494,19 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * behavior outside of xterm.js. */ public getSelection(): string { - return this.selectionManager ? this.selectionManager.selectionText : ''; + return this._selectionService ? this._selectionService.selectionText : ''; } public getSelectionPosition(): ISelectionPosition | undefined { - if (!this.selectionManager.hasSelection) { + if (!this._selectionService.hasSelection) { return undefined; } return { - startColumn: this.selectionManager.selectionStart[0], - startRow: this.selectionManager.selectionStart[1], - endColumn: this.selectionManager.selectionEnd[0], - endRow: this.selectionManager.selectionEnd[1] + startColumn: this._selectionService.selectionStart[0], + startRow: this._selectionService.selectionStart[1], + endColumn: this._selectionService.selectionEnd[0], + endRow: this._selectionService.selectionEnd[1] }; } @@ -1517,8 +1514,8 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * Clears the current terminal selection. */ public clearSelection(): void { - if (this.selectionManager) { - this.selectionManager.clearSelection(); + if (this._selectionService) { + this._selectionService.clearSelection(); } } @@ -1526,14 +1523,14 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * Selects all text within the terminal. */ public selectAll(): void { - if (this.selectionManager) { - this.selectionManager.selectAll(); + if (this._selectionService) { + this._selectionService.selectAll(); } } public selectLines(start: number, end: number): void { - if (this.selectionManager) { - this.selectionManager.selectLines(start, end); + if (this._selectionService) { + this._selectionService.selectLines(start, end); } } @@ -1555,7 +1552,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp return false; } - const result = evaluateKeyboardEvent(event, this.applicationCursor, this.browser.isMac, this.options.macOptionIsMeta); + const result = evaluateKeyboardEvent(event, this._coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta); this.updateCursorStyle(event); @@ -1598,7 +1595,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp private _isThirdLevelShift(browser: IBrowser, ev: IKeyboardEvent): boolean { const thirdLevelKey = (browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) || - (browser.isMSWindows && ev.altKey && ev.ctrlKey && !ev.metaKey); + (browser.isWindows && ev.altKey && ev.ctrlKey && !ev.metaKey); if (ev.type === 'keypress') { return thirdLevelKey; @@ -1806,8 +1803,8 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // } // // Clear the selection if the selection manager is available and has an active selection - // if (this.selectionManager && this.selectionManager.hasSelection) { - // this.selectionManager.clearSelection(); + // if (this.selectionService && this.selectionService.hasSelection) { + // this.selectionService.clearSelection(); // } // // Input is being sent to the terminal, the terminal should focus the prompt. @@ -1885,6 +1882,10 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._setup(); this._bufferService.reset(); + this._coreService.reset(); + if (this._selectionService) { + this._selectionService.reset(); + } // reattach this._customKeyEventHandler = customKeyEventHandler; diff --git a/src/TestUtils.test.ts b/src/TestUtils.test.ts index 8394f5a7..b203d1a1 100644 --- a/src/TestUtils.test.ts +++ b/src/TestUtils.test.ts @@ -15,7 +15,7 @@ import { AttributeData } from 'common/buffer/AttributeData'; import { IColorManager, IColorSet } from 'browser/Types'; import { IOptionsService } from 'common/services/Services'; import { EventEmitter } from 'common/EventEmitter'; -import { ISelectionManager } from 'browser/selection/Types'; +import { ISelectionService } from 'browser/services/Services'; export class TestTerminal extends Terminal { writeSync(data: string): void { @@ -131,7 +131,7 @@ export class MockTerminal implements ITerminal { screenElement: HTMLElement; rowContainer: HTMLElement; selectionContainer: HTMLElement; - selectionManager: ISelectionManager; + selectionService: ISelectionService; textarea: HTMLTextAreaElement; rows: number; cols: number; @@ -216,7 +216,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { buffers: IBufferSet; buffer: IBuffer = new MockBuffer(); viewport: IViewport; - selectionManager: ISelectionManager; + selectionService: ISelectionService; focus(): void { throw new Error('Method not implemented.'); } diff --git a/src/Types.d.ts b/src/Types.d.ts index 20e6c082..eaf6196b 100644 --- a/src/Types.d.ts +++ b/src/Types.d.ts @@ -9,7 +9,6 @@ import { IEvent, IEventEmitter } from 'common/EventEmitter'; import { IColorSet } from 'browser/Types'; import { IOptionsService } from 'common/services/Services'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { ISelectionManager } from 'browser/selection/Types'; export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean; @@ -33,7 +32,6 @@ export interface IInputHandlingTerminal { glevel: number; charsets: ICharset[]; applicationKeypad: boolean; - applicationCursor: boolean; originMode: boolean; insertMode: boolean; wraparoundMode: boolean; @@ -53,7 +51,6 @@ export interface IInputHandlingTerminal { buffers: IBufferSet; buffer: IBuffer; viewport: IViewport; - selectionManager: ISelectionManager; onA11yCharEmitter: IEventEmitter; onA11yTabEmitter: IEventEmitter; @@ -196,7 +193,6 @@ export interface ILinkifierEvent { export interface ITerminal extends IPublicTerminal, IElementAccessor, IBufferAccessor, ILinkifierAccessor { screenElement: HTMLElement; - selectionManager: ISelectionManager; browser: IBrowser; writeBuffer: string[]; cursorHidden: boolean; @@ -206,7 +202,6 @@ export interface ITerminal extends IPublicTerminal, IElementAccessor, IBufferAcc isFocused: boolean; viewport: IViewport; bracketedPasteMode: boolean; - applicationCursor: boolean; optionsService: IOptionsService; // TODO: We should remove options once components adopt optionsService options: ITerminalOptions; @@ -348,7 +343,7 @@ export interface IBrowser { isMac: boolean; isIpad: boolean; isIphone: boolean; - isMSWindows: boolean; + isWindows: boolean; } export interface ISoundManager { diff --git a/src/browser/selection/Types.d.ts b/src/browser/selection/Types.d.ts index 241731f1..d5a03ecc 100644 --- a/src/browser/selection/Types.d.ts +++ b/src/browser/selection/Types.d.ts @@ -3,20 +3,8 @@ * @license MIT */ -export interface ISelectionManager { - selectionText: string; - selectionStart: [number, number]; - selectionEnd: [number, number]; - - disable(): void; - enable(): void; - setSelection(row: number, col: number, length: number): void; - isClickInSelection(event: MouseEvent): boolean; - selectWordAtCursor(event: MouseEvent): void; -} - export interface ISelectionRedrawRequestEvent { - start: [number, number]; - end: [number, number]; + start: [number, number] | undefined; + end: [number, number] | undefined; columnSelectMode: boolean; } diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts new file mode 100644 index 00000000..fc9cafdd --- /dev/null +++ b/src/browser/services/SelectionService.test.ts @@ -0,0 +1,492 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { SelectionService, SelectionMode } from './SelectionService'; +import { SelectionModel } from 'browser/selection/SelectionModel'; +import { IBufferLine } from 'common/Types'; +import { MockBufferService, MockOptionsService, MockCoreService } from 'common/TestUtils.test'; +import { BufferLine } from 'common/buffer/BufferLine'; +import { IBufferService, IOptionsService } from 'common/services/Services'; +import { MockCharSizeService, MockMouseService } from 'browser/TestUtils.test'; +import { CellData } from 'common/buffer/CellData'; +import { IBuffer } from 'common/buffer/Types'; +import { isWindows } from '../../../out/common/Platform'; + +class TestSelectionService extends SelectionService { + constructor( + bufferService: IBufferService, + optionsService: IOptionsService + ) { + super(() => {}, null!, null!, new MockCharSizeService(10, 10), bufferService, new MockCoreService(), new MockMouseService(), optionsService); + } + + public get model(): SelectionModel { return this._model; } + + public set selectionMode(mode: SelectionMode) { this._activeSelectionMode = mode; } + + public selectLineAt(line: number): void { this._selectLineAt(line); } + public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); } + public areCoordsInSelection(coords: [number, number], start: [number, number], end: [number, number]): boolean { return this._areCoordsInSelection(coords, start, end); } + + // Disable DOM interaction + public enable(): void {} + public disable(): void {} + public refresh(): void {} +} + +describe('SelectionService', () => { + let buffer: IBuffer; + let bufferService: IBufferService; + let optionsService: IOptionsService; + let selectionService: TestSelectionService; + + beforeEach(() => { + optionsService = new MockOptionsService(); + bufferService = new MockBufferService(20, 20, optionsService); + buffer = bufferService.buffer; + selectionService = new TestSelectionService(bufferService, optionsService); + }); + + function stringToRow(text: string): IBufferLine { + const result = new BufferLine(text.length); + for (let i = 0; i < text.length; i++) { + result.setCell(i, CellData.fromCharData([0, text.charAt(i), 1, text.charCodeAt(i)])); + } + return result; + } + + function stringArrayToRow(chars: string[]): IBufferLine { + const line = new BufferLine(chars.length); + chars.map((c, idx) => line.setCell(idx, CellData.fromCharData([0, c, 1, c.charCodeAt(0)]))); + return line; + } + + describe('_selectWordAt', () => { + it('should expand selection for normal width chars', () => { + buffer.lines.set(0, stringToRow('foo bar')); + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, 'foo'); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, 'foo'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, 'foo'); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([4, 0]); + assert.equal(selectionService.selectionText, 'bar'); + selectionService.selectWordAt([5, 0]); + assert.equal(selectionService.selectionText, 'bar'); + selectionService.selectWordAt([6, 0]); + assert.equal(selectionService.selectionText, 'bar'); + }); + it('should expand selection for whitespace', () => { + buffer.lines.set(0, stringToRow('a b')); + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, 'a'); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([4, 0]); + assert.equal(selectionService.selectionText, 'b'); + }); + it('should expand selection for wide characters', () => { + // Wide characters use a special format + const data: [number, string, number, number][] = [ + [0, '中', 2, '中'.charCodeAt(0)], + [0, '', 0, 0], + [0, '文', 2, '文'.charCodeAt(0)], + [0, '', 0, 0], + [0, ' ', 1, ' '.charCodeAt(0)], + [0, 'a', 1, 'a'.charCodeAt(0)], + [0, '中', 2, '中'.charCodeAt(0)], + [0, '', 0, 0], + [0, '文', 2, '文'.charCodeAt(0)], + [0, '', 0, ''.charCodeAt(0)], + [0, 'b', 1, 'b'.charCodeAt(0)], + [0, ' ', 1, ' '.charCodeAt(0)], + [0, 'f', 1, 'f'.charCodeAt(0)], + [0, 'o', 1, 'o'.charCodeAt(0)], + [0, 'o', 1, 'o'.charCodeAt(0)] + ]; + const line = new BufferLine(data.length); + for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i])); + buffer.lines.set(0, line); + // Ensure wide characters take up 2 columns + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, '中文'); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, '中文'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, '中文'); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, '中文'); + selectionService.selectWordAt([4, 0]); + assert.equal(selectionService.selectionText, ' '); + // Ensure wide characters work when wrapped in normal width characters + selectionService.selectWordAt([5, 0]); + assert.equal(selectionService.selectionText, 'a中文b'); + selectionService.selectWordAt([6, 0]); + assert.equal(selectionService.selectionText, 'a中文b'); + selectionService.selectWordAt([7, 0]); + assert.equal(selectionService.selectionText, 'a中文b'); + selectionService.selectWordAt([8, 0]); + assert.equal(selectionService.selectionText, 'a中文b'); + selectionService.selectWordAt([9, 0]); + assert.equal(selectionService.selectionText, 'a中文b'); + selectionService.selectWordAt([10, 0]); + assert.equal(selectionService.selectionText, 'a中文b'); + selectionService.selectWordAt([11, 0]); + assert.equal(selectionService.selectionText, ' '); + // Ensure normal width characters work fine in a line containing wide characters + selectionService.selectWordAt([12, 0]); + assert.equal(selectionService.selectionText, 'foo'); + selectionService.selectWordAt([13, 0]); + assert.equal(selectionService.selectionText, 'foo'); + selectionService.selectWordAt([14, 0]); + assert.equal(selectionService.selectionText, 'foo'); + }); + it('should select up to non-path characters that are commonly adjacent to paths', () => { + buffer.lines.set(0, stringToRow('(cd)[ef]{gh}\'ij"')); + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, '(cd'); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, 'cd'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, 'cd'); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, 'cd)'); + selectionService.selectWordAt([4, 0]); + assert.equal(selectionService.selectionText, '[ef'); + selectionService.selectWordAt([5, 0]); + assert.equal(selectionService.selectionText, 'ef'); + selectionService.selectWordAt([6, 0]); + assert.equal(selectionService.selectionText, 'ef'); + selectionService.selectWordAt([7, 0]); + assert.equal(selectionService.selectionText, 'ef]'); + selectionService.selectWordAt([8, 0]); + assert.equal(selectionService.selectionText, '{gh'); + selectionService.selectWordAt([9, 0]); + assert.equal(selectionService.selectionText, 'gh'); + selectionService.selectWordAt([10, 0]); + assert.equal(selectionService.selectionText, 'gh'); + selectionService.selectWordAt([11, 0]); + assert.equal(selectionService.selectionText, 'gh}'); + selectionService.selectWordAt([12, 0]); + assert.equal(selectionService.selectionText, '\'ij'); + selectionService.selectWordAt([13, 0]); + assert.equal(selectionService.selectionText, 'ij'); + selectionService.selectWordAt([14, 0]); + assert.equal(selectionService.selectionText, 'ij'); + selectionService.selectWordAt([15, 0]); + assert.equal(selectionService.selectionText, 'ij"'); + }); + it('should expand upwards or downards for wrapped lines', () => { + buffer.lines.set(0, stringToRow(' foo')); + buffer.lines.set(1, stringToRow('bar ')); + buffer.lines.get(1)!.isWrapped = true; + selectionService.selectWordAt([1, 1]); + assert.equal(selectionService.selectionText, 'foobar'); + selectionService.model.clearSelection(); + selectionService.selectWordAt([18, 0]); + assert.equal(selectionService.selectionText, 'foobar'); + }); + it('should expand both upwards and downwards for word wrapped over many lines', () => { + const expectedText = 'fooaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbccccccccccccccccccccbar'; + buffer.lines.set(0, stringToRow(' foo')); + buffer.lines.set(1, stringToRow('aaaaaaaaaaaaaaaaaaaa')); + buffer.lines.set(2, stringToRow('bbbbbbbbbbbbbbbbbbbb')); + buffer.lines.set(3, stringToRow('cccccccccccccccccccc')); + buffer.lines.set(4, stringToRow('bar ')); + buffer.lines.get(1)!.isWrapped = true; + buffer.lines.get(2)!.isWrapped = true; + buffer.lines.get(3)!.isWrapped = true; + buffer.lines.get(4)!.isWrapped = true; + selectionService.selectWordAt([18, 0]); + assert.equal(selectionService.selectionText, expectedText); + selectionService.model.clearSelection(); + selectionService.selectWordAt([10, 1]); + assert.equal(selectionService.selectionText, expectedText); + selectionService.model.clearSelection(); + selectionService.selectWordAt([10, 2]); + assert.equal(selectionService.selectionText, expectedText); + selectionService.model.clearSelection(); + selectionService.selectWordAt([10, 3]); + assert.equal(selectionService.selectionText, expectedText); + selectionService.model.clearSelection(); + selectionService.selectWordAt([1, 4]); + assert.equal(selectionService.selectionText, expectedText); + }); + describe('emoji', () => { + it('should treat a single emoji as a word when wrapped in spaces', () => { + buffer.lines.set(0, stringToRow(' ⚽ a')); // The a is here to prevent the space being trimmed in selectionText + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, '⚽'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, ' '); + }); + it('should treat multiple emojis as a word when wrapped in spaces', () => { + buffer.lines.set(0, stringToRow(' ⚽⚽ a')); // The a is here to prevent the space being trimmed in selectionText + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, '⚽⚽'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, '⚽⚽'); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, ' '); + }); + it('should treat emojis using the zero-width-joiner as a single word', () => { + // Note that the first 3 emojis include the invisible ZWJ char + buffer.lines.set(0, stringArrayToRow([ + ' ', '👨‍', '👩‍', '👧‍', '👦', ' ', 'a' + ])); // The a is here to prevent the space being trimmed in selectionText + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, ' '); + // ZWJ emojis do not combine in the terminal so the family emoji used here consumed 4 cells + // The selection text should retain ZWJ chars despite not combining on the terminal + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, '👨‍👩‍👧‍👦'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, '👨‍👩‍👧‍👦'); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, '👨‍👩‍👧‍👦'); + selectionService.selectWordAt([4, 0]); + assert.equal(selectionService.selectionText, '👨‍👩‍👧‍👦'); + selectionService.selectWordAt([5, 0]); + assert.equal(selectionService.selectionText, ' '); + }); + it('should treat emojis and characters joined together as a word', () => { + buffer.lines.set(0, stringToRow(' ⚽ab cd⚽ ef⚽gh')); // The a is here to prevent the space being trimmed in selectionText + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, '⚽ab'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, '⚽ab'); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, '⚽ab'); + selectionService.selectWordAt([4, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([5, 0]); + assert.equal(selectionService.selectionText, 'cd⚽'); + selectionService.selectWordAt([6, 0]); + assert.equal(selectionService.selectionText, 'cd⚽'); + selectionService.selectWordAt([7, 0]); + assert.equal(selectionService.selectionText, 'cd⚽'); + selectionService.selectWordAt([8, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([9, 0]); + assert.equal(selectionService.selectionText, 'ef⚽gh'); + selectionService.selectWordAt([10, 0]); + assert.equal(selectionService.selectionText, 'ef⚽gh'); + selectionService.selectWordAt([11, 0]); + assert.equal(selectionService.selectionText, 'ef⚽gh'); + selectionService.selectWordAt([12, 0]); + assert.equal(selectionService.selectionText, 'ef⚽gh'); + selectionService.selectWordAt([13, 0]); + assert.equal(selectionService.selectionText, 'ef⚽gh'); + }); + it('should treat complex emojis and characters joined together as a word', () => { + // This emoji is the flag for England and is made up of: 1F3F4 E0067 E0062 E0065 E006E E0067 E007F + buffer.lines.set(0, stringArrayToRow([ + ' ', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'a', 'b', ' ', 'c', 'd', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', ' ', 'e', 'f', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'g', 'h', ' ', 'a' + ])); // The a is here to prevent the space being trimmed in selectionText + selectionService.selectWordAt([0, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([1, 0]); + assert.equal(selectionService.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); + selectionService.selectWordAt([2, 0]); + assert.equal(selectionService.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); + selectionService.selectWordAt([3, 0]); + assert.equal(selectionService.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); + selectionService.selectWordAt([4, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([5, 0]); + assert.equal(selectionService.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); + selectionService.selectWordAt([6, 0]); + assert.equal(selectionService.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); + selectionService.selectWordAt([7, 0]); + assert.equal(selectionService.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); + selectionService.selectWordAt([8, 0]); + assert.equal(selectionService.selectionText, ' '); + selectionService.selectWordAt([9, 0]); + assert.equal(selectionService.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionService.selectWordAt([10, 0]); + assert.equal(selectionService.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionService.selectWordAt([11, 0]); + assert.equal(selectionService.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionService.selectWordAt([12, 0]); + assert.equal(selectionService.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionService.selectWordAt([13, 0]); + assert.equal(selectionService.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + }); + }); + }); + + describe('_selectLineAt', () => { + it('should select the entire line', () => { + buffer.lines.set(0, stringToRow('foo bar')); + selectionService.selectLineAt(0); + assert.equal(selectionService.selectionText, 'foo bar', 'The selected text is correct'); + assert.deepEqual(selectionService.model.finalSelectionStart, [0, 0]); + assert.deepEqual(selectionService.model.finalSelectionEnd, [bufferService.cols, 0], 'The actual selection spans the entire column'); + }); + it('should select the entire wrapped line', () => { + buffer.lines.set(0, stringToRow('foo')); + const line2 = stringToRow('bar'); + line2.isWrapped = true; + buffer.lines.set(1, line2); + selectionService.selectLineAt(0); + assert.equal(selectionService.selectionText, 'foobar', 'The selected text is correct'); + assert.deepEqual(selectionService.model.finalSelectionStart, [0, 0]); + assert.deepEqual(selectionService.model.finalSelectionEnd, [bufferService.cols, 1], 'The actual selection spans the entire column'); + }); + }); + + describe('selectAll', () => { + it('should select the entire buffer, beyond the viewport', () => { + bufferService.resize(20, 5); + buffer.lines.set(0, stringToRow('1')); + buffer.lines.set(1, stringToRow('2')); + buffer.lines.set(2, stringToRow('3')); + buffer.lines.set(3, stringToRow('4')); + buffer.lines.set(4, stringToRow('5')); + selectionService.selectAll(); + console.log(selectionService.selectionText.length); + console.log(isWindows); + assert.equal(selectionService.selectionText, '1\n2\n3\n4\n5'); + }); + }); + + describe('selectLines', () => { + it('should select a single line', () => { + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('1')); + buffer.lines.set(1, stringToRow('2')); + buffer.lines.set(2, stringToRow('3')); + selectionService.selectLines(1, 1); + assert.deepEqual(selectionService.model.finalSelectionStart, [0, 1]); + assert.deepEqual(selectionService.model.finalSelectionEnd, [bufferService.cols, 1]); + }); + it('should select multiple lines', () => { + buffer.lines.length = 5; + buffer.lines.set(0, stringToRow('1')); + buffer.lines.set(1, stringToRow('2')); + buffer.lines.set(2, stringToRow('3')); + buffer.lines.set(3, stringToRow('4')); + buffer.lines.set(4, stringToRow('5')); + selectionService.selectLines(1, 3); + assert.deepEqual(selectionService.model.finalSelectionStart, [0, 1]); + assert.deepEqual(selectionService.model.finalSelectionEnd, [bufferService.cols, 3]); + }); + it('should select the to the start when requesting a negative row', () => { + buffer.lines.length = 2; + buffer.lines.set(0, stringToRow('1')); + buffer.lines.set(1, stringToRow('2')); + selectionService.selectLines(-1, 0); + assert.deepEqual(selectionService.model.finalSelectionStart, [0, 0]); + assert.deepEqual(selectionService.model.finalSelectionEnd, [bufferService.cols, 0]); + }); + it('should select the to the end when requesting beyond the final row', () => { + buffer.lines.length = 2; + buffer.lines.set(0, stringToRow('1')); + buffer.lines.set(1, stringToRow('2')); + selectionService.selectLines(1, 2); + assert.deepEqual(selectionService.model.finalSelectionStart, [0, 1]); + assert.deepEqual(selectionService.model.finalSelectionEnd, [bufferService.cols, 1]); + }); + }); + + describe('hasSelection', () => { + it('should return whether there is a selection', () => { + selectionService.model.selectionStart = [0, 0]; + selectionService.model.selectionStartLength = 0; + assert.equal(selectionService.hasSelection, false); + selectionService.model.selectionEnd = [0, 0]; + assert.equal(selectionService.hasSelection, false); + selectionService.model.selectionEnd = [1, 0]; + assert.equal(selectionService.hasSelection, true); + selectionService.model.selectionEnd = [0, 1]; + assert.equal(selectionService.hasSelection, true); + selectionService.model.selectionEnd = [1, 1]; + assert.equal(selectionService.hasSelection, true); + }); + }); + + describe('column selection', () => { + it('should select a column of text', () => { + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('abcdefghij')); + buffer.lines.set(1, stringToRow('klmnopqrst')); + buffer.lines.set(2, stringToRow('uvwxyz')); + + selectionService.selectionMode = SelectionMode.COLUMN; + selectionService.model.selectionStart = [2, 0]; + selectionService.model.selectionEnd = [4, 2]; + + assert.equal(selectionService.selectionText, 'cd\nmn\nwx'); + }); + + it('should select a column of text without chopping up double width characters', () => { + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('a')); + buffer.lines.set(1, stringToRow('語')); + buffer.lines.set(2, stringToRow('b')); + + selectionService.selectionMode = SelectionMode.COLUMN; + selectionService.model.selectionStart = [0, 0]; + selectionService.model.selectionEnd = [1, 2]; + + assert.equal(selectionService.selectionText, 'a\n語\nb'); + }); + + it('should select a column of text with single character emojis', () => { + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('a')); + buffer.lines.set(1, stringToRow('☃')); + buffer.lines.set(2, stringToRow('c')); + + selectionService.selectionMode = SelectionMode.COLUMN; + selectionService.model.selectionStart = [0, 0]; + selectionService.model.selectionEnd = [1, 2]; + + assert.equal(selectionService.selectionText, 'a\n☃\nc'); + }); + + it('should select a column of text with double character emojis', () => { + // TODO the case this is testing works for me in the demo webapp, + // but doing it programmatically fails. + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('a ')); + buffer.lines.set(1, stringArrayToRow(['😁', ' '])); + buffer.lines.set(2, stringToRow('c ')); + + selectionService.selectionMode = SelectionMode.COLUMN; + selectionService.model.selectionStart = [0, 0]; + selectionService.model.selectionEnd = [1, 2]; + + assert.equal(selectionService.selectionText, 'a\n😁\nc'); + }); + }); + + describe('_areCoordsInSelection', () => { + it('should return whether coords are in the selection', () => { + assert.isFalse(selectionService.areCoordsInSelection([0, 0], [2, 0], [2, 1])); + assert.isFalse(selectionService.areCoordsInSelection([1, 0], [2, 0], [2, 1])); + assert.isTrue(selectionService.areCoordsInSelection([2, 0], [2, 0], [2, 1])); + assert.isTrue(selectionService.areCoordsInSelection([10, 0], [2, 0], [2, 1])); + assert.isTrue(selectionService.areCoordsInSelection([0, 1], [2, 0], [2, 1])); + assert.isTrue(selectionService.areCoordsInSelection([1, 1], [2, 0], [2, 1])); + assert.isFalse(selectionService.areCoordsInSelection([2, 1], [2, 0], [2, 1])); + }); + }); +}); + diff --git a/src/SelectionManager.ts b/src/browser/services/SelectionService.ts similarity index 92% rename from src/SelectionManager.ts rename to src/browser/services/SelectionService.ts index 70023836..7fd821fb 100644 --- a/src/SelectionManager.ts +++ b/src/browser/services/SelectionService.ts @@ -3,16 +3,14 @@ * @license MIT */ -import { ITerminal } from './Types'; -import { ISelectionManager, ISelectionRedrawRequestEvent } from 'browser/selection/Types'; +import { ISelectionRedrawRequestEvent } from 'browser/selection/Types'; import { IBuffer } from 'common/buffer/Types'; -import { IBufferLine } from 'common/Types'; +import { IBufferLine, IDisposable } from 'common/Types'; import * as Browser from 'common/Platform'; import { SelectionModel } from 'browser/selection/SelectionModel'; import { CellData } from 'common/buffer/CellData'; -import { IDisposable } from 'xterm'; import { EventEmitter, IEvent } from 'common/EventEmitter'; -import { ICharSizeService, IMouseService } from 'browser/services/Services'; +import { ICharSizeService, IMouseService, ISelectionService } from 'browser/services/Services'; import { IBufferService, IOptionsService, ICoreService } from 'common/services/Services'; import { getCoordsRelativeToElement } from 'browser/input/Mouse'; import { moveToCellSequence } from 'browser/input/MoveToCell'; @@ -62,20 +60,20 @@ export const enum SelectionMode { /** * A class that manages the selection of the terminal. With help from - * SelectionModel, SelectionManager handles with all logic associated with + * SelectionModel, SelectionService handles with all logic associated with * dealing with the selection, including handling mouse interaction, wide * characters and fetching the actual text within the selection. Rendering is - * not handled by the SelectionManager but the onRedrawRequest event is fired + * not handled by the SelectionService but the onRedrawRequest event is fired * when the selection is ready to be redrawn (on an animation frame). */ -export class SelectionManager implements ISelectionManager { +export class SelectionService implements ISelectionService { protected _model: SelectionModel; /** * The amount to scroll every drag scroll update (depends on how far the mouse * drag is above or below the terminal). */ - private _dragScrollAmount: number; + private _dragScrollAmount: number = 0; /** * The current selection mode. @@ -86,12 +84,12 @@ export class SelectionManager implements ISelectionManager { * A setInterval timer that is active while the mouse is down whose callback * scrolls the viewport when necessary. */ - private _dragScrollIntervalTimer: NodeJS.Timer; + private _dragScrollIntervalTimer: number | undefined; /** * The animation frame ID used for refreshing the selection. */ - private _refreshAnimationFrame: number; + private _refreshAnimationFrame: number | undefined; /** * Whether selection is enabled. @@ -103,7 +101,7 @@ export class SelectionManager implements ISelectionManager { private _trimListener: IDisposable; private _workCell: CellData = new CellData(); - private _mouseDownTimeStamp: number; + private _mouseDownTimeStamp: number = 0; private _onLinuxMouseSelection = new EventEmitter(); public get onLinuxMouseSelection(): IEvent { return this._onLinuxMouseSelection.event; } @@ -113,7 +111,8 @@ export class SelectionManager implements ISelectionManager { public get onSelectionChange(): IEvent { return this._onSelectionChange.event; } constructor( - private readonly _terminal: ITerminal, + private readonly _scrollLines: (amount: number, suppressEvent: boolean) => void, + private readonly _element: HTMLElement, private readonly _screenElement: HTMLElement, private readonly _charSizeService: ICharSizeService, private readonly _bufferService: IBufferService, @@ -121,7 +120,17 @@ export class SelectionManager implements ISelectionManager { private readonly _mouseService: IMouseService, private readonly _optionsService: IOptionsService ) { - this._initListeners(); + // Init listeners + this._mouseMoveListener = event => this._onMouseMove(event); + this._mouseUpListener = event => this._onMouseUp(event); + this._coreService.onUserInput(() => { + if (this.hasSelection) { + this.clearSelection(); + } + }); + this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._onTrim(amount)); + this._bufferService.buffers.onBufferActivate(e => this._onBufferActivate(e)); + this.enable(); this._model = new SelectionModel(this._bufferService); @@ -132,23 +141,8 @@ export class SelectionManager implements ISelectionManager { this._removeMouseDownListeners(); } - /** - * Initializes listener variables. - */ - private _initListeners(): void { - this._mouseMoveListener = event => this._onMouseMove(event); - this._mouseUpListener = event => this._onMouseUp(event); - this._coreService.onUserInput(() => { - if (this.hasSelection) { - this.clearSelection(); - } - }); - this.initBuffersListeners(); - } - - public initBuffersListeners(): void { - this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._onTrim(amount)); - this._bufferService.buffers.onBufferActivate(e => this._onBufferActivate(e)); + public reset(): void { + this.clearSelection(); } /** @@ -167,8 +161,8 @@ export class SelectionManager implements ISelectionManager { this._enabled = true; } - public get selectionStart(): [number, number] { return this._model.finalSelectionStart; } - public get selectionEnd(): [number, number] { return this._model.finalSelectionEnd; } + public get selectionStart(): [number, number] | undefined { return this._model.finalSelectionStart; } + public get selectionEnd(): [number, number] | undefined { return this._model.finalSelectionEnd; } /** * Gets whether there is an active text selection. @@ -214,7 +208,7 @@ export class SelectionManager implements ISelectionManager { for (let i = start[1] + 1; i <= end[1] - 1; i++) { const bufferLine = buffer.lines.get(i); const lineText = buffer.translateBufferLineToString(i, true); - if (bufferLine.isWrapped) { + if (bufferLine!.isWrapped) { result[result.length - 1] += lineText; } else { result.push(lineText); @@ -225,7 +219,7 @@ export class SelectionManager implements ISelectionManager { if (start[1] !== end[1]) { const bufferLine = buffer.lines.get(end[1]); const lineText = buffer.translateBufferLineToString(end[1], true, 0, end[0]); - if (bufferLine.isWrapped) { + if (bufferLine!.isWrapped) { result[result.length - 1] += lineText; } else { result.push(lineText); @@ -237,7 +231,7 @@ export class SelectionManager implements ISelectionManager { // and joining the array into a multi-line string. const formattedResult = result.map(line => { return line.replace(ALL_NON_BREAKING_SPACE_REGEX, ' '); - }).join(Browser.isMSWindows ? '\r\n' : '\n'); + }).join(Browser.isWindows ? '\r\n' : '\n'); return formattedResult; } @@ -278,7 +272,7 @@ export class SelectionManager implements ISelectionManager { * selection state. */ private _refresh(): void { - this._refreshAnimationFrame = null; + this._refreshAnimationFrame = undefined; this._onRedrawRequest.fire({ start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd, @@ -295,7 +289,7 @@ export class SelectionManager implements ISelectionManager { const start = this._model.finalSelectionStart; const end = this._model.finalSelectionEnd; - if (!start || !end) { + if (!start || !end || !coords) { return false; } @@ -317,7 +311,7 @@ export class SelectionManager implements ISelectionManager { const coords = this._getMouseBufferCoords(event); if (coords) { this._selectWordAt(coords, false); - this._model.selectionEnd = null; + this._model.selectionEnd = undefined; this.refresh(true); } } @@ -356,10 +350,10 @@ export class SelectionManager implements ISelectionManager { * Gets the 0-based [x, y] buffer coordinates of the current mouse event. * @param event The mouse event. */ - private _getMouseBufferCoords(event: MouseEvent): [number, number] { + private _getMouseBufferCoords(event: MouseEvent): [number, number] | undefined { const coords = this._mouseService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true); if (!coords) { - return null; + return undefined; } // Convert to 0-based @@ -458,9 +452,11 @@ export class SelectionManager implements ISelectionManager { */ private _addMouseDownListeners(): void { // Listen on the document so that dragging outside of viewport works - this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); - this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener); - this._dragScrollIntervalTimer = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); + if (this._screenElement.ownerDocument) { + this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); + this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener); + } + this._dragScrollIntervalTimer = window.setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); } /** @@ -472,7 +468,7 @@ export class SelectionManager implements ISelectionManager { this._screenElement.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); } clearInterval(this._dragScrollIntervalTimer); - this._dragScrollIntervalTimer = null; + this._dragScrollIntervalTimer = undefined; } /** @@ -501,7 +497,7 @@ export class SelectionManager implements ISelectionManager { if (!this._model.selectionStart) { return; } - this._model.selectionEnd = null; + this._model.selectionEnd = undefined; // Ensure the line exists const line = this._bufferService.buffer.lines.get(this._model.selectionStart[1]); @@ -565,6 +561,11 @@ export class SelectionManager implements ISelectionManager { // to be sent to the pty. event.stopImmediatePropagation(); + // Something went wrong + if (!this._model.selectionStart) { + throw new Error('Selection start position was not set before mousemove event'); + } + // Record the previous position so we know whether to redraw the selection // at the end. const previousSelectionEnd = this._model.selectionEnd ? [this._model.selectionEnd[0], this._model.selectionEnd[1]] : null; @@ -606,7 +607,8 @@ export class SelectionManager implements ISelectionManager { // have a character. const buffer = this._bufferService.buffer; if (this._model.selectionEnd[1] < buffer.lines.length) { - if (buffer.lines.get(this._model.selectionEnd[1]).hasWidth(this._model.selectionEnd[0]) === 0) { + const line = buffer.lines.get(this._model.selectionEnd[1]); + if (line && line.hasWidth(this._model.selectionEnd[0]) === 0) { this._model.selectionEnd[0]++; } } @@ -624,8 +626,11 @@ export class SelectionManager implements ISelectionManager { * scrolling of the viewport. */ private _dragScroll(): void { + if (!this._model.selectionEnd || !this._model.selectionStart) { + return; + } if (this._dragScrollAmount) { - this._terminal.scrollLines(this._dragScrollAmount, false); + this._scrollLines(this._dragScrollAmount, false); // Re-evaluate selection // If the cursor was above or below the viewport, make sure it's at the // start or end of the viewport respectively. This should only happen when @@ -659,13 +664,13 @@ export class SelectionManager implements ISelectionManager { if (event.altKey) { const coordinates = this._mouseService.getCoords( event, - this._terminal.element, + this._element, this._bufferService.cols, this._bufferService.rows, false ); if (coordinates && coordinates[0] !== undefined && coordinates[1] !== undefined) { - const sequence = moveToCellSequence(coordinates[0] - 1, coordinates[1] - 1, this._bufferService, this._terminal.applicationCursor); + const sequence = moveToCellSequence(coordinates[0] - 1, coordinates[1] - 1, this._bufferService, this._coreService.decPrivateModes.applicationCursorKeys); this._coreService.triggerDataEvent(sequence, true); } } @@ -721,16 +726,16 @@ export class SelectionManager implements ISelectionManager { * Gets positional information for the word at the coordinated specified. * @param coords The coordinates to get the word at. */ - private _getWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean, followWrappedLinesAbove: boolean = true, followWrappedLinesBelow: boolean = true): IWordPosition { + private _getWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean, followWrappedLinesAbove: boolean = true, followWrappedLinesBelow: boolean = true): IWordPosition | undefined { // Ensure coords are within viewport (eg. not within scroll bar) if (coords[0] >= this._bufferService.cols) { - return null; + return undefined; } const buffer = this._bufferService.buffer; const bufferLine = buffer.lines.get(coords[1]); if (!bufferLine) { - return null; + return undefined; } const line = buffer.translateBufferLineToString(coords[1], false); @@ -837,7 +842,7 @@ export class SelectionManager implements ISelectionManager { - rightLongCharOffset); // The number of additional chars right of the initial char (inclusive) added by columns with strings longer than 1 (emojis) if (!allowWhitespaceOnlySelection && line.slice(startIndex, endIndex).trim() === '') { - return null; + return undefined; } // Recurse upwards if the line is wrapped and the word wraps to the above line diff --git a/src/browser/services/Services.d.ts b/src/browser/services/Services.d.ts index 64539b95..b22a0b37 100644 --- a/src/browser/services/Services.d.ts +++ b/src/browser/services/Services.d.ts @@ -6,6 +6,7 @@ import { IEvent } from 'common/EventEmitter'; import { IRenderDimensions, IRenderer, CharacterJoinerHandler } from 'browser/renderer/Types'; import { IColorSet } from 'browser/Types'; +import { ISelectionRedrawRequestEvent } from 'browser/selection/Types'; export interface ICharSizeService { readonly width: number; @@ -46,3 +47,28 @@ export interface IRenderService { registerCharacterJoiner(handler: CharacterJoinerHandler): number; deregisterCharacterJoiner(joinerId: number): boolean; } + +export interface ISelectionService { + readonly selectionText: string; + readonly hasSelection: boolean; + readonly selectionStart: [number, number] | undefined; + readonly selectionEnd: [number, number] | undefined; + + readonly onLinuxMouseSelection: IEvent; + readonly onRedrawRequest: IEvent; + readonly onSelectionChange: IEvent; + + disable(): void; + enable(): void; + reset(): void; + setSelection(row: number, col: number, length: number): void; + selectAll(): void; + selectLines(start: number, end: number): void; + clearSelection(): void; + isClickInSelection(event: MouseEvent): boolean; + selectWordAtCursor(event: MouseEvent): void; + shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean; + shouldForceSelection(event: MouseEvent): boolean; + refresh(isLinuxMouseSelection?: boolean): void; + onMouseDown(event: MouseEvent): void; +} diff --git a/src/common/Platform.ts b/src/common/Platform.ts index 87d466c0..aae06c74 100644 --- a/src/common/Platform.ts +++ b/src/common/Platform.ts @@ -26,7 +26,7 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test(userAgent); export const isMac = contains(['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'], platform); export const isIpad = platform === 'iPad'; export const isIphone = platform === 'iPhone'; -export const isMSWindows = contains(['Windows', 'Win16', 'Win32', 'WinCE'], platform); +export const isWindows = contains(['Windows', 'Win16', 'Win32', 'WinCE'], platform); export const isLinux = platform.indexOf('Linux') >= 0; /** diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 86098d33..e3a6c9b9 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -9,6 +9,7 @@ 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'; export class MockBufferService implements IBufferService { public get buffer(): IBuffer { return this.buffers.active; } @@ -28,8 +29,10 @@ export class MockBufferService implements IBufferService { } export class MockCoreService implements ICoreService { + decPrivateModes: IDecPrivateModes = {} as any; onData: IEvent = new EventEmitter().event; onUserInput: IEvent = new EventEmitter().event; + reset(): void {} triggerDataEvent(data: string, wasUserInput?: boolean): void {} } diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 97bd6029..320aaeb8 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -149,3 +149,7 @@ export interface IMarker extends IDisposable { readonly isDisposed: boolean; readonly line: number; } + +export interface IDecPrivateModes { + applicationCursorKeys: boolean; +} diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index bd731e8d..295b5a08 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -5,8 +5,16 @@ import { ICoreService, IOptionsService, IBufferService } from 'common/services/Services'; import { EventEmitter, IEvent } from 'common/EventEmitter'; +import { IDecPrivateModes } from 'common/Types'; +import { clone } from 'common/Clone'; + +const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ + applicationCursorKeys: false +}); export class CoreService implements ICoreService { + public decPrivateModes: IDecPrivateModes; + private _onData = new EventEmitter(); public get onData(): IEvent { return this._onData.event; } private _onUserInput = new EventEmitter(); @@ -18,6 +26,11 @@ export class CoreService implements ICoreService { private readonly _bufferService: IBufferService, private readonly _optionsService: IOptionsService ) { + this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); + } + + public reset(): void { + this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); } public triggerDataEvent(data: string, wasUserInput: boolean = false): void { diff --git a/src/common/services/Services.d.ts b/src/common/services/Services.d.ts index d9903e70..f46f3d76 100644 --- a/src/common/services/Services.d.ts +++ b/src/common/services/Services.d.ts @@ -5,6 +5,7 @@ import { IEvent } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; +import { IDecPrivateModes } from 'common/Types'; export interface IBufferService { readonly cols: number; @@ -19,9 +20,13 @@ export interface IBufferService { } export interface ICoreService { + readonly decPrivateModes: IDecPrivateModes; + readonly onData: IEvent; readonly onUserInput: IEvent; + reset(): void; + /** * Triggers the onData event in the public API. * @param data The data that is being emitted. @@ -99,6 +104,7 @@ export interface ITerminalOptions { tabStopWidth: number; theme: ITheme; windowsMode: boolean; + wordSeparator: string; [key: string]: any; cancelEvents: boolean; @@ -107,7 +113,6 @@ export interface ITerminalOptions { screenKeys: boolean; termName: string; useFlowControl: boolean; - wordSeparator?: string; } export interface ITheme {