]>
Commit | Line | Data |
---|---|---|
70fda994 DI |
1 | /** |
2 | * @license MIT | |
3 | */ | |
4 | ||
5 | import { CharMeasure } from './utils/CharMeasure'; | |
6 | import { CircularList } from './utils/CircularList'; | |
b594407c | 7 | import { EventEmitter } from './EventEmitter'; |
70fda994 | 8 | import * as Mouse from './utils/Mouse'; |
ad3ae67e | 9 | import { ITerminal } from './Interfaces'; |
f7d6ab5f | 10 | import { SelectionModel } from './SelectionModel'; |
70fda994 | 11 | |
0dc3dd03 DI |
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 | ||
9f271de8 DI |
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 | ||
54e7f65d DI |
34 | // TODO: Move these constants elsewhere |
35 | const LINE_DATA_CHAR_INDEX = 1; | |
36 | const LINE_DATA_WIDTH_INDEX = 2; | |
37 | ||
b594407c | 38 | export class SelectionManager extends EventEmitter { |
f7d6ab5f | 39 | private _model: SelectionModel; |
9f271de8 DI |
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 | */ | |
0dc3dd03 | 45 | private _dragScrollAmount: number; |
70fda994 | 46 | |
9f271de8 DI |
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 | ||
ab40908f | 55 | private _bufferTrimListener: any; |
70fda994 | 56 | private _mouseMoveListener: EventListener; |
ab40908f DI |
57 | private _mouseDownListener: EventListener; |
58 | private _mouseUpListener: EventListener; | |
70fda994 | 59 | |
0dc3dd03 DI |
60 | private _dragScrollTimeout: NodeJS.Timer; |
61 | ||
ad3ae67e DI |
62 | constructor( |
63 | private _terminal: ITerminal, | |
64 | private _buffer: CircularList<any>, | |
65 | private _rowContainer: HTMLElement, | |
ad3ae67e DI |
66 | private _charMeasure: CharMeasure |
67 | ) { | |
b594407c | 68 | super(); |
ab40908f DI |
69 | this._initListeners(); |
70 | this.enable(); | |
9f271de8 | 71 | |
f7d6ab5f | 72 | this._model = new SelectionModel(_terminal); |
9f271de8 | 73 | this._lastMouseDownTime = 0; |
70fda994 DI |
74 | } |
75 | ||
ab40908f DI |
76 | private _initListeners() { |
77 | this._bufferTrimListener = (amount: number) => this._onTrim(amount); | |
70fda994 | 78 | this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event); |
ab40908f DI |
79 | this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event); |
80 | this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event); | |
ab40908f | 81 | } |
70fda994 | 82 | |
ab40908f DI |
83 | /** |
84 | * Disables the selection manager. This is useful for when terminal mouse | |
85 | * are enabled. | |
86 | */ | |
87 | public disable() { | |
f7d6ab5f DI |
88 | this._model.selectionStart = null; |
89 | this._model.selectionEnd = null; | |
ab40908f DI |
90 | this.refresh(); |
91 | this._buffer.off('trim', this._bufferTrimListener); | |
92 | this._rowContainer.removeEventListener('mousedown', this._mouseDownListener); | |
0dc3dd03 DI |
93 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
94 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
95 | clearInterval(this._dragScrollTimeout); | |
ab40908f DI |
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); | |
70fda994 DI |
104 | } |
105 | ||
9f271de8 DI |
106 | /** |
107 | * Gets the text currently selected. | |
108 | */ | |
70fda994 | 109 | public get selectionText(): string { |
f7d6ab5f DI |
110 | const start = this._model.finalSelectionStart; |
111 | const end = this._model.finalSelectionEnd; | |
e29ab294 | 112 | if (!start || !end) { |
293ae18a | 113 | return ''; |
70fda994 | 114 | } |
43c796a7 | 115 | |
43c796a7 | 116 | // Get first row |
293ae18a | 117 | const startRowEndCol = start[1] === end[1] ? end[0] : null; |
32b34cbe | 118 | let result: string[] = []; |
ec61f3ac | 119 | result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol)); |
43c796a7 DI |
120 | |
121 | // Get middle rows | |
293ae18a | 122 | for (let i = start[1] + 1; i <= end[1] - 1; i++) { |
ec61f3ac | 123 | result.push(this._translateBufferLineToString(this._buffer.get(i), true)); |
32b34cbe | 124 | } |
43c796a7 DI |
125 | |
126 | // Get final row | |
293ae18a | 127 | if (start[1] !== end[1]) { |
ec61f3ac | 128 | result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0])); |
32b34cbe | 129 | } |
54e7f65d | 130 | console.log('selectionText result: "' + result + '"'); |
32b34cbe DI |
131 | return result.join('\n'); |
132 | } | |
133 | ||
ec61f3ac | 134 | private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string { |
32b34cbe | 135 | // TODO: This function should live in a buffer or buffer line class |
ec61f3ac DI |
136 | |
137 | // Get full line | |
138 | let lineString = ''; | |
54e7f65d DI |
139 | let widthAdjustedStartCol = startCol; |
140 | let widthAdjustedEndCol = endCol; | |
ec61f3ac | 141 | for (let i = 0; i < line.length; i++) { |
54e7f65d DI |
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 | } | |
ec61f3ac DI |
154 | } |
155 | ||
54e7f65d DI |
156 | // Calculate the final end col by trimming whitespace on the right of the |
157 | // line if needed. | |
158 | let finalEndCol = widthAdjustedEndCol || line.length | |
ec61f3ac DI |
159 | if (trimRight) { |
160 | const rightWhitespaceIndex = lineString.search(/\s+$/); | |
a53a0acd DI |
161 | if (rightWhitespaceIndex !== -1) { |
162 | finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex); | |
163 | } | |
ec61f3ac | 164 | // Return the empty string if only trimmed whitespace is selected |
54e7f65d | 165 | if (finalEndCol <= widthAdjustedStartCol) { |
ec61f3ac DI |
166 | return ''; |
167 | } | |
168 | } | |
169 | ||
54e7f65d | 170 | return lineString.substring(widthAdjustedStartCol, finalEndCol); |
70fda994 DI |
171 | } |
172 | ||
207c4cf9 DI |
173 | /** |
174 | * Redraws the selection. | |
175 | */ | |
176 | public refresh(): void { | |
b594407c | 177 | // TODO: Figure out when to refresh the selection vs when to refresh the viewport |
f7d6ab5f | 178 | this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd }); |
25152e44 DI |
179 | } |
180 | ||
181 | /** | |
182 | * Selects all text within the terminal. | |
183 | */ | |
184 | public selectAll(): void { | |
f7d6ab5f | 185 | this._model.isSelectAllActive = true; |
25152e44 | 186 | this.refresh(); |
207c4cf9 DI |
187 | } |
188 | ||
189 | /** | |
190 | * Handle the buffer being trimmed, adjust the selection position. | |
191 | * @param amount The amount the buffer is being trimmed. | |
192 | */ | |
70fda994 | 193 | private _onTrim(amount: number) { |
f7d6ab5f DI |
194 | const needsRefresh = this._model.onTrim(amount); |
195 | if (needsRefresh) { | |
207c4cf9 | 196 | this.refresh(); |
207c4cf9 | 197 | } |
70fda994 DI |
198 | } |
199 | ||
32b34cbe DI |
200 | // TODO: Handle splice/shiftElements in the buffer (just clear the selection?) |
201 | ||
0dc3dd03 DI |
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); | |
ad3ae67e DI |
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; | |
b36d8780 DI |
211 | } |
212 | ||
0dc3dd03 DI |
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 | ||
e63fdf58 DI |
228 | /** |
229 | * Handles te mousedown event, setting up for a new selection. | |
230 | * @param event The mousedown event. | |
231 | */ | |
70fda994 | 232 | private _onMouseDown(event: MouseEvent) { |
0dc3dd03 DI |
233 | // Only action the primary button |
234 | if (event.button !== 0) { | |
235 | return; | |
236 | } | |
237 | ||
9f271de8 DI |
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 | } | |
e29ab294 DI |
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(); | |
9f271de8 DI |
254 | } |
255 | ||
256 | private _onSingleClick(event: MouseEvent): void { | |
f7d6ab5f DI |
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; | |
54e7f65d DI |
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 | } | |
70fda994 DI |
268 | } |
269 | } | |
270 | ||
9f271de8 DI |
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 | ||
e63fdf58 DI |
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 | */ | |
70fda994 | 301 | private _onMouseMove(event: MouseEvent) { |
f7d6ab5f | 302 | this._model.selectionEnd = this._getMouseBufferCoords(event); |
0dc3dd03 DI |
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) { | |
f7d6ab5f | 308 | this._model.selectionEnd[0] = this._terminal.cols - 1; |
0dc3dd03 | 309 | } else if (this._dragScrollAmount < 0) { |
f7d6ab5f | 310 | this._model.selectionEnd[0] = 0; |
0dc3dd03 | 311 | } |
54e7f65d DI |
312 | |
313 | // If the character is a wide character include the cell to the right in the | |
314 | // selection. | |
cb6533f3 DI |
315 | const char = this._buffer.get(this._model.selectionEnd[1])[this._model.selectionEnd[0]]; |
316 | if (char[2] === 0) { | |
54e7f65d DI |
317 | this._model.selectionEnd[0]++; |
318 | } | |
319 | ||
207c4cf9 DI |
320 | // TODO: Only draw here if the selection changes |
321 | this.refresh(); | |
70fda994 DI |
322 | } |
323 | ||
0dc3dd03 DI |
324 | private _dragScroll() { |
325 | if (this._dragScrollAmount) { | |
326 | this._terminal.scrollDisp(this._dragScrollAmount, false); | |
327 | // Re-evaluate selection | |
328 | if (this._dragScrollAmount > 0) { | |
f7d6ab5f | 329 | this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows]; |
0dc3dd03 | 330 | } else { |
f7d6ab5f | 331 | this._model.selectionEnd = [0, this._terminal.ydisp]; |
0dc3dd03 DI |
332 | } |
333 | this.refresh(); | |
334 | } | |
335 | } | |
336 | ||
e63fdf58 DI |
337 | /** |
338 | * Handles the mouseup event, removing the mousemove listener when | |
339 | * appropriate. | |
340 | * @param event The mouseup event. | |
341 | */ | |
70fda994 | 342 | private _onMouseUp(event: MouseEvent) { |
b3b2bd1f | 343 | this._dragScrollAmount = 0; |
f7d6ab5f | 344 | if (!this._model.selectionStart) { |
70fda994 DI |
345 | return; |
346 | } | |
0dc3dd03 DI |
347 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
348 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
70fda994 | 349 | } |
597c6939 | 350 | |
cb6533f3 DI |
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 | */ | |
11cec31d | 356 | private _convertViewportColToCharacterIndex(bufferLine: any, coords: [number, number]): number { |
cb6533f3 | 357 | let charIndex = coords[0]; |
d9991b25 | 358 | for (let i = 0; coords[0] >= i; i++) { |
11cec31d | 359 | const char = bufferLine[i]; |
cb6533f3 DI |
360 | if (char[LINE_DATA_WIDTH_INDEX] === 0) { |
361 | charIndex--; | |
362 | } | |
363 | } | |
364 | return charIndex; | |
365 | } | |
366 | ||
597c6939 DI |
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 | */ | |
a53a0acd | 372 | protected _selectWordAt(coords: [number, number]): void { |
cb6533f3 | 373 | // TODO: Only fetch buffer line once for translate and convert functions |
11cec31d DI |
374 | const bufferLine = this._buffer.get(coords[1]); |
375 | const line = this._translateBufferLineToString(bufferLine, false); | |
cb6533f3 DI |
376 | |
377 | // Get actual index, taking into consideration wide characters | |
11cec31d | 378 | let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); |
cb6533f3 | 379 | let startIndex = endIndex; |
cb6533f3 DI |
380 | |
381 | // Record offset to be used later | |
382 | const charOffset = coords[0] - startIndex; | |
11cec31d DI |
383 | let leftWideCharCount = 0; |
384 | let rightWideCharCount = 0; | |
cb6533f3 | 385 | |
cb6533f3 | 386 | if (line.charAt(startIndex) === ' ') { |
11cec31d | 387 | // Expand until non-whitespace is hit |
cb6533f3 DI |
388 | while (startIndex > 0 && line.charAt(startIndex - 1) === ' ') { |
389 | startIndex--; | |
390 | } | |
391 | while (endIndex < line.length && line.charAt(endIndex + 1) === ' ') { | |
392 | endIndex++; | |
393 | } | |
cb6533f3 | 394 | } else { |
11cec31d DI |
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. | |
d9991b25 DI |
399 | let startCol = coords[0]; |
400 | let endCol = coords[0]; | |
401 | // Consider the initial position, skip it and increment the wide char | |
402 | // variable | |
11cec31d | 403 | if (bufferLine[startCol][LINE_DATA_WIDTH_INDEX] === 0) { |
d9991b25 DI |
404 | leftWideCharCount++; |
405 | startCol--; | |
406 | } | |
11cec31d | 407 | if (bufferLine[endCol][LINE_DATA_WIDTH_INDEX] === 2) { |
d9991b25 DI |
408 | rightWideCharCount++; |
409 | endCol++; | |
410 | } | |
cb6533f3 DI |
411 | // Expand the string in both directions until a space is hit |
412 | while (startIndex > 0 && line.charAt(startIndex - 1) !== ' ') { | |
11cec31d DI |
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 | |
d9991b25 DI |
415 | leftWideCharCount++; |
416 | startCol--; | |
cb6533f3 DI |
417 | } |
418 | startIndex--; | |
d9991b25 | 419 | startCol--; |
cb6533f3 DI |
420 | } |
421 | while (endIndex < line.length && line.charAt(endIndex + 1) !== ' ') { | |
11cec31d DI |
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 | |
d9991b25 DI |
424 | rightWideCharCount++; |
425 | endCol++; | |
cb6533f3 DI |
426 | } |
427 | endIndex++; | |
d9991b25 | 428 | endCol++; |
cb6533f3 | 429 | } |
597c6939 | 430 | } |
11cec31d DI |
431 | |
432 | // Record the resulting selection | |
d9991b25 | 433 | this._model.selectionStart = [startIndex + charOffset - leftWideCharCount, coords[1]]; |
d9991b25 | 434 | this._model.selectionStartLength = endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/; |
597c6939 | 435 | } |
9f271de8 DI |
436 | |
437 | private _selectLineAt(line: number): void { | |
f7d6ab5f DI |
438 | this._model.selectionStart = [0, line]; |
439 | this._model.selectionStartLength = this._terminal.cols; | |
9f271de8 | 440 | } |
70fda994 | 441 | } |