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