mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
Merge pull request #1017 from Tyriar/1015_fix_emoji_selection
Fix several issues related to emojis
This commit is contained in:
+25
-8
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 => <CharData>[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', () => {
|
||||
|
||||
+66
-12
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user