]> git.proxmox.com Git - mirror_xterm.js.git/blame - src/SelectionManager.ts
Remove no longer valid clipboard test and add new one
[mirror_xterm.js.git] / src / SelectionManager.ts
CommitLineData
70fda994
DI
1/**
2 * @license MIT
3 */
4
5import { CharMeasure } from './utils/CharMeasure';
6import { CircularList } from './utils/CircularList';
b594407c 7import { EventEmitter } from './EventEmitter';
70fda994 8import * as Mouse from './utils/Mouse';
ad3ae67e 9import { ITerminal } from './Interfaces';
f7d6ab5f 10import { 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 */
16const DRAG_SCROLL_MAX_THRESHOLD = 100;
17
18/**
19 * The maximum scrolling speed
20 */
21const DRAG_SCROLL_MAX_SPEED = 5;
22
23/**
24 * The number of milliseconds between drag scroll updates.
25 */
26const 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 */
32const CLEAR_MOUSE_DOWN_TIME = 400;
33
54e7f65d
DI
34// TODO: Move these constants elsewhere
35const LINE_DATA_CHAR_INDEX = 1;
36const LINE_DATA_WIDTH_INDEX = 2;
37
b594407c 38export 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}