xterm
[VSoRC/.git] / node_modules / xterm / src / browser / renderer / CharacterJoinerRegistry.ts
1 /**
2  * Copyright (c) 2018 The xterm.js authors. All rights reserved.
3  * @license MIT
4  */
5
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';
12
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;
18   public fg: number;
19   public bg: number;
20   public combinedData: string = '';
21
22   constructor(firstCell: ICellData, chars: string, width: number) {
23     super();
24     this.fg = firstCell.fg;
25     this.bg = firstCell.bg;
26     this.combinedData = chars;
27     this._width = width;
28   }
29
30   public isCombined(): number {
31     // always mark joined cell data as combined
32     return Content.IS_COMBINED_MASK;
33   }
34
35   public getWidth(): number {
36     return this._width;
37   }
38
39   public getChars(): string {
40     return this.combinedData;
41   }
42
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
46     return 0x1FFFFF;
47   }
48
49   public setFromCharData(value: CharData): void {
50     throw new Error('not implemented');
51   }
52
53   public getAsCharData(): CharData {
54     return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
55   }
56 }
57
58 export class CharacterJoinerRegistry implements ICharacterJoinerRegistry {
59
60   private _characterJoiners: ICharacterJoiner[] = [];
61   private _nextCharacterJoinerId: number = 0;
62   private _workCell: CellData = new CellData();
63
64   constructor(private _bufferService: IBufferService) { }
65
66   public registerCharacterJoiner(handler: (text: string) => [number, number][]): number {
67     const joiner: ICharacterJoiner = {
68       id: this._nextCharacterJoinerId++,
69       handler
70     };
71
72     this._characterJoiners.push(joiner);
73     return joiner.id;
74   }
75
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);
80         return true;
81       }
82     }
83
84     return false;
85   }
86
87   public getJoinedCharacters(row: number): [number, number][] {
88     if (this._characterJoiners.length === 0) {
89       return [];
90     }
91
92     const line = this._bufferService.buffer.lines.get(row);
93     if (!line || line.length === 0) {
94       return [];
95     }
96
97     const ranges: [number, number][] = [];
98     const lineStr = line.translateToString(true);
99
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);
109
110     for (let x = 0; x < line.getTrimmedLength(); x++) {
111       line.loadCell(x, this._workCell);
112
113       if (this._workCell.getWidth() === 0) {
114         // If this character is of width 0, skip it.
115         continue;
116       }
117
118       // End of range
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(
124             lineStr,
125             rangeStartStringIndex,
126             currentStringIndex,
127             line,
128             rangeStartColumn
129           );
130           for (let i = 0; i < joinedRanges.length; i++) {
131             ranges.push(joinedRanges[i]);
132           }
133         }
134
135         // Reset our markers for a new range.
136         rangeStartColumn = x;
137         rangeStartStringIndex = currentStringIndex;
138         rangeAttrFG = this._workCell.fg;
139         rangeAttrBG = this._workCell.bg;
140       }
141
142       currentStringIndex += this._workCell.getChars().length || WHITESPACE_CELL_CHAR.length;
143     }
144
145     // Process any trailing ranges.
146     if (this._bufferService.cols - rangeStartColumn > 1) {
147       const joinedRanges = this._getJoinedRanges(
148         lineStr,
149         rangeStartStringIndex,
150         currentStringIndex,
151         line,
152         rangeStartColumn
153       );
154       for (let i = 0; i < joinedRanges.length; i++) {
155         ranges.push(joinedRanges[i]);
156       }
157     }
158
159     return ranges;
160   }
161
162   /**
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)
169    */
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]);
181       }
182     }
183     this._stringRangesToCellRanges(joinedRanges, lineData, startCol);
184     return joinedRanges;
185   }
186
187   /**
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
194    */
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];
200
201     // If we got through all of the ranges, stop searching
202     if (!currentRange) {
203       return;
204     }
205
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;
209
210       // We skip zero-width characters when creating the string to join the text
211       // so we do the same here
212       if (width === 0) {
213         continue;
214       }
215
216       // Adjust the start of the range
217       if (!currentRangeStarted && currentRange[0] <= currentStringIndex) {
218         currentRange[0] = x;
219         currentRangeStarted = true;
220       }
221
222       // Adjust the end of the range
223       if (currentRange[1] <= currentStringIndex) {
224         currentRange[1] = x;
225
226         // We're finished with this range, so we move to the next one
227         currentRange = ranges[++currentRangeIndex];
228
229         // If there are no more ranges left, stop searching
230         if (!currentRange) {
231           break;
232         }
233
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) {
239           currentRange[0] = x;
240           currentRangeStarted = true;
241         } else {
242           currentRangeStarted = false;
243         }
244       }
245
246       // Adjust the string index based on the character length to line up with
247       // the column adjustment
248       currentStringIndex += length;
249     }
250
251     // If there is still a range left at the end, it must extend all the way to
252     // the end of the line.
253     if (currentRange) {
254       currentRange[1] = this._bufferService.cols;
255     }
256   }
257
258   /**
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
265    */
266   private static _mergeRanges(ranges: [number, number][], newRange: [number, number]): [number, number][] {
267     let inRange = false;
268     for (let i = 0; i < ranges.length; i++) {
269       const range = ranges[i];
270       if (!inRange) {
271         if (newRange[1] <= range[0]) {
272           // Case 1: New range is before the search range
273           ranges.splice(i, 0, newRange);
274           return ranges;
275         }
276
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]);
281           return ranges;
282         }
283
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]);
288           inRange = true;
289         }
290
291         // Case 4: New range starts after the search range
292         continue;
293       } else {
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];
298           return ranges;
299         }
300
301         if (newRange[1] <= range[1]) {
302           // Case 6: New range extends from prvious range into the
303           // current range
304           ranges[i - 1][1] = Math.max(newRange[1], range[1]);
305           ranges.splice(i, 1);
306           return ranges;
307         }
308
309         // Case 7: New range extends from previous range past the
310         // end of the current range
311         ranges.splice(i, 1);
312         i--;
313       }
314     }
315
316     if (inRange) {
317       // Case 8: New range extends past the last existing range
318       ranges[ranges.length - 1][1] = newRange[1];
319     } else {
320       // Case 9: New range starts after the last existing range
321       ranges.push(newRange);
322     }
323
324     return ranges;
325   }
326 }