2 * Copyright (c) 2018 The xterm.js authors. All rights reserved.
6 import { IBufferLine, ICellData, CharData } from 'common/Types';
7 import { ICharacterJoinerRegistry, ICharacterJoiner } from 'browser/renderer/Types';
8 import { AttributeData } from 'common/buffer/AttributeData';
9 import { WHITESPACE_CELL_CHAR, Content } from 'common/buffer/Constants';
10 import { CellData } from 'common/buffer/CellData';
11 import { IBufferService } from 'common/services/Services';
13 export class JoinedCellData extends AttributeData implements ICellData {
14 private _width: number;
15 // .content carries no meaning for joined CellData, simply nullify it
16 // thus we have to overload all other .content accessors
17 public content: number = 0;
20 public combinedData: string = '';
22 constructor(firstCell: ICellData, chars: string, width: number) {
24 this.fg = firstCell.fg;
25 this.bg = firstCell.bg;
26 this.combinedData = chars;
30 public isCombined(): number {
31 // always mark joined cell data as combined
32 return Content.IS_COMBINED_MASK;
35 public getWidth(): number {
39 public getChars(): string {
40 return this.combinedData;
43 public getCode(): number {
44 // code always gets the highest possible fake codepoint (read as -1)
45 // this is needed as code is used by caches as identifier
49 public setFromCharData(value: CharData): void {
50 throw new Error('not implemented');
53 public getAsCharData(): CharData {
54 return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
58 export class CharacterJoinerRegistry implements ICharacterJoinerRegistry {
60 private _characterJoiners: ICharacterJoiner[] = [];
61 private _nextCharacterJoinerId: number = 0;
62 private _workCell: CellData = new CellData();
64 constructor(private _bufferService: IBufferService) { }
66 public registerCharacterJoiner(handler: (text: string) => [number, number][]): number {
67 const joiner: ICharacterJoiner = {
68 id: this._nextCharacterJoinerId++,
72 this._characterJoiners.push(joiner);
76 public deregisterCharacterJoiner(joinerId: number): boolean {
77 for (let i = 0; i < this._characterJoiners.length; i++) {
78 if (this._characterJoiners[i].id === joinerId) {
79 this._characterJoiners.splice(i, 1);
87 public getJoinedCharacters(row: number): [number, number][] {
88 if (this._characterJoiners.length === 0) {
92 const line = this._bufferService.buffer.lines.get(row);
93 if (!line || line.length === 0) {
97 const ranges: [number, number][] = [];
98 const lineStr = line.translateToString(true);
100 // Because some cells can be represented by multiple javascript characters,
101 // we track the cell and the string indexes separately. This allows us to
102 // translate the string ranges we get from the joiners back into cell ranges
103 // for use when rendering
104 let rangeStartColumn = 0;
105 let currentStringIndex = 0;
106 let rangeStartStringIndex = 0;
107 let rangeAttrFG = line.getFg(0);
108 let rangeAttrBG = line.getBg(0);
110 for (let x = 0; x < line.getTrimmedLength(); x++) {
111 line.loadCell(x, this._workCell);
113 if (this._workCell.getWidth() === 0) {
114 // If this character is of width 0, skip it.
119 if (this._workCell.fg !== rangeAttrFG || this._workCell.bg !== rangeAttrBG) {
120 // If we ended up with a sequence of more than one character,
121 // look for ranges to join.
122 if (x - rangeStartColumn > 1) {
123 const joinedRanges = this._getJoinedRanges(
125 rangeStartStringIndex,
130 for (let i = 0; i < joinedRanges.length; i++) {
131 ranges.push(joinedRanges[i]);
135 // Reset our markers for a new range.
136 rangeStartColumn = x;
137 rangeStartStringIndex = currentStringIndex;
138 rangeAttrFG = this._workCell.fg;
139 rangeAttrBG = this._workCell.bg;
142 currentStringIndex += this._workCell.getChars().length || WHITESPACE_CELL_CHAR.length;
145 // Process any trailing ranges.
146 if (this._bufferService.cols - rangeStartColumn > 1) {
147 const joinedRanges = this._getJoinedRanges(
149 rangeStartStringIndex,
154 for (let i = 0; i < joinedRanges.length; i++) {
155 ranges.push(joinedRanges[i]);
163 * Given a segment of a line of text, find all ranges of text that should be
164 * joined in a single rendering unit. Ranges are internally converted to
165 * column ranges, rather than string ranges.
166 * @param line String representation of the full line of text
167 * @param startIndex Start position of the range to search in the string (inclusive)
168 * @param endIndex End position of the range to search in the string (exclusive)
170 private _getJoinedRanges(line: string, startIndex: number, endIndex: number, lineData: IBufferLine, startCol: number): [number, number][] {
171 const text = line.substring(startIndex, endIndex);
172 // At this point we already know that there is at least one joiner so
173 // we can just pull its value and assign it directly rather than
174 // merging it into an empty array, which incurs unnecessary writes.
175 const joinedRanges: [number, number][] = this._characterJoiners[0].handler(text);
176 for (let i = 1; i < this._characterJoiners.length; i++) {
177 // We merge any overlapping ranges across the different joiners
178 const joinerRanges = this._characterJoiners[i].handler(text);
179 for (let j = 0; j < joinerRanges.length; j++) {
180 CharacterJoinerRegistry._mergeRanges(joinedRanges, joinerRanges[j]);
183 this._stringRangesToCellRanges(joinedRanges, lineData, startCol);
188 * Modifies the provided ranges in-place to adjust for variations between
189 * string length and cell width so that the range represents a cell range,
190 * rather than the string range the joiner provides.
191 * @param ranges String ranges containing start (inclusive) and end (exclusive) index
192 * @param line Cell data for the relevant line in the terminal
193 * @param startCol Offset within the line to start from
195 private _stringRangesToCellRanges(ranges: [number, number][], line: IBufferLine, startCol: number): void {
196 let currentRangeIndex = 0;
197 let currentRangeStarted = false;
198 let currentStringIndex = 0;
199 let currentRange = ranges[currentRangeIndex];
201 // If we got through all of the ranges, stop searching
206 for (let x = startCol; x < this._bufferService.cols; x++) {
207 const width = line.getWidth(x);
208 const length = line.getString(x).length || WHITESPACE_CELL_CHAR.length;
210 // We skip zero-width characters when creating the string to join the text
211 // so we do the same here
216 // Adjust the start of the range
217 if (!currentRangeStarted && currentRange[0] <= currentStringIndex) {
219 currentRangeStarted = true;
222 // Adjust the end of the range
223 if (currentRange[1] <= currentStringIndex) {
226 // We're finished with this range, so we move to the next one
227 currentRange = ranges[++currentRangeIndex];
229 // If there are no more ranges left, stop searching
234 // Ranges can be on adjacent characters. Because the end index of the
235 // ranges are exclusive, this means that the index for the start of a
236 // range can be the same as the end index of the previous range. To
237 // account for the start of the next range, we check here just in case.
238 if (currentRange[0] <= currentStringIndex) {
240 currentRangeStarted = true;
242 currentRangeStarted = false;
246 // Adjust the string index based on the character length to line up with
247 // the column adjustment
248 currentStringIndex += length;
251 // If there is still a range left at the end, it must extend all the way to
252 // the end of the line.
254 currentRange[1] = this._bufferService.cols;
259 * Merges the range defined by the provided start and end into the list of
260 * existing ranges. The merge is done in place on the existing range for
261 * performance and is also returned.
262 * @param ranges Existing range list
263 * @param newRange Tuple of two numbers representing the new range to merge in.
264 * @returns The ranges input with the new range merged in place
266 private static _mergeRanges(ranges: [number, number][], newRange: [number, number]): [number, number][] {
268 for (let i = 0; i < ranges.length; i++) {
269 const range = ranges[i];
271 if (newRange[1] <= range[0]) {
272 // Case 1: New range is before the search range
273 ranges.splice(i, 0, newRange);
277 if (newRange[1] <= range[1]) {
278 // Case 2: New range is either wholly contained within the
279 // search range or overlaps with the front of it
280 range[0] = Math.min(newRange[0], range[0]);
284 if (newRange[0] < range[1]) {
285 // Case 3: New range either wholly contains the search range
286 // or overlaps with the end of it
287 range[0] = Math.min(newRange[0], range[0]);
291 // Case 4: New range starts after the search range
294 if (newRange[1] <= range[0]) {
295 // Case 5: New range extends from previous range but doesn't
296 // reach the current one
297 ranges[i - 1][1] = newRange[1];
301 if (newRange[1] <= range[1]) {
302 // Case 6: New range extends from prvious range into the
304 ranges[i - 1][1] = Math.max(newRange[1], range[1]);
309 // Case 7: New range extends from previous range past the
310 // end of the current range
317 // Case 8: New range extends past the last existing range
318 ranges[ranges.length - 1][1] = newRange[1];
320 // Case 9: New range starts after the last existing range
321 ranges.push(newRange);