]> git.proxmox.com Git - mirror_xterm.js.git/blob - src/SelectionManager.ts
Fix issue with trimming whitespace
[mirror_xterm.js.git] / src / SelectionManager.ts
1 /**
2 * @license MIT
3 */
4
5 import { CharMeasure } from './utils/CharMeasure';
6 import { CircularList } from './utils/CircularList';
7 import { EventEmitter } from './EventEmitter';
8 import * as Mouse from './utils/Mouse';
9 import { ITerminal } from './Interfaces';
10 import { SelectionModel } from './SelectionModel';
11
12 /**
13 * The number of pixels the mouse needs to be above or below the viewport in
14 * order to scroll at the maximum speed.
15 */
16 const DRAG_SCROLL_MAX_THRESHOLD = 100;
17
18 /**
19 * The maximum scrolling speed
20 */
21 const DRAG_SCROLL_MAX_SPEED = 5;
22
23 /**
24 * The number of milliseconds between drag scroll updates.
25 */
26 const DRAG_SCROLL_INTERVAL = 100;
27
28 /**
29 * The amount of time before mousedown events are no stacked to create double
30 * click events.
31 */
32 const CLEAR_MOUSE_DOWN_TIME = 400;
33
34 // TODO: Move these constants elsewhere
35 const LINE_DATA_CHAR_INDEX = 1;
36 const LINE_DATA_WIDTH_INDEX = 2;
37
38 export class SelectionManager extends EventEmitter {
39 private _model: SelectionModel;
40
41 /**
42 * The amount to scroll every drag scroll update (depends on how far the mouse
43 * drag is above or below the terminal).
44 */
45 private _dragScrollAmount: number;
46
47 /**
48 * The last time the mousedown event fired, this is used to track double and
49 * triple clicks.
50 */
51 private _lastMouseDownTime: number;
52
53 private _clickCount: number;
54
55 private _bufferTrimListener: any;
56 private _mouseMoveListener: EventListener;
57 private _mouseDownListener: EventListener;
58 private _mouseUpListener: EventListener;
59
60 private _dragScrollTimeout: NodeJS.Timer;
61
62 constructor(
63 private _terminal: ITerminal,
64 private _buffer: CircularList<any>,
65 private _rowContainer: HTMLElement,
66 private _charMeasure: CharMeasure
67 ) {
68 super();
69 this._initListeners();
70 this.enable();
71
72 this._model = new SelectionModel(_terminal);
73 this._lastMouseDownTime = 0;
74 }
75
76 private _initListeners() {
77 this._bufferTrimListener = (amount: number) => this._onTrim(amount);
78 this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event);
79 this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event);
80 this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event);
81 }
82
83 /**
84 * Disables the selection manager. This is useful for when terminal mouse
85 * are enabled.
86 */
87 public disable() {
88 this._model.selectionStart = null;
89 this._model.selectionEnd = null;
90 this.refresh();
91 this._buffer.off('trim', this._bufferTrimListener);
92 this._rowContainer.removeEventListener('mousedown', this._mouseDownListener);
93 this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
94 this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
95 clearInterval(this._dragScrollTimeout);
96 }
97
98 /**
99 * Enable the selection manager.
100 */
101 public enable() {
102 this._buffer.on('trim', this._bufferTrimListener);
103 this._rowContainer.addEventListener('mousedown', this._mouseDownListener);
104 }
105
106 /**
107 * Gets the text currently selected.
108 */
109 public get selectionText(): string {
110 const start = this._model.finalSelectionStart;
111 const end = this._model.finalSelectionEnd;
112 if (!start || !end) {
113 return '';
114 }
115
116 // Get first row
117 const startRowEndCol = start[1] === end[1] ? end[0] : null;
118 let result: string[] = [];
119 result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol));
120
121 // Get middle rows
122 for (let i = start[1] + 1; i <= end[1] - 1; i++) {
123 result.push(this._translateBufferLineToString(this._buffer.get(i), true));
124 }
125
126 // Get final row
127 if (start[1] !== end[1]) {
128 result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0]));
129 }
130 console.log('selectionText result: "' + result + '"');
131 return result.join('\n');
132 }
133
134 private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string {
135 // TODO: This function should live in a buffer or buffer line class
136
137 // Get full line
138 let lineString = '';
139 let widthAdjustedStartCol = startCol;
140 let widthAdjustedEndCol = endCol;
141 for (let i = 0; i < line.length; i++) {
142 const char = line[i];
143 lineString += char[LINE_DATA_CHAR_INDEX];
144 // Adjust start and end cols for wide characters if they affect their
145 // column indexes
146 if (char[LINE_DATA_WIDTH_INDEX] === 0) {
147 if (startCol >= i) {
148 widthAdjustedStartCol--;
149 }
150 if (endCol >= i) {
151 widthAdjustedEndCol--;
152 }
153 }
154 }
155
156 // Calculate the final end col by trimming whitespace on the right of the
157 // line if needed.
158 let finalEndCol = widthAdjustedEndCol || line.length
159 if (trimRight) {
160 const rightWhitespaceIndex = lineString.search(/\s+$/);
161 if (rightWhitespaceIndex !== -1) {
162 finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex);
163 }
164 // Return the empty string if only trimmed whitespace is selected
165 if (finalEndCol <= widthAdjustedStartCol) {
166 return '';
167 }
168 }
169
170 return lineString.substring(widthAdjustedStartCol, finalEndCol);
171 }
172
173 /**
174 * Redraws the selection.
175 */
176 public refresh(): void {
177 // TODO: Figure out when to refresh the selection vs when to refresh the viewport
178 this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd });
179 }
180
181 /**
182 * Selects all text within the terminal.
183 */
184 public selectAll(): void {
185 this._model.isSelectAllActive = true;
186 this.refresh();
187 }
188
189 /**
190 * Handle the buffer being trimmed, adjust the selection position.
191 * @param amount The amount the buffer is being trimmed.
192 */
193 private _onTrim(amount: number) {
194 const needsRefresh = this._model.onTrim(amount);
195 if (needsRefresh) {
196 this.refresh();
197 }
198 }
199
200 // TODO: Handle splice/shiftElements in the buffer (just clear the selection?)
201
202 private _getMouseBufferCoords(event: MouseEvent): [number, number] {
203 const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows);
204 console.log(coords);
205 // Convert to 0-based
206 coords[0]--;
207 coords[1]--;
208 // Convert viewport coords to buffer coords
209 coords[1] += this._terminal.ydisp;
210 return coords;
211 }
212
213 private _getMouseEventScrollAmount(event: MouseEvent): number {
214 let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1];
215 const terminalHeight = this._terminal.rows * this._charMeasure.height;
216 if (offset >= 0 && offset <= terminalHeight) {
217 return 0;
218 }
219 if (offset > terminalHeight) {
220 offset -= terminalHeight;
221 }
222
223 offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD);
224 offset /= DRAG_SCROLL_MAX_THRESHOLD;
225 return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1));
226 }
227
228 /**
229 * Handles te mousedown event, setting up for a new selection.
230 * @param event The mousedown event.
231 */
232 private _onMouseDown(event: MouseEvent) {
233 // Only action the primary button
234 if (event.button !== 0) {
235 return;
236 }
237
238 this._setMouseClickCount();
239 console.log(this._clickCount);
240
241 if (this._clickCount === 1) {
242 this._onSingleClick(event);
243 } else if (this._clickCount === 2) {
244 this._onDoubleClick(event);
245 } else if (this._clickCount === 3) {
246 this._onTripleClick(event);
247 }
248
249 // Listen on the document so that dragging outside of viewport works
250 this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener);
251 this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener);
252 this._dragScrollTimeout = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL);
253 this.refresh();
254 }
255
256 private _onSingleClick(event: MouseEvent): void {
257 this._model.selectionStartLength = 0;
258 this._model.isSelectAllActive = false;
259 this._model.selectionStart = this._getMouseBufferCoords(event);
260 if (this._model.selectionStart) {
261 this._model.selectionEnd = null;
262 // If the mouse is over the second half of a wide character, adjust the
263 // selection to cover the whole character
264 const char = this._buffer.get(this._model.selectionStart[1])[this._model.selectionStart[0]];
265 if (char[LINE_DATA_WIDTH_INDEX] === 0) {
266 this._model.selectionStart[0]++;
267 }
268 }
269 }
270
271 private _onDoubleClick(event: MouseEvent): void {
272 const coords = this._getMouseBufferCoords(event);
273 if (coords) {
274 this._selectWordAt(coords);
275 }
276 }
277
278 private _onTripleClick(event: MouseEvent): void {
279 const coords = this._getMouseBufferCoords(event);
280 if (coords) {
281 this._selectLineAt(coords[1]);
282 }
283 }
284
285 private _setMouseClickCount(): void {
286 let currentTime = (new Date()).getTime();
287 if (currentTime - this._lastMouseDownTime > CLEAR_MOUSE_DOWN_TIME) {
288 this._clickCount = 0;
289 }
290 this._lastMouseDownTime = currentTime;
291 this._clickCount++;
292
293 // TODO: Invalidate click count if the position is different
294 }
295
296 /**
297 * Handles the mousemove event when the mouse button is down, recording the
298 * end of the selection and refreshing the selection.
299 * @param event The mousemove event.
300 */
301 private _onMouseMove(event: MouseEvent) {
302 this._model.selectionEnd = this._getMouseBufferCoords(event);
303 // TODO: Perhaps the actual selection setting could be merged into _dragScroll?
304 this._dragScrollAmount = this._getMouseEventScrollAmount(event);
305 // If the cursor was above or below the viewport, make sure it's at the
306 // start or end of the viewport respectively
307 if (this._dragScrollAmount > 0) {
308 this._model.selectionEnd[0] = this._terminal.cols - 1;
309 } else if (this._dragScrollAmount < 0) {
310 this._model.selectionEnd[0] = 0;
311 }
312
313 // If the character is a wide character include the cell to the right in the
314 // selection.
315 const char = this._buffer.get(this._model.selectionEnd[1])[this._model.selectionEnd[0]];
316 if (char[2] === 0) {
317 this._model.selectionEnd[0]++;
318 }
319
320 // TODO: Only draw here if the selection changes
321 this.refresh();
322 }
323
324 private _dragScroll() {
325 if (this._dragScrollAmount) {
326 this._terminal.scrollDisp(this._dragScrollAmount, false);
327 // Re-evaluate selection
328 if (this._dragScrollAmount > 0) {
329 this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows];
330 } else {
331 this._model.selectionEnd = [0, this._terminal.ydisp];
332 }
333 this.refresh();
334 }
335 }
336
337 /**
338 * Handles the mouseup event, removing the mousemove listener when
339 * appropriate.
340 * @param event The mouseup event.
341 */
342 private _onMouseUp(event: MouseEvent) {
343 this._dragScrollAmount = 0;
344 if (!this._model.selectionStart) {
345 return;
346 }
347 this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
348 this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
349 }
350
351 /**
352 * Converts a viewport column to the character index on the buffer line, the
353 * latter takes into account wide characters.
354 * @param coords The coordinates to find the character index for.
355 */
356 private _convertViewportColToCharacterIndex(bufferLine: any, coords: [number, number]): number {
357 let charIndex = coords[0];
358 for (let i = 0; coords[0] >= i; i++) {
359 const char = bufferLine[i];
360 if (char[LINE_DATA_WIDTH_INDEX] === 0) {
361 charIndex--;
362 }
363 }
364 return charIndex;
365 }
366
367 /**
368 * Selects the word at the coordinates specified. Words are defined as all
369 * non-whitespace characters.
370 * @param coords The coordinates to get the word at.
371 */
372 protected _selectWordAt(coords: [number, number]): void {
373 // TODO: Only fetch buffer line once for translate and convert functions
374 const bufferLine = this._buffer.get(coords[1]);
375 const line = this._translateBufferLineToString(bufferLine, false);
376
377 // Get actual index, taking into consideration wide characters
378 let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords);
379 let startIndex = endIndex;
380
381 // Record offset to be used later
382 const charOffset = coords[0] - startIndex;
383 let leftWideCharCount = 0;
384 let rightWideCharCount = 0;
385
386 if (line.charAt(startIndex) === ' ') {
387 // Expand until non-whitespace is hit
388 while (startIndex > 0 && line.charAt(startIndex - 1) === ' ') {
389 startIndex--;
390 }
391 while (endIndex < line.length && line.charAt(endIndex + 1) === ' ') {
392 endIndex++;
393 }
394 } else {
395 // Expand until whitespace is hit. This algorithm works by scanning left
396 // and right from the starting position, keeping both the index format
397 // (line) and the column format (bufferLine) in sync. When a wide
398 // character is hit, it is recorded and the column index is adjusted.
399 let startCol = coords[0];
400 let endCol = coords[0];
401 // Consider the initial position, skip it and increment the wide char
402 // variable
403 if (bufferLine[startCol][LINE_DATA_WIDTH_INDEX] === 0) {
404 leftWideCharCount++;
405 startCol--;
406 }
407 if (bufferLine[endCol][LINE_DATA_WIDTH_INDEX] === 2) {
408 rightWideCharCount++;
409 endCol++;
410 }
411 // Expand the string in both directions until a space is hit
412 while (startIndex > 0 && line.charAt(startIndex - 1) !== ' ') {
413 if (bufferLine[startCol - 1][LINE_DATA_WIDTH_INDEX] === 0) {
414 // If the next character is a wide char, record it and skip the column
415 leftWideCharCount++;
416 startCol--;
417 }
418 startIndex--;
419 startCol--;
420 }
421 while (endIndex < line.length && line.charAt(endIndex + 1) !== ' ') {
422 if (bufferLine[endCol + 1][LINE_DATA_WIDTH_INDEX] === 2) {
423 // If the next character is a wide char, record it and skip the column
424 rightWideCharCount++;
425 endCol++;
426 }
427 endIndex++;
428 endCol++;
429 }
430 }
431
432 // Record the resulting selection
433 this._model.selectionStart = [startIndex + charOffset - leftWideCharCount, coords[1]];
434 this._model.selectionStartLength = endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/;
435 }
436
437 private _selectLineAt(line: number): void {
438 this._model.selectionStart = [0, line];
439 this._model.selectionStartLength = this._terminal.cols;
440 }
441 }