]>
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 | ||
b594407c | 34 | export class SelectionManager extends EventEmitter { |
f7d6ab5f | 35 | private _model: SelectionModel; |
9f271de8 DI |
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 | */ | |
0dc3dd03 | 41 | private _dragScrollAmount: number; |
70fda994 | 42 | |
9f271de8 DI |
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 | ||
ab40908f | 51 | private _bufferTrimListener: any; |
70fda994 | 52 | private _mouseMoveListener: EventListener; |
ab40908f DI |
53 | private _mouseDownListener: EventListener; |
54 | private _mouseUpListener: EventListener; | |
70fda994 | 55 | |
0dc3dd03 DI |
56 | private _dragScrollTimeout: NodeJS.Timer; |
57 | ||
ad3ae67e DI |
58 | constructor( |
59 | private _terminal: ITerminal, | |
60 | private _buffer: CircularList<any>, | |
61 | private _rowContainer: HTMLElement, | |
62 | private _selectionContainer: HTMLElement, | |
63 | private _charMeasure: CharMeasure | |
64 | ) { | |
b594407c | 65 | super(); |
ab40908f DI |
66 | this._initListeners(); |
67 | this.enable(); | |
9f271de8 | 68 | |
f7d6ab5f | 69 | this._model = new SelectionModel(_terminal); |
9f271de8 | 70 | this._lastMouseDownTime = 0; |
70fda994 DI |
71 | } |
72 | ||
ab40908f DI |
73 | private _initListeners() { |
74 | this._bufferTrimListener = (amount: number) => this._onTrim(amount); | |
70fda994 | 75 | this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event); |
ab40908f DI |
76 | this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event); |
77 | this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event); | |
ab40908f | 78 | } |
70fda994 | 79 | |
ab40908f DI |
80 | /** |
81 | * Disables the selection manager. This is useful for when terminal mouse | |
82 | * are enabled. | |
83 | */ | |
84 | public disable() { | |
f7d6ab5f DI |
85 | this._model.selectionStart = null; |
86 | this._model.selectionEnd = null; | |
ab40908f DI |
87 | this.refresh(); |
88 | this._buffer.off('trim', this._bufferTrimListener); | |
89 | this._rowContainer.removeEventListener('mousedown', this._mouseDownListener); | |
0dc3dd03 DI |
90 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
91 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
92 | clearInterval(this._dragScrollTimeout); | |
ab40908f DI |
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); | |
70fda994 DI |
101 | } |
102 | ||
9f271de8 DI |
103 | /** |
104 | * Gets the text currently selected. | |
105 | */ | |
70fda994 | 106 | public get selectionText(): string { |
f7d6ab5f DI |
107 | const start = this._model.finalSelectionStart; |
108 | const end = this._model.finalSelectionEnd; | |
e29ab294 | 109 | if (!start || !end) { |
293ae18a | 110 | return ''; |
70fda994 | 111 | } |
43c796a7 | 112 | |
43c796a7 | 113 | // Get first row |
293ae18a | 114 | const startRowEndCol = start[1] === end[1] ? end[0] : null; |
32b34cbe | 115 | let result: string[] = []; |
ec61f3ac | 116 | result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol)); |
43c796a7 DI |
117 | |
118 | // Get middle rows | |
293ae18a | 119 | for (let i = start[1] + 1; i <= end[1] - 1; i++) { |
ec61f3ac | 120 | result.push(this._translateBufferLineToString(this._buffer.get(i), true)); |
32b34cbe | 121 | } |
43c796a7 DI |
122 | |
123 | // Get final row | |
293ae18a | 124 | if (start[1] !== end[1]) { |
ec61f3ac | 125 | result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0])); |
32b34cbe | 126 | } |
597c6939 | 127 | console.log('selectionText result: ' + result); |
32b34cbe DI |
128 | return result.join('\n'); |
129 | } | |
130 | ||
ec61f3ac | 131 | private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string { |
32b34cbe | 132 | // TODO: This function should live in a buffer or buffer line class |
32b34cbe | 133 | // TODO: Handle the double-width character case |
ec61f3ac DI |
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); | |
70fda994 DI |
153 | } |
154 | ||
207c4cf9 DI |
155 | /** |
156 | * Redraws the selection. | |
157 | */ | |
158 | public refresh(): void { | |
b594407c | 159 | // TODO: Figure out when to refresh the selection vs when to refresh the viewport |
f7d6ab5f | 160 | this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd }); |
25152e44 DI |
161 | } |
162 | ||
163 | /** | |
164 | * Selects all text within the terminal. | |
165 | */ | |
166 | public selectAll(): void { | |
f7d6ab5f | 167 | this._model.isSelectAllActive = true; |
25152e44 | 168 | this.refresh(); |
207c4cf9 DI |
169 | } |
170 | ||
171 | /** | |
172 | * Handle the buffer being trimmed, adjust the selection position. | |
173 | * @param amount The amount the buffer is being trimmed. | |
174 | */ | |
70fda994 | 175 | private _onTrim(amount: number) { |
f7d6ab5f DI |
176 | const needsRefresh = this._model.onTrim(amount); |
177 | if (needsRefresh) { | |
207c4cf9 | 178 | this.refresh(); |
207c4cf9 | 179 | } |
70fda994 DI |
180 | } |
181 | ||
32b34cbe DI |
182 | // TODO: Handle splice/shiftElements in the buffer (just clear the selection?) |
183 | ||
0dc3dd03 DI |
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); | |
ad3ae67e DI |
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; | |
b36d8780 DI |
193 | } |
194 | ||
0dc3dd03 DI |
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 | ||
e63fdf58 DI |
210 | /** |
211 | * Handles te mousedown event, setting up for a new selection. | |
212 | * @param event The mousedown event. | |
213 | */ | |
70fda994 | 214 | private _onMouseDown(event: MouseEvent) { |
0dc3dd03 DI |
215 | // Only action the primary button |
216 | if (event.button !== 0) { | |
217 | return; | |
218 | } | |
219 | ||
9f271de8 DI |
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 | } | |
e29ab294 DI |
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(); | |
9f271de8 DI |
236 | } |
237 | ||
238 | private _onSingleClick(event: MouseEvent): void { | |
f7d6ab5f DI |
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; | |
70fda994 DI |
244 | } |
245 | } | |
246 | ||
9f271de8 DI |
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 | ||
e63fdf58 DI |
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 | */ | |
70fda994 | 277 | private _onMouseMove(event: MouseEvent) { |
f7d6ab5f | 278 | this._model.selectionEnd = this._getMouseBufferCoords(event); |
0dc3dd03 DI |
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) { | |
f7d6ab5f | 284 | this._model.selectionEnd[0] = this._terminal.cols - 1; |
0dc3dd03 | 285 | } else if (this._dragScrollAmount < 0) { |
f7d6ab5f | 286 | this._model.selectionEnd[0] = 0; |
0dc3dd03 | 287 | } |
207c4cf9 DI |
288 | // TODO: Only draw here if the selection changes |
289 | this.refresh(); | |
f7d6ab5f DI |
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); | |
70fda994 DI |
294 | } |
295 | ||
0dc3dd03 DI |
296 | private _dragScroll() { |
297 | if (this._dragScrollAmount) { | |
298 | this._terminal.scrollDisp(this._dragScrollAmount, false); | |
299 | // Re-evaluate selection | |
300 | if (this._dragScrollAmount > 0) { | |
f7d6ab5f | 301 | this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows]; |
0dc3dd03 | 302 | } else { |
f7d6ab5f | 303 | this._model.selectionEnd = [0, this._terminal.ydisp]; |
0dc3dd03 DI |
304 | } |
305 | this.refresh(); | |
306 | } | |
307 | } | |
308 | ||
e63fdf58 DI |
309 | /** |
310 | * Handles the mouseup event, removing the mousemove listener when | |
311 | * appropriate. | |
312 | * @param event The mouseup event. | |
313 | */ | |
70fda994 | 314 | private _onMouseUp(event: MouseEvent) { |
b3b2bd1f | 315 | this._dragScrollAmount = 0; |
f7d6ab5f | 316 | if (!this._model.selectionStart) { |
70fda994 DI |
317 | return; |
318 | } | |
0dc3dd03 DI |
319 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
320 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
70fda994 | 321 | } |
597c6939 | 322 | |
597c6939 DI |
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 { | |
ec61f3ac | 329 | const line = this._translateBufferLineToString(this._buffer.get(coords[1]), false); |
597c6939 DI |
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 | } | |
f7d6ab5f DI |
339 | this._model.selectionStart = [startCol, coords[1]]; |
340 | this._model.selectionStartLength = endCol - startCol; | |
597c6939 | 341 | } |
9f271de8 DI |
342 | |
343 | private _selectLineAt(line: number): void { | |
f7d6ab5f DI |
344 | this._model.selectionStart = [0, line]; |
345 | this._model.selectionStartLength = this._terminal.cols; | |
9f271de8 | 346 | } |
70fda994 | 347 | } |