Skip to content

Commit c22b3a9

Browse files
authored
Merge pull request #3236 from gera2ld/master
Search Addon: Fix length calculation of wide unicode chars
2 parents ae6f575 + f685360 commit c22b3a9

File tree

2 files changed

+135
-54
lines changed

2 files changed

+135
-54
lines changed

addons/xterm-addon-search/src/SearchAddon.ts

Lines changed: 118 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @license MIT
44
*/
55

6-
import { Terminal, IDisposable, ITerminalAddon, ISelectionPosition } from 'xterm';
6+
import { Terminal, IBufferLine, IDisposable, ITerminalAddon, ISelectionPosition } from 'xterm';
77

88
export interface ISearchOptions {
99
regex?: boolean;
@@ -21,8 +21,20 @@ export interface ISearchResult {
2121
term: string;
2222
col: number;
2323
row: number;
24+
size: number;
2425
}
2526

27+
type LineCacheEntry = [
28+
/**
29+
* The string representation of a line (as opposed to the buffer cell representation).
30+
*/
31+
lineAsString: string,
32+
/**
33+
* The offsets where each line starts when the entry describes a wrapped line.
34+
*/
35+
lineOffsets: number[]
36+
];
37+
2638
const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?';
2739
const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
2840

@@ -34,7 +46,7 @@ export class SearchAddon implements ITerminalAddon {
3446
* We memoize the calls into an array that has a time based ttl.
3547
* _linesCache is also invalidated when the terminal cursor moves.
3648
*/
37-
private _linesCache: string[] | undefined;
49+
private _linesCache: LineCacheEntry[] | undefined;
3850
private _linesCacheTimeoutId = 0;
3951
private _cursorMoveListener: IDisposable | undefined;
4052
private _resizeListener: IDisposable | undefined;
@@ -257,7 +269,7 @@ export class SearchAddon implements ITerminalAddon {
257269
*/
258270
protected _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
259271
const terminal = this._terminal!;
260-
let row = searchPosition.startRow;
272+
const row = searchPosition.startRow;
261273
const col = searchPosition.startCol;
262274

263275
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
@@ -274,14 +286,16 @@ export class SearchAddon implements ITerminalAddon {
274286
searchPosition.startCol += terminal.cols;
275287
return this._findInLine(term, searchPosition, searchOptions);
276288
}
277-
let stringLine = this._linesCache ? this._linesCache[row] : void 0;
278-
if (stringLine === void 0) {
279-
stringLine = this._translateBufferLineToStringWithWrap(row, true);
289+
let cache = this._linesCache?.[row];
290+
if (!cache) {
291+
cache = this._translateBufferLineToStringWithWrap(row, true);
280292
if (this._linesCache) {
281-
this._linesCache[row] = stringLine;
293+
this._linesCache[row] = cache;
282294
}
283295
}
296+
const [stringLine, offsets] = cache;
284297

298+
const offset = this._bufferColsToStringOffset(row, col);
285299
const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
286300
const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
287301

@@ -290,68 +304,108 @@ export class SearchAddon implements ITerminalAddon {
290304
const searchRegex = RegExp(searchTerm, 'g');
291305
let foundTerm: RegExpExecArray | null;
292306
if (isReverseSearch) {
293-
// This loop will get the resultIndex of the _last_ regex match in the range 0..col
294-
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, col))) {
307+
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
308+
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
295309
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
296310
term = foundTerm[0];
297311
searchRegex.lastIndex -= (term.length - 1);
298312
}
299313
} else {
300-
foundTerm = searchRegex.exec(searchStringLine.slice(col));
314+
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
301315
if (foundTerm && foundTerm[0].length > 0) {
302-
resultIndex = col + (searchRegex.lastIndex - foundTerm[0].length);
316+
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
303317
term = foundTerm[0];
304318
}
305319
}
306320
} else {
307321
if (isReverseSearch) {
308-
if (col - searchTerm.length >= 0) {
309-
resultIndex = searchStringLine.lastIndexOf(searchTerm, col - searchTerm.length);
322+
if (offset - searchTerm.length >= 0) {
323+
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
310324
}
311325
} else {
312-
resultIndex = searchStringLine.indexOf(searchTerm, col);
326+
resultIndex = searchStringLine.indexOf(searchTerm, offset);
313327
}
314328
}
315329

316330
if (resultIndex >= 0) {
317-
// Adjust the row number and search index if needed since a "line" of text can span multiple rows
318-
if (resultIndex >= terminal.cols) {
319-
row += Math.floor(resultIndex / terminal.cols);
320-
resultIndex = resultIndex % terminal.cols;
321-
}
322331
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
323332
return;
324333
}
325334

326-
const line = terminal.buffer.active.getLine(row);
327-
328-
if (line) {
329-
for (let i = 0; i < resultIndex; i++) {
330-
const cell = line.getCell(i);
331-
if (!cell) {
332-
break;
333-
}
334-
// Adjust the searchIndex to normalize emoji into single chars
335-
const char = cell.getChars();
336-
if (char.length > 1) {
337-
resultIndex -= char.length - 1;
338-
}
339-
// Adjust the searchIndex for empty characters following wide unicode
340-
// chars (eg. CJK)
341-
const charWidth = cell.getWidth();
342-
if (charWidth === 0) {
343-
resultIndex++;
344-
}
345-
}
335+
// Adjust the row number and search index if needed since a "line" of text can span multiple rows
336+
let startRowOffset = 0;
337+
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
338+
startRowOffset++;
346339
}
340+
let endRowOffset = startRowOffset;
341+
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
342+
endRowOffset++;
343+
}
344+
const startColOffset = resultIndex - offsets[startRowOffset];
345+
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
346+
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
347+
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
348+
const size = endColIndex - startColIndex + terminal.cols * (endRowOffset - startRowOffset);
349+
347350
return {
348351
term,
349-
col: resultIndex,
350-
row
352+
col: startColIndex,
353+
row: row + startRowOffset,
354+
size
351355
};
352356
}
353357
}
354358

