From f961f90bc83ab249529f4187ecb250168176bfbf Mon Sep 17 00:00:00 2001 From: ntchjb Date: Tue, 1 Jan 2019 16:39:23 +0700 Subject: [PATCH] Support wide characters and combined characters - Convert buffer index to string index before searching - Convert string index to buffer index after searching - Fix bug when search selection skipped some result --- src/addons/search/SearchHelper.ts | 214 +++++++++++++++++++++++------- 1 file changed, 164 insertions(+), 50 deletions(-) diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index 7200539b..69fd919f 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -7,6 +7,8 @@ import { ISearchHelper, ISearchAddonTerminal, ISearchOptions, ISearchResult } fr const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?'; const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs +const CHAR_DATA_CHAR_INDEX = 1; +const CHAR_DATA_WIDTH_INDEX = 2; /** * A class that knows how to search the terminal and how to display the results. @@ -55,28 +57,25 @@ export class SearchHelper implements ISearchHelper { this._initLinesCache(); - // Search startRow - result = this._findInLine(term, startRow, startCol, searchOptions); + // The row that has isWrapped = false + let findingRow = startRow; + // index of beginning column that _findInLine need to scan. + let cumulativeCols = startCol; + // If startRow is wrapped row, scan for unwrapped row above. + // So we can start matching on wrapped line from long unwrapped line. + while (this._terminal._core.buffer.lines.get(findingRow).isWrapped) { + findingRow--; + cumulativeCols += this._terminal.cols; + } - // Search from startRow + 1 to end + // Search unwarpped row + result = this._findInLine(term, findingRow, cumulativeCols, searchOptions); + + // Search from startRow + 1 to end, if the row is still wrapped line, increase cumulativeCols, + // otherwise, reset it and set the new unwrapped line index. if (!result) { - // A row that has isWrapped = false - let findingRow = startRow; - // index of beginning column that _findInLine need to scan. - let cumulativeCols = startCol; - // If startRow is wrapped row, scan for unwrapped row above. - // So we can start matching on wrapped line from long unwrapped line. - while (this._terminal._core.buffer.lines.get(findingRow).isWrapped) { - findingRow--; - cumulativeCols += this._terminal.cols; - } for (let y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) { - // Run _findInLine at unwrapped row, scan for cumulativeCols columns - result = this._findInLine(term, findingRow, cumulativeCols, searchOptions); - if (result) { - break; - } // If the current line is wrapped line, increase index of column to ignore the previous scan // Otherwise, reset beginning column index to zero with set new unwrapped line index if (this._terminal._core.buffer.lines.get(y).isWrapped) { @@ -85,6 +84,11 @@ export class SearchHelper implements ISearchHelper { cumulativeCols = 0; findingRow = y; } + // Run _findInLine at unwrapped row, start scan at cumulativeCols column index + result = this._findInLine(term, findingRow, cumulativeCols, searchOptions); + if (result) { + break; + } } } @@ -226,11 +230,129 @@ export class SearchHelper implements ISearchHelper { (((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex + term.length]) !== -1))); } + /** + * Translates a string index back to a BufferIndex. + * To get the correct buffer position the string must start at `startCol` 0 + * (default in translateBufferLineToString). + * This method is similar to stringIndexToBufferIndex in Buffer.ts + * but this method added some modification that, if it found an empty cell, + * the method will see it as a whitespace and count it as a character. + * The modification is added because the given string index may include + * empty cells inside the string, which is needed to be counted. + * The return value of this method is the same as BufferIndex + * @param lineIndex line index the string was retrieved from + * @param stringIndex index within the string + * @param startCol column offset the string was retrieved from + */ + private _stringIndexToBufferIndex(lineIndex: number, stringIndex: number): [number, number] { + while (stringIndex) { + const line = this._terminal._core.buffer.lines.get(lineIndex); + if (!line) { + return [-1, -1]; + } + for (let i = 0; i < this._terminal.cols; ++i) { + const charData = line.get(i); + const char = charData[CHAR_DATA_CHAR_INDEX]; + // If found empty cell with width equals to 1, see it as whitespace + if (charData[CHAR_DATA_CHAR_INDEX] === '' && charData[CHAR_DATA_WIDTH_INDEX] > 0) { + stringIndex--; + } + stringIndex -= char.length; + if (stringIndex < 0) { + return [lineIndex, i]; + } + } + lineIndex++; + } + return [lineIndex, 0]; + } + + /** + * Convert buffer index of unwrapped row to string index. + * @param lineIndex index of terminal row that is unwrapped + * @param bufferIndex index of terminal column on unwrapped row + */ + private _bufferIndexToStringIndex(lineIndex: number, bufferIndex: number): number { + let stringIndex = -1; + const buffer = this._terminal._core.buffer; + while (bufferIndex >= 0) { + const line = buffer.lines.get(lineIndex); + // Exceed index of bottom row, returned + if (!line) { + break; + } + + let lineLength = this._terminal.cols; + + // At the last line, lineLength will be trimmed to remove trailing empty cells + if (bufferIndex < lineLength) { + // Add 1 to getTrimmedLength because if providing bufferIndex is larger than + // converted string length, the result should be `string length`, not `string length - 1` + // to make sure that searching range includes the last character in the string + lineLength = line.getTrimmedLength() + 1; + } + + for (let i = 0; i < lineLength; i++) { + const cell = line.get(i); + + // Count number of characters from current buffer column in each cell. + stringIndex += cell[CHAR_DATA_CHAR_INDEX].length; + bufferIndex--; + + // If found empty cell, act like found whitespace + if (cell[CHAR_DATA_CHAR_INDEX] === '' && cell[CHAR_DATA_WIDTH_INDEX] > 0) { + stringIndex++; + } + + if (bufferIndex < 0) { + return stringIndex; + } + } + lineIndex++; + } + return stringIndex; + } + + /** + * Get buffer length (number of cells) from provided string + * @param result The search result object including term, row, and col + */ + private _getCellLengthFromString(result: ISearchResult): number { + const length = result.term.length; + + let strCount = 0; + let cellCount = 0; + let { col, row } = result; + let rowContent = this._terminal._core.buffer.lines.get(row); + + // Count cells along with characters until the number of characters + // exceeds string length of search result. + while (strCount <= length) { + strCount += rowContent.get(col)[CHAR_DATA_CHAR_INDEX].length; + cellCount += rowContent.get(col)[CHAR_DATA_WIDTH_INDEX]; + if (strCount >= length) { + break; + } + col++; + + // In case that current cell exceed total number of cells in a row + // Begin col at 0 on the next line + if (col >= this._terminal.cols) { + col = 0; + rowContent = this._terminal._core.buffer.lines.get(++row); + } + } + return cellCount; + } /** * Searches a line for a search term. Takes the provided terminal line and searches the text line, which may contain * subsequent terminal lines if the text is wrapped. If the provided line number is part of a wrapped text line that * started on an earlier line then it is skipped since it will be properly searched when the terminal line that the * text starts on is searched. + * + * The concept of searching is that: + * Get unwarpped line as string => convert rowand col to string index => begin searching to get search result as + * string index => convert back to buffer index (col, row) => return the result. * @param term The search term. * @param row The line to start the search from. * @param col The column to start the search from. @@ -243,6 +365,8 @@ export class SearchHelper implements ISearchHelper { if (this._terminal._core.buffer.lines.get(row).isWrapped) { return; } + + // Get unwrapped string from buffer lines let stringLine = this._linesCache ? this._linesCache[row] : void 0; if (stringLine === void 0) { stringLine = this.translateBufferLineToStringWithWrap(row, true); @@ -251,67 +375,54 @@ export class SearchHelper implements ISearchHelper { } } + // Check for case sensitive option const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); let resultIndex = -1; + // Convert from buffer index (col, row) to string index before begin searching + const stringIndex = this._bufferIndexToStringIndex(row, col); + if (searchOptions.regex) { const searchRegex = RegExp(searchTerm, 'g'); let foundTerm: RegExpExecArray; if (isReverseSearch) { // This loop will get the resultIndex of the _last_ regex match in the range 0..col - while (foundTerm = searchRegex.exec(searchStringLine.slice(0, col))) { + while (foundTerm = searchRegex.exec(searchStringLine.slice(0, stringIndex))) { resultIndex = searchRegex.lastIndex - foundTerm[0].length; term = foundTerm[0]; searchRegex.lastIndex -= (term.length - 1); } } else { - foundTerm = searchRegex.exec(searchStringLine.slice(col)); + foundTerm = searchRegex.exec(searchStringLine.slice(stringIndex)); if (foundTerm && foundTerm[0].length > 0) { - resultIndex = col + (searchRegex.lastIndex - foundTerm[0].length); + resultIndex = stringIndex + (searchRegex.lastIndex - foundTerm[0].length); term = foundTerm[0]; } } } else { if (isReverseSearch) { - if (col - searchTerm.length >= 0) { - resultIndex = searchStringLine.lastIndexOf(searchTerm, col - searchTerm.length); + if (stringIndex - searchTerm.length >= 0) { + resultIndex = searchStringLine.lastIndexOf(searchTerm, stringIndex - searchTerm.length); } } else { - resultIndex = searchStringLine.indexOf(searchTerm, col); + resultIndex = searchStringLine.indexOf(searchTerm, stringIndex); } } if (resultIndex >= 0) { - // Adjust the row number and search index if needed since a "line" of text can span multiple rows - if (resultIndex >= this._terminal.cols) { - row += Math.floor(resultIndex / this._terminal.cols); - resultIndex = resultIndex % this._terminal.cols; - } + // After getting the result as string index, convert it to buffer index. + const resultBufferIndex = this._stringIndexToBufferIndex(row, resultIndex); + + // Check for wholeword option if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { return; } - const line = this._terminal._core.buffer.lines.get(row); - - for (let i = 0; i < resultIndex; i++) { - const charData = line.get(i); - // Adjust the searchIndex to normalize emoji into single chars - const char = charData[1/*CHAR_DATA_CHAR_INDEX*/]; - if (char.length > 1) { - resultIndex -= char.length - 1; - } - // Adjust the searchIndex for empty characters following wide unicode - // chars (eg. CJK) - const charWidth = charData[2/*CHAR_DATA_WIDTH_INDEX*/]; - if (charWidth === 0) { - resultIndex++; - } - } return { term, - col: resultIndex, - row + col: resultBufferIndex[1], + row: resultBufferIndex[0] }; } } @@ -321,7 +432,7 @@ export class SearchHelper implements ISearchHelper { * function is useful for getting the actual text underneath the raw selection * position. * @param line The line being translated. - * @param trimRight Whether to trim whitespace to the right. + * @param trimRight Whether to trim -space to the right. */ public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): string { let lineString = ''; @@ -330,7 +441,8 @@ export class SearchHelper implements ISearchHelper { do { const nextLine = this._terminal._core.buffer.lines.get(lineIndex + 1); lineWrapsToNext = nextLine ? nextLine.isWrapped : false; - lineString += this._terminal._core.buffer.translateBufferLineToString(lineIndex, !lineWrapsToNext && trimRight).substring(0, this._terminal.cols); + // string should be cut with string index, not buffer index to support wide characters + lineString += this._terminal._core.buffer.translateBufferLineToString(lineIndex, !lineWrapsToNext && trimRight).substring(0, this._bufferIndexToStringIndex(lineIndex, this._terminal.cols)); lineIndex++; } while (lineWrapsToNext); @@ -347,7 +459,9 @@ export class SearchHelper implements ISearchHelper { this._terminal.clearSelection(); return false; } - this._terminal._core.selectionManager.setSelection(result.col, result.row, result.term.length); + // The selection length should be number of cell needed to be selected, not string length. + // To support wide character + this._terminal._core.selectionManager.setSelection(result.col, result.row, this._getCellLengthFromString(result)); this._terminal.scrollLines(result.row - this._terminal._core.buffer.ydisp); return true; }