diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index 4ae0c08f..20e1ca60 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -183,6 +183,42 @@ describe('SelectionManager', () => { 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([78, 0]); + assert.equal(selectionManager.selectionText, 'foobar'); + }); + it('should expand both upwards and downwards for word wrapped over many lines', () => { + const expectedText = 'fooaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccbar'; + buffer.lines.set(0, stringToRow(' foo')); + buffer.lines.set(1, stringToRow('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')); + buffer.lines.set(2, stringToRow('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')); + buffer.lines.set(3, stringToRow('cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc')); + 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([78, 0]); + assert.equal(selectionManager.selectionText, expectedText); + selectionManager.model.clearSelection(); + selectionManager.selectWordAt([40, 1]); + assert.equal(selectionManager.selectionText, expectedText); + selectionManager.model.clearSelection(); + selectionManager.selectWordAt([40, 2]); + assert.equal(selectionManager.selectionText, expectedText); + selectionManager.model.clearSelection(); + selectionManager.selectWordAt([40, 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 diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 1de14e70..3731eb9a 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -9,7 +9,7 @@ import * as Browser from './shared/utils/Browser'; import { CharMeasure } from './ui/CharMeasure'; import { EventEmitter } from './EventEmitter'; import { SelectionModel } from './SelectionModel'; -import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; +import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_CODE_INDEX } from './Buffer'; import { AltClickHandler } from './handlers/AltClickHandler'; /** @@ -451,8 +451,10 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * Removes the listeners that are registered when mousedown is triggered. */ private _removeMouseDownListeners(): void { - this._terminal.element.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); - this._terminal.element.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); + if (this._terminal.element.ownerDocument) { + this._terminal.element.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); + this._terminal.element.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); + } clearInterval(this._dragScrollIntervalTimer); this._dragScrollIntervalTimer = null; } @@ -677,7 +679,7 @@ export class SelectionManager extends EventEmitter 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): IWordPosition { + private _getWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean, followWrappedLinesAbove: boolean = true, followWrappedLinesBelow: boolean = true): IWordPosition { // Ensure coords are within viewport (eg. not within scroll bar) if (coords[0] >= this._terminal.cols) { return null; @@ -772,7 +774,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // Calculate the start _column_, converting the the string indexes back to // column coordinates. - const start = + let start = startIndex // The index of the selection's start char in the line string + charOffset // The difference between the initial char's column and index - leftWideCharCount // The number of wide chars left of the initial char @@ -780,7 +782,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // Calculate the length in _columns_, converting the the string indexes back // to column coordinates. - const length = Math.min(this._terminal.cols, // Disallow lengths larger than the terminal cols + let length = Math.min(this._terminal.cols, // Disallow lengths larger than the terminal cols endIndex // The index of the selection's end char in the line string - startIndex // The index of the selection's start char in the line string + leftWideCharCount // The number of wide chars left of the initial char @@ -792,6 +794,34 @@ export class SelectionManager extends EventEmitter implements ISelectionManager return null; } + // Recurse upwards if the line is wrapped and the word wraps to the above line + if (followWrappedLinesAbove) { + if (start === 0 && bufferLine[0][CHAR_DATA_CODE_INDEX] !== 32 /*' '*/) { + const previousBufferLine = this._buffer.lines.get(coords[1] - 1); + if (previousBufferLine && (bufferLine).isWrapped && previousBufferLine[this._terminal.cols - 1][CHAR_DATA_CODE_INDEX] !== 32 /*' '*/) { + const previousLineWordPosition = this._getWordAt([this._terminal.cols - 1, coords[1] - 1], false, true, false); + if (previousLineWordPosition) { + const offset = this._terminal.cols - previousLineWordPosition.start; + start -= offset; + length += offset; + } + } + } + } + + // Recurse downwards if the line is wrapped and the word wraps to the next line + if (followWrappedLinesBelow) { + if (start + length === this._terminal.cols && bufferLine[this._terminal.cols - 1][CHAR_DATA_CODE_INDEX] !== 32 /*' '*/) { + const nextBufferLine = this._buffer.lines.get(coords[1] + 1); + if (nextBufferLine && (nextBufferLine).isWrapped && nextBufferLine[0][CHAR_DATA_CODE_INDEX] !== 32 /*' '*/) { + const nextLineWordPosition = this._getWordAt([0, coords[1] + 1], false, false, true); + if (nextLineWordPosition) { + length += nextLineWordPosition.length; + } + } + } + } + return { start, length }; } @@ -803,6 +833,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager protected _selectWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean): void { const wordPosition = this._getWordAt(coords, allowWhitespaceOnlySelection); if (wordPosition) { + // Adjust negative start value + while (wordPosition.start < 0) { + wordPosition.start += this._terminal.cols; + coords[1]--; + } this._model.selectionStart = [wordPosition.start, coords[1]]; this._model.selectionStartLength = wordPosition.length; } @@ -815,7 +850,24 @@ export class SelectionManager extends EventEmitter implements ISelectionManager private _selectToWordAt(coords: [number, number]): void { const wordPosition = this._getWordAt(coords, true); if (wordPosition) { - this._model.selectionEnd = [this._model.areSelectionValuesReversed() ? wordPosition.start : (wordPosition.start + wordPosition.length), coords[1]]; + let endRow = coords[1]; + + // Adjust negative start value + while (wordPosition.start < 0) { + wordPosition.start += this._terminal.cols; + endRow--; + } + + // Adjust wrapped length value, this only needs to happen when values are reversed as in that + // case we're interested in the start of the word, not the end + if (!this._model.areSelectionValuesReversed()) { + while (wordPosition.start + wordPosition.length > this._terminal.cols) { + wordPosition.length -= this._terminal.cols; + endRow++; + } + } + + this._model.selectionEnd = [this._model.areSelectionValuesReversed() ? wordPosition.start : wordPosition.start + wordPosition.length, endRow]; } } diff --git a/src/SelectionModel.test.ts b/src/SelectionModel.test.ts index 85486bab..59b2ce75 100644 --- a/src/SelectionModel.test.ts +++ b/src/SelectionModel.test.ts @@ -128,6 +128,11 @@ describe('SelectionManager', () => { model.selectionEnd = [3, 2]; assert.deepEqual(model.finalSelectionEnd, [4, 2]); }); + it('should return the end on a different row when start + length overflows onto a following row', () => { + model.selectionStart = [78, 2]; + model.selectionStartLength = 4; + assert.deepEqual(model.finalSelectionEnd, [2, 3]); + }); it('should return selection end if selection end is after selection start + length', () => { model.selectionStart = [2, 2]; model.selectionStartLength = 2; diff --git a/src/SelectionModel.ts b/src/SelectionModel.ts index a9a3c89e..f87667f2 100644 --- a/src/SelectionModel.ts +++ b/src/SelectionModel.ts @@ -76,9 +76,13 @@ export class SelectionModel { return null; } - // Use the selection start if the end doesn't exist or they're reversed + // Use the selection start + length if the end doesn't exist or they're reversed if (!this.selectionEnd || this.areSelectionValuesReversed()) { - return [this.selectionStart[0] + this.selectionStartLength, this.selectionStart[1]]; + const startPlusLength = this.selectionStart[0] + this.selectionStartLength; + if (startPlusLength > this._terminal.cols) { + return [startPlusLength % this._terminal.cols, this.selectionStart[1] + Math.floor(startPlusLength / this._terminal.cols)]; + } + return [startPlusLength, this.selectionStart[1]]; } // Ensure the the word/line is selected after a double/triple click