]>
Commit | Line | Data |
---|---|---|
70fda994 DI |
1 | /** |
2 | * @license MIT | |
3 | */ | |
4 | ||
dc165175 DI |
5 | import * as Mouse from './utils/Mouse'; |
6 | import * as Browser from './utils/Browser'; | |
70fda994 DI |
7 | import { CharMeasure } from './utils/CharMeasure'; |
8 | import { CircularList } from './utils/CircularList'; | |
b594407c | 9 | import { EventEmitter } from './EventEmitter'; |
ad3ae67e | 10 | import { ITerminal } from './Interfaces'; |
f7d6ab5f | 11 | import { SelectionModel } from './SelectionModel'; |
70fda994 | 12 | |
0dc3dd03 DI |
13 | /** |
14 | * The number of pixels the mouse needs to be above or below the viewport in | |
15 | * order to scroll at the maximum speed. | |
16 | */ | |
b8129910 | 17 | const DRAG_SCROLL_MAX_THRESHOLD = 50; |
0dc3dd03 DI |
18 | |
19 | /** | |
20 | * The maximum scrolling speed | |
21 | */ | |
b8129910 | 22 | const DRAG_SCROLL_MAX_SPEED = 15; |
0dc3dd03 DI |
23 | |
24 | /** | |
25 | * The number of milliseconds between drag scroll updates. | |
26 | */ | |
b8129910 | 27 | const DRAG_SCROLL_INTERVAL = 50; |
0dc3dd03 | 28 | |
9f271de8 | 29 | /** |
f380153f DI |
30 | * The amount of time before mousedown events are no longer stacked to create |
31 | * double/triple click events. | |
9f271de8 DI |
32 | */ |
33 | const CLEAR_MOUSE_DOWN_TIME = 400; | |
34 | ||
f380153f DI |
35 | /** |
36 | * The number of pixels in each direction that the mouse must move before | |
37 | * mousedown events are no longer stacked to create double/triple click events. | |
38 | */ | |
39 | const CLEAR_MOUSE_DISTANCE = 10; | |
40 | ||
7f2e94c3 DI |
41 | /** |
42 | * A string containing all characters that are considered word separated by the | |
43 | * double click to select work logic. | |
44 | */ | |
ca03f892 | 45 | const WORD_SEPARATORS = ' ()[]{}\'"'; |
7f2e94c3 | 46 | |
2621be81 DI |
47 | // TODO: Move these constants elsewhere, they belong in a buffer or buffer |
48 | // data/line class. | |
54e7f65d DI |
49 | const LINE_DATA_CHAR_INDEX = 1; |
50 | const LINE_DATA_WIDTH_INDEX = 2; | |
51 | ||
2012c8c9 DI |
52 | const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160); |
53 | const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g'); | |
54 | ||
320fb55d DI |
55 | /** |
56 | * Represents a position of a word on a line. | |
57 | */ | |
4b170ca4 DI |
58 | interface IWordPosition { |
59 | start: number; | |
60 | length: number; | |
61 | } | |
62 | ||
320fb55d DI |
63 | /** |
64 | * A selection mode, this drives how the selection behaves on mouse move. | |
65 | */ | |
66 | enum SelectionMode { | |
67 | NORMAL, | |
68 | WORD, | |
69 | LINE | |
70 | } | |
71 | ||
6075498c DI |
72 | /** |
73 | * A class that manages the selection of the terminal. With help from | |
74 | * SelectionModel, SelectionManager handles with all logic associated with | |
75 | * dealing with the selection, including handling mouse interaction, wide | |
76 | * characters and fetching the actual text within the selection. Rendering is | |
77 | * not handled by the SelectionManager but a 'refresh' event is fired when the | |
78 | * selection is ready to be redrawn. | |
79 | */ | |
b594407c | 80 | export class SelectionManager extends EventEmitter { |
fd91c5e1 | 81 | protected _model: SelectionModel; |
9f271de8 DI |
82 | |
83 | /** | |
84 | * The amount to scroll every drag scroll update (depends on how far the mouse | |
85 | * drag is above or below the terminal). | |
86 | */ | |
0dc3dd03 | 87 | private _dragScrollAmount: number; |
70fda994 | 88 | |
9f271de8 DI |
89 | /** |
90 | * The last time the mousedown event fired, this is used to track double and | |
91 | * triple clicks. | |
92 | */ | |
93 | private _lastMouseDownTime: number; | |
94 | ||
f380153f DI |
95 | /** |
96 | * The last position the mouse was clicked [x, y]. | |
97 | */ | |
98 | private _lastMousePosition: [number, number]; | |
99 | ||
2621be81 DI |
100 | /** |
101 | * The number of clicks of the mousedown event. This is used to keep track of | |
102 | * double and triple clicks. | |
103 | */ | |
9f271de8 DI |
104 | private _clickCount: number; |
105 | ||
2621be81 | 106 | /** |
320fb55d | 107 | * The current selection mode. |
2621be81 | 108 | */ |
320fb55d | 109 | private _activeSelectionMode: SelectionMode; |
4b170ca4 | 110 | |
d0b603d0 DI |
111 | /** |
112 | * A setInterval timer that is active while the mouse is down whose callback | |
113 | * scrolls the viewport when necessary. | |
114 | */ | |
115 | private _dragScrollIntervalTimer: NodeJS.Timer; | |
116 | ||
b81c165b DI |
117 | /** |
118 | * The animation frame ID used for refreshing the selection. | |
119 | */ | |
13c401cb DI |
120 | private _refreshAnimationFrame: number; |
121 | ||
ab40908f | 122 | private _bufferTrimListener: any; |
70fda994 | 123 | private _mouseMoveListener: EventListener; |
ab40908f DI |
124 | private _mouseDownListener: EventListener; |
125 | private _mouseUpListener: EventListener; | |
70fda994 | 126 | |
ad3ae67e DI |
127 | constructor( |
128 | private _terminal: ITerminal, | |
129 | private _buffer: CircularList<any>, | |
130 | private _rowContainer: HTMLElement, | |
ad3ae67e DI |
131 | private _charMeasure: CharMeasure |
132 | ) { | |
b594407c | 133 | super(); |
ab40908f DI |
134 | this._initListeners(); |
135 | this.enable(); | |
9f271de8 | 136 | |
f7d6ab5f | 137 | this._model = new SelectionModel(_terminal); |
9f271de8 | 138 | this._lastMouseDownTime = 0; |
320fb55d | 139 | this._activeSelectionMode = SelectionMode.NORMAL; |
70fda994 DI |
140 | } |
141 | ||
d0b603d0 DI |
142 | /** |
143 | * Initializes listener variables. | |
144 | */ | |
ab40908f DI |
145 | private _initListeners() { |
146 | this._bufferTrimListener = (amount: number) => this._onTrim(amount); | |
70fda994 | 147 | this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event); |
ab40908f DI |
148 | this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event); |
149 | this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event); | |
ab40908f | 150 | } |
70fda994 | 151 | |
ab40908f DI |
152 | /** |
153 | * Disables the selection manager. This is useful for when terminal mouse | |
154 | * are enabled. | |
155 | */ | |
156 | public disable() { | |
4405f5e1 | 157 | this.clearSelection(); |
ab40908f DI |
158 | this._buffer.off('trim', this._bufferTrimListener); |
159 | this._rowContainer.removeEventListener('mousedown', this._mouseDownListener); | |
ab40908f DI |
160 | } |
161 | ||
162 | /** | |
163 | * Enable the selection manager. | |
164 | */ | |
165 | public enable() { | |
f380153f DI |
166 | // Only adjust the selection on trim, shiftElements is rarely used (only in |
167 | // reverseIndex) and delete in a splice is only ever used when the same | |
168 | // number of elements was just added. Given this is could actually be | |
169 | // beneficial to leave the selection as is for these cases. | |
ab40908f DI |
170 | this._buffer.on('trim', this._bufferTrimListener); |
171 | this._rowContainer.addEventListener('mousedown', this._mouseDownListener); | |
70fda994 DI |
172 | } |
173 | ||
9a86eeb3 DI |
174 | /** |
175 | * Sets the active buffer, this should be called when the alt buffer is | |
176 | * switched in or out. | |
177 | * @param buffer The active buffer. | |
178 | */ | |
179 | public setBuffer(buffer: CircularList<any>): void { | |
180 | this._buffer = buffer; | |
181 | } | |
182 | ||
1343b83f DI |
183 | /** |
184 | * Gets whether there is an active text selection. | |
185 | */ | |
186 | public get hasSelection(): boolean { | |
7b469407 DI |
187 | const start = this._model.finalSelectionStart; |
188 | const end = this._model.finalSelectionEnd; | |
189 | if (!start || !end) { | |
190 | return false; | |
191 | } | |
192 | return start[0] !== end[0] || start[1] !== end[1]; | |
1343b83f DI |
193 | } |
194 | ||
9f271de8 DI |
195 | /** |
196 | * Gets the text currently selected. | |
197 | */ | |
70fda994 | 198 | public get selectionText(): string { |
f7d6ab5f DI |
199 | const start = this._model.finalSelectionStart; |
200 | const end = this._model.finalSelectionEnd; | |
e29ab294 | 201 | if (!start || !end) { |
293ae18a | 202 | return ''; |
70fda994 | 203 | } |
43c796a7 | 204 | |
43c796a7 | 205 | // Get first row |
293ae18a | 206 | const startRowEndCol = start[1] === end[1] ? end[0] : null; |
32b34cbe | 207 | let result: string[] = []; |
ec61f3ac | 208 | result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol)); |
43c796a7 DI |
209 | |
210 | // Get middle rows | |
293ae18a | 211 | for (let i = start[1] + 1; i <= end[1] - 1; i++) { |
346b5177 DI |
212 | const bufferLine = this._buffer.get(i); |
213 | const lineText = this._translateBufferLineToString(bufferLine, true); | |
214 | if (bufferLine.isWrapped) { | |
215 | result[result.length - 1] += lineText; | |
216 | } else { | |
217 | result.push(lineText); | |
218 | } | |
32b34cbe | 219 | } |
43c796a7 DI |
220 | |
221 | // Get final row | |
293ae18a | 222 | if (start[1] !== end[1]) { |
346b5177 DI |
223 | const bufferLine = this._buffer.get(end[1]); |
224 | const lineText = this._translateBufferLineToString(bufferLine, true, 0, end[0]); | |
225 | if (bufferLine.isWrapped) { | |
226 | result[result.length - 1] += lineText; | |
227 | } else { | |
228 | result.push(lineText); | |
229 | } | |
32b34cbe | 230 | } |
2b243182 | 231 | |
2012c8c9 DI |
232 | // Format string by replacing non-breaking space chars with regular spaces |
233 | // and joining the array into a multi-line string. | |
234 | const formattedResult = result.map(line => { | |
235 | return line.replace(ALL_NON_BREAKING_SPACE_REGEX, ' '); | |
236 | }).join('\n'); | |
237 | ||
238 | return formattedResult; | |
32b34cbe DI |
239 | } |
240 | ||
5d33727a DI |
241 | /** |
242 | * Clears the current terminal selection. | |
243 | */ | |
244 | public clearSelection(): void { | |
4405f5e1 | 245 | this._model.clearSelection(); |
5d33727a DI |
246 | this._removeMouseDownListeners(); |
247 | this.refresh(); | |
248 | } | |
249 | ||
d0b603d0 DI |
250 | /** |
251 | * Translates a buffer line to a string, with optional start and end columns. | |
252 | * Wide characters will count as two columns in the resulting string. This | |
253 | * function is useful for getting the actual text underneath the raw selection | |
254 | * position. | |
255 | * @param line The line being translated. | |
256 | * @param trimRight Whether to trim whitespace to the right. | |
257 | * @param startCol The column to start at. | |
258 | * @param endCol The column to end at. | |
259 | */ | |
ec61f3ac | 260 | private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string { |
32b34cbe | 261 | // TODO: This function should live in a buffer or buffer line class |
ec61f3ac DI |
262 | |
263 | // Get full line | |
264 | let lineString = ''; | |
54e7f65d DI |
265 | let widthAdjustedStartCol = startCol; |
266 | let widthAdjustedEndCol = endCol; | |
ec61f3ac | 267 | for (let i = 0; i < line.length; i++) { |
54e7f65d DI |
268 | const char = line[i]; |
269 | lineString += char[LINE_DATA_CHAR_INDEX]; | |
270 | // Adjust start and end cols for wide characters if they affect their | |
271 | // column indexes | |
272 | if (char[LINE_DATA_WIDTH_INDEX] === 0) { | |
273 | if (startCol >= i) { | |
274 | widthAdjustedStartCol--; | |
275 | } | |
276 | if (endCol >= i) { | |
277 | widthAdjustedEndCol--; | |
278 | } | |
279 | } | |
ec61f3ac DI |
280 | } |
281 | ||
54e7f65d DI |
282 | // Calculate the final end col by trimming whitespace on the right of the |
283 | // line if needed. | |
d3865ad2 | 284 | let finalEndCol = widthAdjustedEndCol || line.length; |
ec61f3ac DI |
285 | if (trimRight) { |
286 | const rightWhitespaceIndex = lineString.search(/\s+$/); | |
a53a0acd DI |
287 | if (rightWhitespaceIndex !== -1) { |
288 | finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex); | |
289 | } | |
ec61f3ac | 290 | // Return the empty string if only trimmed whitespace is selected |
54e7f65d | 291 | if (finalEndCol <= widthAdjustedStartCol) { |
ec61f3ac DI |
292 | return ''; |
293 | } | |
294 | } | |
295 | ||
54e7f65d | 296 | return lineString.substring(widthAdjustedStartCol, finalEndCol); |
70fda994 DI |
297 | } |
298 | ||
207c4cf9 | 299 | /** |
13c401cb | 300 | * Queues a refresh, redrawing the selection on the next opportunity. |
63d63c27 DI |
301 | * @param isNewSelection Whether the selection should be registered as a new |
302 | * selection on Linux. | |
207c4cf9 | 303 | */ |
63d63c27 | 304 | public refresh(isNewSelection?: boolean): void { |
8811d96a | 305 | // Queue the refresh for the renderer |
13c401cb DI |
306 | if (!this._refreshAnimationFrame) { |
307 | this._refreshAnimationFrame = window.requestAnimationFrame(() => this._refresh()); | |
308 | } | |
8811d96a | 309 | |
dc165175 DI |
310 | // If the platform is Linux and the refresh call comes from a mouse event, |
311 | // we need to update the selection for middle click to paste selection. | |
63d63c27 | 312 | if (Browser.isLinux && isNewSelection) { |
dc165175 DI |
313 | const selectionText = this.selectionText; |
314 | if (selectionText.length) { | |
315 | this.emit('newselection', this.selectionText); | |
316 | } | |
8811d96a | 317 | } |
13c401cb DI |
318 | } |
319 | ||
320 | /** | |
321 | * Fires the refresh event, causing consumers to pick it up and redraw the | |
322 | * selection state. | |
323 | */ | |
324 | private _refresh(): void { | |
325 | this._refreshAnimationFrame = null; | |
f7d6ab5f | 326 | this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd }); |
25152e44 DI |
327 | } |
328 | ||
329 | /** | |
330 | * Selects all text within the terminal. | |
331 | */ | |
332 | public selectAll(): void { | |
f7d6ab5f | 333 | this._model.isSelectAllActive = true; |
25152e44 | 334 | this.refresh(); |
207c4cf9 DI |
335 | } |
336 | ||
337 | /** | |
338 | * Handle the buffer being trimmed, adjust the selection position. | |
339 | * @param amount The amount the buffer is being trimmed. | |
340 | */ | |
70fda994 | 341 | private _onTrim(amount: number) { |
f7d6ab5f DI |
342 | const needsRefresh = this._model.onTrim(amount); |
343 | if (needsRefresh) { | |
207c4cf9 | 344 | this.refresh(); |
207c4cf9 | 345 | } |
70fda994 DI |
346 | } |
347 | ||
d0b603d0 DI |
348 | /** |
349 | * Gets the 0-based [x, y] buffer coordinates of the current mouse event. | |
350 | * @param event The mouse event. | |
351 | */ | |
0dc3dd03 | 352 | private _getMouseBufferCoords(event: MouseEvent): [number, number] { |
b05814e6 | 353 | const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows, true); |
ad3ae67e DI |
354 | // Convert to 0-based |
355 | coords[0]--; | |
356 | coords[1]--; | |
357 | // Convert viewport coords to buffer coords | |
358 | coords[1] += this._terminal.ydisp; | |
359 | return coords; | |
b36d8780 DI |
360 | } |
361 | ||
d0b603d0 DI |
362 | /** |
363 | * Gets the amount the viewport should be scrolled based on how far out of the | |
364 | * terminal the mouse is. | |
365 | * @param event The mouse event. | |
366 | */ | |
0dc3dd03 DI |
367 | private _getMouseEventScrollAmount(event: MouseEvent): number { |
368 | let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1]; | |
369 | const terminalHeight = this._terminal.rows * this._charMeasure.height; | |
370 | if (offset >= 0 && offset <= terminalHeight) { | |
371 | return 0; | |
372 | } | |
373 | if (offset > terminalHeight) { | |
374 | offset -= terminalHeight; | |
375 | } | |
376 | ||
377 | offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD); | |
378 | offset /= DRAG_SCROLL_MAX_THRESHOLD; | |
379 | return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1)); | |
380 | } | |
381 | ||
e63fdf58 DI |
382 | /** |
383 | * Handles te mousedown event, setting up for a new selection. | |
384 | * @param event The mousedown event. | |
385 | */ | |
70fda994 | 386 | private _onMouseDown(event: MouseEvent) { |
0dc3dd03 DI |
387 | // Only action the primary button |
388 | if (event.button !== 0) { | |
389 | return; | |
390 | } | |
391 | ||
2a0f44b0 DI |
392 | // Tell the browser not to start a regular selection |
393 | event.preventDefault(); | |
394 | ||
b8129910 DI |
395 | // Reset drag scroll state |
396 | this._dragScrollAmount = 0; | |
397 | ||
f380153f | 398 | this._setMouseClickCount(event); |
9f271de8 | 399 | |
db8ded2a DI |
400 | if (event.shiftKey) { |
401 | this._onShiftClick(event); | |
402 | } else { | |
403 | if (this._clickCount === 1) { | |
404 | this._onSingleClick(event); | |
405 | } else if (this._clickCount === 2) { | |
406 | this._onDoubleClick(event); | |
407 | } else if (this._clickCount === 3) { | |
408 | this._onTripleClick(event); | |
409 | } | |
9f271de8 | 410 | } |
e29ab294 | 411 | |
b8129910 | 412 | this._addMouseDownListeners(); |
8811d96a | 413 | this.refresh(true); |
b8129910 DI |
414 | } |
415 | ||
416 | /** | |
417 | * Adds listeners when mousedown is triggered. | |
418 | */ | |
419 | private _addMouseDownListeners(): void { | |
e29ab294 DI |
420 | // Listen on the document so that dragging outside of viewport works |
421 | this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); | |
422 | this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener); | |
2621be81 | 423 | this._dragScrollIntervalTimer = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); |
b8129910 DI |
424 | } |
425 | ||
426 | /** | |
427 | * Removes the listeners that are registered when mousedown is triggered. | |
428 | */ | |
429 | private _removeMouseDownListeners(): void { | |
430 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); | |
431 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
432 | clearInterval(this._dragScrollIntervalTimer); | |
433 | this._dragScrollIntervalTimer = null; | |
9f271de8 DI |
434 | } |
435 | ||
d0b603d0 DI |
436 | /** |
437 | * Performs a shift click, setting the selection end position to the mouse | |
438 | * position. | |
439 | * @param event The mouse event. | |
440 | */ | |
db8ded2a DI |
441 | private _onShiftClick(event: MouseEvent): void { |
442 | if (this._model.selectionStart) { | |
443 | this._model.selectionEnd = this._getMouseBufferCoords(event); | |
444 | } | |
445 | } | |
446 | ||
d0b603d0 DI |
447 | /** |
448 | * Performs a single click, resetting relevant state and setting the selection | |
449 | * start position. | |
450 | * @param event The mouse event. | |
451 | */ | |
9f271de8 | 452 | private _onSingleClick(event: MouseEvent): void { |
f7d6ab5f DI |
453 | this._model.selectionStartLength = 0; |
454 | this._model.isSelectAllActive = false; | |
320fb55d | 455 | this._activeSelectionMode = SelectionMode.NORMAL; |
f7d6ab5f DI |
456 | this._model.selectionStart = this._getMouseBufferCoords(event); |
457 | if (this._model.selectionStart) { | |
458 | this._model.selectionEnd = null; | |
54e7f65d DI |
459 | // If the mouse is over the second half of a wide character, adjust the |
460 | // selection to cover the whole character | |
461 | const char = this._buffer.get(this._model.selectionStart[1])[this._model.selectionStart[0]]; | |
462 | if (char[LINE_DATA_WIDTH_INDEX] === 0) { | |
463 | this._model.selectionStart[0]++; | |
464 | } | |
70fda994 DI |
465 | } |
466 | } | |
467 | ||
d0b603d0 DI |
468 | /** |
469 | * Performs a double click, selecting the current work. | |
470 | * @param event The mouse event. | |
471 | */ | |
9f271de8 DI |
472 | private _onDoubleClick(event: MouseEvent): void { |
473 | const coords = this._getMouseBufferCoords(event); | |
474 | if (coords) { | |
320fb55d | 475 | this._activeSelectionMode = SelectionMode.WORD; |
9f271de8 DI |
476 | this._selectWordAt(coords); |
477 | } | |
478 | } | |
479 | ||
d0b603d0 DI |
480 | /** |
481 | * Performs a triple click, selecting the current line and activating line | |
482 | * select mode. | |
483 | * @param event The mouse event. | |
484 | */ | |
9f271de8 DI |
485 | private _onTripleClick(event: MouseEvent): void { |
486 | const coords = this._getMouseBufferCoords(event); | |
487 | if (coords) { | |
320fb55d | 488 | this._activeSelectionMode = SelectionMode.LINE; |
9f271de8 DI |
489 | this._selectLineAt(coords[1]); |
490 | } | |
491 | } | |
492 | ||
d0b603d0 DI |
493 | /** |
494 | * Sets the number of clicks for the current mousedown event based on the time | |
495 | * and position of the last mousedown event. | |
496 | * @param event The mouse event. | |
497 | */ | |
f380153f | 498 | private _setMouseClickCount(event: MouseEvent): void { |
9f271de8 | 499 | let currentTime = (new Date()).getTime(); |
f380153f | 500 | if (currentTime - this._lastMouseDownTime > CLEAR_MOUSE_DOWN_TIME || this._distanceFromLastMousePosition(event) > CLEAR_MOUSE_DISTANCE) { |
9f271de8 | 501 | this._clickCount = 0; |
d3865ad2 DI |
502 | } |
503 | this._lastMouseDownTime = currentTime; | |
f380153f | 504 | this._lastMousePosition = [event.pageX, event.pageY]; |
9f271de8 | 505 | this._clickCount++; |
f380153f | 506 | } |
9f271de8 | 507 | |
d0b603d0 DI |
508 | /** |
509 | * Gets the maximum number of pixels in each direction the mouse has moved. | |
510 | * @param event The mouse event. | |
511 | */ | |
f380153f DI |
512 | private _distanceFromLastMousePosition(event: MouseEvent): number { |
513 | const result = Math.max( | |
514 | Math.abs(this._lastMousePosition[0] - event.pageX), | |
515 | Math.abs(this._lastMousePosition[1] - event.pageY)); | |
516 | return result; | |
9f271de8 DI |
517 | } |
518 | ||
e63fdf58 DI |
519 | /** |
520 | * Handles the mousemove event when the mouse button is down, recording the | |
521 | * end of the selection and refreshing the selection. | |
522 | * @param event The mousemove event. | |
523 | */ | |
70fda994 | 524 | private _onMouseMove(event: MouseEvent) { |
2621be81 DI |
525 | // Record the previous position so we know whether to redraw the selection |
526 | // at the end. | |
527 | const previousSelectionEnd = this._model.selectionEnd ? [this._model.selectionEnd[0], this._model.selectionEnd[1]] : null; | |
528 | ||
529 | // Set the initial selection end based on the mouse coordinates | |
530 | this._model.selectionEnd = this._getMouseBufferCoords(event); | |
531 | ||
532 | // Select the entire line if line select mode is active. | |
320fb55d | 533 | if (this._activeSelectionMode === SelectionMode.LINE) { |
5bc11121 DI |
534 | if (this._model.selectionEnd[1] < this._model.selectionStart[1]) { |
535 | this._model.selectionEnd[0] = 0; | |
536 | } else { | |
537 | this._model.selectionEnd[0] = this._terminal.cols; | |
538 | } | |
320fb55d | 539 | } else if (this._activeSelectionMode === SelectionMode.WORD) { |
4b170ca4 DI |
540 | this._selectToWordAt(this._model.selectionEnd); |
541 | } | |
542 | ||
2621be81 | 543 | // Determine the amount of scrolling that will happen. |
0dc3dd03 | 544 | this._dragScrollAmount = this._getMouseEventScrollAmount(event); |
2621be81 | 545 | |
0dc3dd03 | 546 | // If the cursor was above or below the viewport, make sure it's at the |
2621be81 | 547 | // start or end of the viewport respectively. |
0dc3dd03 | 548 | if (this._dragScrollAmount > 0) { |
f7d6ab5f | 549 | this._model.selectionEnd[0] = this._terminal.cols - 1; |
0dc3dd03 | 550 | } else if (this._dragScrollAmount < 0) { |
f7d6ab5f | 551 | this._model.selectionEnd[0] = 0; |
0dc3dd03 | 552 | } |
54e7f65d DI |
553 | |
554 | // If the character is a wide character include the cell to the right in the | |
5bc11121 DI |
555 | // selection. Note that selections at the very end of the line will never |
556 | // have a character. | |
71477874 DI |
557 | if (this._model.selectionEnd[1] < this._buffer.length) { |
558 | const char = this._buffer.get(this._model.selectionEnd[1])[this._model.selectionEnd[0]]; | |
559 | if (char && char[2] === 0) { | |
560 | this._model.selectionEnd[0]++; | |
561 | } | |
54e7f65d DI |
562 | } |
563 | ||
2621be81 DI |
564 | // Only draw here if the selection changes. |
565 | if (!previousSelectionEnd || | |
566 | previousSelectionEnd[0] !== this._model.selectionEnd[0] || | |
567 | previousSelectionEnd[1] !== this._model.selectionEnd[1]) { | |
dc165175 | 568 | this.refresh(true); |
2621be81 | 569 | } |
70fda994 DI |
570 | } |
571 | ||
d0b603d0 DI |
572 | /** |
573 | * The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the | |
574 | * scrolling of the viewport. | |
575 | */ | |
0dc3dd03 DI |
576 | private _dragScroll() { |
577 | if (this._dragScrollAmount) { | |
578 | this._terminal.scrollDisp(this._dragScrollAmount, false); | |
579 | // Re-evaluate selection | |
580 | if (this._dragScrollAmount > 0) { | |
f7d6ab5f | 581 | this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows]; |
0dc3dd03 | 582 | } else { |
f7d6ab5f | 583 | this._model.selectionEnd = [0, this._terminal.ydisp]; |
0dc3dd03 DI |
584 | } |
585 | this.refresh(); | |
586 | } | |
587 | } | |
588 | ||
e63fdf58 | 589 | /** |
b8129910 | 590 | * Handles the mouseup event, removing the mousedown listeners. |
e63fdf58 DI |
591 | * @param event The mouseup event. |
592 | */ | |
70fda994 | 593 | private _onMouseUp(event: MouseEvent) { |
b8129910 | 594 | this._removeMouseDownListeners(); |
70fda994 | 595 | } |
597c6939 | 596 | |
cb6533f3 DI |
597 | /** |
598 | * Converts a viewport column to the character index on the buffer line, the | |
599 | * latter takes into account wide characters. | |
d3865ad2 | 600 | * @param coords The coordinates to find the 2 index for. |
cb6533f3 | 601 | */ |
11cec31d | 602 | private _convertViewportColToCharacterIndex(bufferLine: any, coords: [number, number]): number { |
cb6533f3 | 603 | let charIndex = coords[0]; |
d9991b25 | 604 | for (let i = 0; coords[0] >= i; i++) { |
11cec31d | 605 | const char = bufferLine[i]; |
cb6533f3 DI |
606 | if (char[LINE_DATA_WIDTH_INDEX] === 0) { |
607 | charIndex--; | |
608 | } | |
609 | } | |
610 | return charIndex; | |
611 | } | |
612 | ||
597c6939 | 613 | /** |
4b170ca4 | 614 | * Gets positional information for the word at the coordinated specified. |
597c6939 DI |
615 | * @param coords The coordinates to get the word at. |
616 | */ | |
4b170ca4 | 617 | private _getWordAt(coords: [number, number]): IWordPosition { |
11cec31d DI |
618 | const bufferLine = this._buffer.get(coords[1]); |
619 | const line = this._translateBufferLineToString(bufferLine, false); | |
cb6533f3 DI |
620 | |
621 | // Get actual index, taking into consideration wide characters | |
11cec31d | 622 | let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); |
cb6533f3 | 623 | let startIndex = endIndex; |
cb6533f3 DI |
624 | |
625 | // Record offset to be used later | |
626 | const charOffset = coords[0] - startIndex; | |
11cec31d DI |
627 | let leftWideCharCount = 0; |
628 | let rightWideCharCount = 0; | |
cb6533f3 | 629 | |
cb6533f3 | 630 | if (line.charAt(startIndex) === ' ') { |
11cec31d | 631 | // Expand until non-whitespace is hit |
cb6533f3 DI |
632 | while (startIndex > 0 && line.charAt(startIndex - 1) === ' ') { |
633 | startIndex--; | |
634 | } | |
635 | while (endIndex < line.length && line.charAt(endIndex + 1) === ' ') { | |
636 | endIndex++; | |
637 | } | |
cb6533f3 | 638 | } else { |
11cec31d DI |
639 | // Expand until whitespace is hit. This algorithm works by scanning left |
640 | // and right from the starting position, keeping both the index format | |
641 | // (line) and the column format (bufferLine) in sync. When a wide | |
642 | // character is hit, it is recorded and the column index is adjusted. | |
d9991b25 DI |
643 | let startCol = coords[0]; |
644 | let endCol = coords[0]; | |
645 | // Consider the initial position, skip it and increment the wide char | |
646 | // variable | |
11cec31d | 647 | if (bufferLine[startCol][LINE_DATA_WIDTH_INDEX] === 0) { |
d9991b25 DI |
648 | leftWideCharCount++; |
649 | startCol--; | |
650 | } | |
11cec31d | 651 | if (bufferLine[endCol][LINE_DATA_WIDTH_INDEX] === 2) { |
d9991b25 DI |
652 | rightWideCharCount++; |
653 | endCol++; | |
654 | } | |
cb6533f3 | 655 | // Expand the string in both directions until a space is hit |
7f2e94c3 | 656 | while (startIndex > 0 && !this._isCharWordSeparator(line.charAt(startIndex - 1))) { |
11cec31d DI |
657 | if (bufferLine[startCol - 1][LINE_DATA_WIDTH_INDEX] === 0) { |
658 | // If the next character is a wide char, record it and skip the column | |
d9991b25 DI |
659 | leftWideCharCount++; |
660 | startCol--; | |
cb6533f3 DI |
661 | } |
662 | startIndex--; | |
d9991b25 | 663 | startCol--; |
cb6533f3 | 664 | } |
7f2e94c3 | 665 | while (endIndex + 1 < line.length && !this._isCharWordSeparator(line.charAt(endIndex + 1))) { |
11cec31d DI |
666 | if (bufferLine[endCol + 1][LINE_DATA_WIDTH_INDEX] === 2) { |
667 | // If the next character is a wide char, record it and skip the column | |
d9991b25 DI |
668 | rightWideCharCount++; |
669 | endCol++; | |
cb6533f3 DI |
670 | } |
671 | endIndex++; | |
d9991b25 | 672 | endCol++; |
cb6533f3 | 673 | } |
597c6939 | 674 | } |
11cec31d | 675 | |
4b170ca4 DI |
676 | const start = startIndex + charOffset - leftWideCharCount; |
677 | const length = Math.min(endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/, this._terminal.cols); | |
678 | return {start, length}; | |
679 | } | |
680 | ||
681 | /** | |
682 | * Selects the word at the coordinates specified. | |
683 | * @param coords The coordinates to get the word at. | |
684 | */ | |
685 | protected _selectWordAt(coords: [number, number]): void { | |
686 | const wordPosition = this._getWordAt(coords); | |
687 | this._model.selectionStart = [wordPosition.start, coords[1]]; | |
688 | this._model.selectionStartLength = wordPosition.length; | |
689 | } | |
690 | ||
691 | /** | |
692 | * Sets the selection end to the word at the coordinated specified. | |
693 | * @param coords The coordinates to get the word at. | |
694 | */ | |
695 | private _selectToWordAt(coords: [number, number]): void { | |
696 | const wordPosition = this._getWordAt(coords); | |
697 | this._model.selectionEnd = [this._model.areSelectionValuesReversed() ? wordPosition.start : (wordPosition.start + wordPosition.length), coords[1]]; | |
597c6939 | 698 | } |
9f271de8 | 699 | |
7f2e94c3 DI |
700 | /** |
701 | * Gets whether the character is considered a word separator by the select | |
702 | * word logic. | |
703 | * @param char The character to check. | |
704 | */ | |
705 | private _isCharWordSeparator(char: string): boolean { | |
706 | return WORD_SEPARATORS.indexOf(char) >= 0; | |
707 | } | |
708 | ||
d0b603d0 DI |
709 | /** |
710 | * Selects the line specified. | |
711 | * @param line The line index. | |
712 | */ | |
fd91c5e1 | 713 | protected _selectLineAt(line: number): void { |
f7d6ab5f DI |
714 | this._model.selectionStart = [0, line]; |
715 | this._model.selectionStartLength = this._terminal.cols; | |
9f271de8 | 716 | } |
70fda994 | 717 | } |