]> git.proxmox.com Git - mirror_xterm.js.git/blob - src/SelectionManager.ts
Only trim the right whitespace from selection
[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 export class SelectionManager extends EventEmitter {
35 private _model: SelectionModel;
36
37 /**
38 * The amount to scroll every drag scroll update (depends on how far the mouse
39 * drag is above or below the terminal).
40 */
41 private _dragScrollAmount: number;
42
43 /**
44 * The last time the mousedown event fired, this is used to track double and
45 * triple clicks.
46 */
47 private _lastMouseDownTime: number;
48
49 private _clickCount: number;
50
51 private _bufferTrimListener: any;
52 private _mouseMoveListener: EventListener;
53 private _mouseDownListener: EventListener;
54 private _mouseUpListener: EventListener;
55
56 private _dragScrollTimeout: NodeJS.Timer;
57
58 constructor(
59 private _terminal: ITerminal,
60 private _buffer: CircularList<any>,
61 private _rowContainer: HTMLElement,
62 private _selectionContainer: HTMLElement,
63 private _charMeasure: CharMeasure
64 ) {
65 super();
66 this._initListeners();
67 this.enable();
68
69 this._model = new SelectionModel(_terminal);
70 this._lastMouseDownTime = 0;
71 }
72
73 private _initListeners() {
74 this._bufferTrimListener = (amount: number) => this._onTrim(amount);
75 this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event);
76 this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event);
77 this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event);
78 }
79
80 /**
81 * Disables the selection manager. This is useful for when terminal mouse
82 * are enabled.
83 */
84 public disable() {
85 this._model.selectionStart = null;
86 this._model.selectionEnd = null;
87 this.refresh();
88 this._buffer.off('trim', this._bufferTrimListener);
89 this._rowContainer.removeEventListener('mousedown', this._mouseDownListener);
90 this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
91 this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
92 clearInterval(this._dragScrollTimeout);
93 }
94
95 /**
96 * Enable the selection manager.
97 */
98 public enable() {
99 this._buffer.on('trim', this._bufferTrimListener);
100 this._rowContainer.addEventListener('mousedown', this._mouseDownListener);
101 }
102
103 /**
104 * Gets the text currently selected.
105 */
106 public get selectionText(): string {
107 const start = this._model.finalSelectionStart;
108 const end = this._model.finalSelectionEnd;
109 if (!start || !end) {
110 return '';
111 }
112
113 // Get first row
114 const startRowEndCol = start[1] === end[1] ? end[0] : null;
115 let result: string[] = [];
116 result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol));
117
118 // Get middle rows
119 for (let i = start[1] + 1; i <= end[1] - 1; i++) {
120 result.push(this._translateBufferLineToString(this._buffer.get(i), true));
121 }
122
123 // Get final row
124 if (start[1] !== end[1]) {
125 result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0]));
126 }
127 console.log('selectionText result: ' + result);
128 return result.join('\n');
129 }
130
131 private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string {
132 // TODO: This function should live in a buffer or buffer line class
133 // TODO: Handle the double-width character case
134
135 // Get full line
136 let lineString = '';
137 for (let i = 0; i < line.length; i++) {
138 lineString += line[i][1];
139 }
140
141 let finalEndCol = endCol || line.length
142
143 if (trimRight) {
144 const rightWhitespaceIndex = lineString.search(/\s+$/);
145 finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex);
146 // Return the empty string if only trimmed whitespace is selected
147 if (finalEndCol <= startCol) {
148 return '';
149 }
150 }
151
152 return lineString.substring(startCol, finalEndCol);
153 }
154
155 /**
156 * Redraws the selection.
157 */
158 public refresh(): void {
159 // TODO: Figure out when to refresh the selection vs when to refresh the viewport
160 this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd });
161 }
162
163 /**
164 * Selects all text within the terminal.
165 */
166 public selectAll(): void {
167 this._model.isSelectAllActive = true;
168 this.refresh();
169 }
170
171 /**
172 * Handle the buffer being trimmed, adjust the selection position.
173 * @param amount The amount the buffer is being trimmed.
174 */
175 private _onTrim(amount: number) {
176 const needsRefresh = this._model.onTrim(amount);
177 if (needsRefresh) {
178 this.refresh();
179 }
180 }
181
182 // TODO: Handle splice/shiftElements in the buffer (just clear the selection?)
183
184 private _getMouseBufferCoords(event: MouseEvent): [number, number] {
185 const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows);
186 console.log(coords);
187 // Convert to 0-based
188 coords[0]--;
189 coords[1]--;
190 // Convert viewport coords to buffer coords
191 coords[1] += this._terminal.ydisp;
192 return coords;
193 }
194
195 private _getMouseEventScrollAmount(event: MouseEvent): number {
196 let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1];
197 const terminalHeight = this._terminal.rows * this._charMeasure.height;
198 if (offset >= 0 && offset <= terminalHeight) {
199 return 0;
200 }
201 if (offset > terminalHeight) {
202 offset -= terminalHeight;
203 }
204
205 offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD);
206 offset /= DRAG_SCROLL_MAX_THRESHOLD;
207 return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1));
208 }
209
210 /**
211 * Handles te mousedown event, setting up for a new selection.
212 * @param event The mousedown event.
213 */
214 private _onMouseDown(event: MouseEvent) {
215 // Only action the primary button
216 if (event.button !== 0) {
217 return;
218 }
219
220 this._setMouseClickCount();
221 console.log(this._clickCount);
222
223 if (this._clickCount === 1) {
224 this._onSingleClick(event);
225 } else if (this._clickCount === 2) {
226 this._onDoubleClick(event);
227 } else if (this._clickCount === 3) {
228 this._onTripleClick(event);
229 }
230
231 // Listen on the document so that dragging outside of viewport works
232 this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener);
233 this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener);
234 this._dragScrollTimeout = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL);
235 this.refresh();
236 }
237
238 private _onSingleClick(event: MouseEvent): void {
239 this._model.selectionStartLength = 0;
240 this._model.isSelectAllActive = false;
241 this._model.selectionStart = this._getMouseBufferCoords(event);
242 if (this._model.selectionStart) {
243 this._model.selectionEnd = null;
244 }
245 }
246
247 private _onDoubleClick(event: MouseEvent): void {
248 const coords = this._getMouseBufferCoords(event);
249 if (coords) {
250 this._selectWordAt(coords);
251 }
252 }
253
254 private _onTripleClick(event: MouseEvent): void {
255 const coords = this._getMouseBufferCoords(event);
256 if (coords) {
257 this._selectLineAt(coords[1]);
258 }
259 }
260
261 private _setMouseClickCount(): void {
262 let currentTime = (new Date()).getTime();
263 if (currentTime - this._lastMouseDownTime > CLEAR_MOUSE_DOWN_TIME) {
264 this._clickCount = 0;
265 }
266 this._lastMouseDownTime = currentTime;
267 this._clickCount++;
268
269 // TODO: Invalidate click count if the position is different
270 }
271
272 /**
273 * Handles the mousemove event when the mouse button is down, recording the
274 * end of the selection and refreshing the selection.
275 * @param event The mousemove event.
276 */
277 private _onMouseMove(event: MouseEvent) {
278 this._model.selectionEnd = this._getMouseBufferCoords(event);
279 // TODO: Perhaps the actual selection setting could be merged into _dragScroll?
280 this._dragScrollAmount = this._getMouseEventScrollAmount(event);
281 // If the cursor was above or below the viewport, make sure it's at the
282 // start or end of the viewport respectively
283 if (this._dragScrollAmount > 0) {
284 this._model.selectionEnd[0] = this._terminal.cols - 1;
285 } else if (this._dragScrollAmount < 0) {
286 this._model.selectionEnd[0] = 0;
287 }
288 // TODO: Only draw here if the selection changes
289 this.refresh();
290 console.log('start: ', this._model.selectionStart);
291 console.log('start final: ', this._model.finalSelectionStart);
292 console.log('end: ', this._model.selectionEnd);
293 console.log('end final: ', this._model.finalSelectionEnd);
294 }
295
296 private _dragScroll() {
297 if (this._dragScrollAmount) {
298 this._terminal.scrollDisp(this._dragScrollAmount, false);
299 // Re-evaluate selection
300 if (this._dragScrollAmount > 0) {
301 this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows];
302 } else {
303 this._model.selectionEnd = [0, this._terminal.ydisp];
304 }
305 this.refresh();
306 }
307 }
308
309 /**
310 * Handles the mouseup event, removing the mousemove listener when
311 * appropriate.
312 * @param event The mouseup event.
313 */
314 private _onMouseUp(event: MouseEvent) {
315 this._dragScrollAmount = 0;
316 if (!this._model.selectionStart) {
317 return;
318 }
319 this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
320 this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
321 }
322
323 /**
324 * Selects the word at the coordinates specified. Words are defined as all
325 * non-whitespace characters.
326 * @param coords The coordinates to get the word at.
327 */
328 private _selectWordAt(coords: [number, number]): void {
329 const line = this._translateBufferLineToString(this._buffer.get(coords[1]), false);
330 // Expand the string in both directions until a space is hit
331 let startCol = coords[0];
332 let endCol = coords[0];
333 while (startCol > 0 && line.charAt(startCol - 1) !== ' ') {
334 startCol--;
335 }
336 while (endCol < line.length && line.charAt(endCol) !== ' ') {
337 endCol++;
338 }
339 this._model.selectionStart = [startCol, coords[1]];
340 this._model.selectionStartLength = endCol - startCol;
341 }
342
343 private _selectLineAt(line: number): void {
344 this._model.selectionStart = [0, line];
345 this._model.selectionStartLength = this._terminal.cols;
346 }
347 }