359+
private _stringLengthToBufferSize(row: number, offset: number): number {
360+
const line = this._terminal!.buffer.active.getLine(row);
361+
if (!line) {
362+
return 0;
363+
}
364+
for (let i = 0; i < offset; i++) {
365+
const cell = line.getCell(i);
366+
if (!cell) {
367+
break;
368+
}
369+
// Adjust the searchIndex to normalize emoji into single chars
370+
const char = cell.getChars();
371+
if (char.length > 1) {
372+
offset -= char.length - 1;
373+
}
374+
// Adjust the searchIndex for empty characters following wide unicode
375+
// chars (eg. CJK)
376+
const nextCell = line.getCell(i + 1);
377+
if (nextCell && nextCell.getWidth() === 0) {
378+
offset++;
379+
}
380+
}
381+
return offset;
382+
}
383+
384+
private _bufferColsToStringOffset(startRow: number, cols: number): number {
385+
const terminal = this._terminal!;
386+
let lineIndex = startRow;
387+
let offset = 0;
388+
let line = terminal.buffer.active.getLine(lineIndex);
389+
while (cols > 0 && line) {
390+
for (let i = 0; i < cols && i < terminal.cols; i++) {
391+
const cell = line.getCell(i);
392+
if (!cell) {
393+
break;
394+
}
395+
if (cell.getWidth()) {
396+
offset += cell.getChars().length;
397+
}
398+
}
399+
lineIndex++;
400+
line = terminal.buffer.active.getLine(lineIndex);
401+
if (line && !line.isWrapped) {
402+
break;
403+
}
404+
cols -= terminal.cols;
405+
}
406+
return offset;
407+
}
408+
355409
/**
356410
* Translates a buffer line to a string, including subsequent lines if they are wraps.
357411
* Wide characters will count as two columns in the resulting string. This
@@ -360,23 +414,33 @@ export class SearchAddon implements ITerminalAddon {
360414
* @param line The line being translated.
361415
* @param trimRight Whether to trim whitespace to the right.
362416
*/
363-
private _translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): string {
417+
private _translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
364418
const terminal = this._terminal!;
365-
let lineString = '';
366-
let lineWrapsToNext: boolean;
367-
368-
do {
419+
const strings = [];
420+
const lineOffsets = [0];
421+
let line = terminal.buffer.active.getLine(lineIndex);
422+
while (line) {
369423
const nextLine = terminal.buffer.active.getLine(lineIndex + 1);
370-
lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
371-
const line = terminal.buffer.active.getLine(lineIndex);
372-
if (!line) {
424+
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
425+
let string = line.translateToString(!lineWrapsToNext && trimRight);
426+
if (lineWrapsToNext && nextLine) {
427+
const lastCell = line.getCell(line.length - 1);
428+
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
429+
// a wide character wrapped to the next line
430+
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
431+
string = string.slice(0, -1);
432+
}
433+
}
434+
strings.push(string);
435+
if (lineWrapsToNext) {
436+
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
437+
} else {
373438
break;
374439
}
375-
lineString += line.translateToString(!lineWrapsToNext && trimRight).substring(0, terminal.cols);
376440
lineIndex++;
377-
} while (lineWrapsToNext);
378-
379-
return lineString;
441+
line = nextLine;
442+
}
443+
return [strings.join(''), lineOffsets];
380444
}
381445

382446
/**
@@ -390,7 +454,7 @@ export class SearchAddon implements ITerminalAddon {
390454
terminal.clearSelection();
391455
return false;
392456
}
393-
terminal.select(result.col, result.row, result.term.length);
457+
terminal.select(result.col, result.row, result.size);
394458
// If it is not in the viewport then we scroll else it just gets selected
395459
if (result.row >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
396460
let scroll = result.row - terminal.buffer.active.viewportY;

addons/xterm-addon-search/test/SearchAddon.api.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ describe('Search Tests', function(): void {
103103
assert.deepEqual(await page.evaluate(`window.term.getSelection()`), 'abc');
104104
});
105105

106+
it('Search for result bounding with wide unicode chars', async () => {
107+
await writeSync(page, '中文xx𝄞𝄞');
108+
assert.deepEqual(await page.evaluate(`window.search.findNext('中')`), true);
109+
assert.deepEqual(await page.evaluate(`window.term.getSelection()`), '中');
110+
assert.deepEqual(await page.evaluate(`window.search.findNext('xx')`), true);
111+
assert.deepEqual(await page.evaluate(`window.term.getSelection()`), 'xx');
112+
assert.deepEqual(await page.evaluate(`window.search.findNext('𝄞')`), true);
113+
assert.deepEqual(await page.evaluate(`window.term.getSelection()`), '𝄞');
114+
assert.deepEqual(await page.evaluate(`window.search.findNext('𝄞')`), true);
115+
assert.deepEqual(await page.evaluate(`window.term.getSelectionPosition()`), {
116+
startRow: 0,
117+
endRow: 0,
118+
startColumn: 7,
119+
endColumn: 8
120+
});
121+
});
122+
106123
describe('Regression tests', () => {
107124
describe('#2444 wrapped line content not being found', () => {
108125
let fixture: string;

0 commit comments

Comments
 (0)