diff --git a/src/Buffer.ts b/src/Buffer.ts index 548fba71..8922c3d6 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -208,12 +208,19 @@ export class Buffer implements IBuffer { public translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol: number = 0, endCol: number = null): string { // Get full line let lineString = ''; - let widthAdjustedStartCol = startCol; - let widthAdjustedEndCol = endCol; const line = this.lines.get(lineIndex); if (!line) { return ''; } + + // Initialize column and index values. Column values represent the actual + // cell column, indexes represent the index in the string. Indexes are + // needed here because some chars are 0 characters long (eg. after wide + // chars) and some chars are longer than 1 characters long (eg. emojis). + let startIndex = startCol; + endCol = endCol || line.length; + let endIndex = endCol; + for (let i = 0; i < line.length; i++) { const char = line[i]; lineString += char[CHAR_DATA_CHAR_INDEX]; @@ -221,29 +228,39 @@ export class Buffer implements IBuffer { // column indexes if (char[CHAR_DATA_WIDTH_INDEX] === 0) { if (startCol >= i) { - widthAdjustedStartCol--; + startIndex--; } if (endCol >= i) { - widthAdjustedEndCol--; + endIndex--; + } + } else { + // Adjust the columns to take glyphs that are represented by multiple + // code points into account. + if (char[CHAR_DATA_CHAR_INDEX].length > 1) { + if (startCol > i) { + startIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; + } + if (endCol > i) { + endIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; + } } } } // Calculate the final end col by trimming whitespace on the right of the // line if needed. - let finalEndCol = widthAdjustedEndCol || line.length; if (trimRight) { const rightWhitespaceIndex = lineString.search(/\s+$/); if (rightWhitespaceIndex !== -1) { - finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex); + endIndex = Math.min(endIndex, rightWhitespaceIndex); } // Return the empty string if only trimmed whitespace is selected - if (finalEndCol <= widthAdjustedStartCol) { + if (endIndex <= startIndex) { return ''; } } - return lineString.substring(widthAdjustedStartCol, finalEndCol); + return lineString.substring(startIndex, endIndex); } /** diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index e5bdc117..8c6ea3e1 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -12,7 +12,7 @@ import { SelectionManager } from './SelectionManager'; import { SelectionModel } from './SelectionModel'; import { BufferSet } from './BufferSet'; import { MockTerminal } from './utils/TestUtils.test'; -import { LineData } from './Types'; +import { LineData, CharData } from './Types'; class TestSelectionManager extends SelectionManager { constructor( @@ -66,6 +66,10 @@ describe('SelectionManager', () => { return result; } + function stringArrayToRow(chars: string[]): LineData { + return chars.map(c => [0, c, 1, c.charCodeAt(0)]); + } + describe('_selectWordAt', () => { it('should expand selection for normal width chars', () => { buffer.lines.set(0, stringToRow('foo bar')); @@ -185,6 +189,113 @@ describe('SelectionManager', () => { selectionManager.selectWordAt([15, 0]); assert.equal(selectionManager.selectionText, 'ij"'); }); + 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', () => { diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 23fe729d..5dd652e0 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -10,8 +10,8 @@ import { CircularList } from './utils/CircularList'; import { EventEmitter } from './EventEmitter'; import { ITerminal, ICircularList, ISelectionManager, IBuffer } from './Interfaces'; import { SelectionModel } from './SelectionModel'; -import { LineData } from './Types'; -import { CHAR_DATA_WIDTH_INDEX } from './Buffer'; +import { LineData, CharData } from './Types'; +import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; /** * The number of pixels the mouse needs to be above or below the viewport in @@ -281,6 +281,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // Convert to 0-based coords[0]--; coords[1]--; + // Convert viewport coords to buffer coords coords[1] += this._terminal.buffer.ydisp; return coords; @@ -545,7 +546,14 @@ export class SelectionManager extends EventEmitter implements ISelectionManager for (let i = 0; coords[0] >= i; i++) { const char = bufferLine[i]; if (char[CHAR_DATA_WIDTH_INDEX] === 0) { + // Wide characters aren't included in the line string so decrement the + // index so the index is back on the wide character. charIndex--; + } else if (char[CHAR_DATA_CHAR_INDEX].length > 1 && coords[0] !== i) { + // Emojis take up multiple characters, so adjust accordingly. For these + // we don't want ot include the character at the column as we're + // returning the start index in the string, not the end index. + charIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; } } return charIndex; @@ -572,13 +580,15 @@ export class SelectionManager extends EventEmitter implements ISelectionManager const line = this._buffer.translateBufferLineToString(coords[1], false); // Get actual index, taking into consideration wide characters - let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); - let startIndex = endIndex; + let startIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); + let endIndex = startIndex; // Record offset to be used later const charOffset = coords[0] - startIndex; let leftWideCharCount = 0; let rightWideCharCount = 0; + let leftLongCharOffset = 0; + let rightLongCharOffset = 0; if (line.charAt(startIndex) === ' ') { // Expand until non-whitespace is hit @@ -595,6 +605,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // character is hit, it is recorded and the column index is adjusted. let startCol = coords[0]; let endCol = coords[0]; + // Consider the initial position, skip it and increment the wide char // variable if (bufferLine[startCol][CHAR_DATA_WIDTH_INDEX] === 0) { @@ -605,29 +616,67 @@ export class SelectionManager extends EventEmitter implements ISelectionManager rightWideCharCount++; endCol++; } + + // Adjust the end index for characters whose length are > 1 (emojis) + if (bufferLine[endCol][CHAR_DATA_CHAR_INDEX].length > 1) { + rightLongCharOffset += bufferLine[endCol][CHAR_DATA_CHAR_INDEX].length - 1; + endIndex += bufferLine[endCol][CHAR_DATA_CHAR_INDEX].length - 1; + } + // Expand the string in both directions until a space is hit - while (startIndex > 0 && !this._isCharWordSeparator(line.charAt(startIndex - 1))) { - if (bufferLine[startCol - 1][CHAR_DATA_WIDTH_INDEX] === 0) { + while (startCol > 0 && startIndex > 0 && !this._isCharWordSeparator(bufferLine[startCol - 1])) { + const char = bufferLine[startCol - 1]; + if (char[CHAR_DATA_WIDTH_INDEX] === 0) { // If the next character is a wide char, record it and skip the column leftWideCharCount++; startCol--; + } else if (char[CHAR_DATA_CHAR_INDEX].length > 1) { + // If the next character's string is longer than 1 char (eg. emoji), + // adjust the index + leftLongCharOffset += char[CHAR_DATA_CHAR_INDEX].length - 1; + startIndex -= char[CHAR_DATA_CHAR_INDEX].length - 1; } startIndex--; startCol--; } - while (endIndex + 1 < line.length && !this._isCharWordSeparator(line.charAt(endIndex + 1))) { - if (bufferLine[endCol + 1][CHAR_DATA_WIDTH_INDEX] === 2) { + while (endCol < bufferLine.length && endIndex + 1 < line.length && !this._isCharWordSeparator(bufferLine[endCol + 1])) { + const char = bufferLine[endCol + 1]; + if (char[CHAR_DATA_WIDTH_INDEX] === 2) { // If the next character is a wide char, record it and skip the column rightWideCharCount++; endCol++; + } else if (char[CHAR_DATA_CHAR_INDEX].length > 1) { + // If the next character's string is longer than 1 char (eg. emoji), + // adjust the index + rightLongCharOffset += char[CHAR_DATA_CHAR_INDEX].length - 1; + endIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; } endIndex++; endCol++; } } - const start = startIndex + charOffset - leftWideCharCount; - const length = Math.min(endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/, this._terminal.cols); + // Incremenet the end index so it is at the start of the next character + endIndex++; + + // Calculate the start _column_, converting the the string indexes back to + // column coordinates. + const 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 + + leftLongCharOffset; // The number of additional chars left of the initial char added by columns with strings longer than 1 (emojis) + + // 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 + 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 + + rightWideCharCount // The number of wide chars right of the initial char (inclusive) + - leftLongCharOffset // The number of additional chars left of the initial char added by columns with strings longer than 1 (emojis) + - rightLongCharOffset); // The number of additional chars right of the initial char (inclusive) added by columns with strings longer than 1 (emojis) + return { start, length }; } @@ -659,8 +708,13 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * word logic. * @param char The character to check. */ - private _isCharWordSeparator(char: string): boolean { - return WORD_SEPARATORS.indexOf(char) >= 0; + private _isCharWordSeparator(charData: CharData): boolean { + // Zero width characters are never separators as they are always to the + // right of wide characters + if (charData[CHAR_DATA_WIDTH_INDEX] === 0) { + return false; + } + return WORD_SEPARATORS.indexOf(charData[CHAR_DATA_CHAR_INDEX]) >= 0; } /** diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index 75c1dae8..996e1728 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -193,10 +193,15 @@ export class TextRenderLayer extends BaseRenderLayer { } /** - * Whether a character is overlapping to the - * next cell. + * Whether a character is overlapping to the next cell. */ private _isOverlapping(charData: CharData): boolean { + // Only single cell characters can be overlapping, rendering issues can + // occur without this check + if (charData[CHAR_DATA_WIDTH_INDEX] !== 1) { + return false; + } + // We assume that any ascii character will not overlap const code = charData[CHAR_DATA_CODE_INDEX]; if (code < 256) {