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';
13 * The number of pixels the mouse needs to be above or below the viewport in
14 * order to scroll at the maximum speed.
16 const DRAG_SCROLL_MAX_THRESHOLD = 100;
19 * The maximum scrolling speed
21 const DRAG_SCROLL_MAX_SPEED = 5;
24 * The number of milliseconds between drag scroll updates.
26 const DRAG_SCROLL_INTERVAL = 100;
29 * The amount of time before mousedown events are no stacked to create double
32 const CLEAR_MOUSE_DOWN_TIME = 400;
34 export class SelectionManager extends EventEmitter {
35 private _model: SelectionModel;
38 * The amount to scroll every drag scroll update (depends on how far the mouse
39 * drag is above or below the terminal).
41 private _dragScrollAmount: number;
44 * The last time the mousedown event fired, this is used to track double and
47 private _lastMouseDownTime: number;
49 private _clickCount: number;
51 private _bufferTrimListener: any;
52 private _mouseMoveListener: EventListener;
53 private _mouseDownListener: EventListener;
54 private _mouseUpListener: EventListener;
56 private _dragScrollTimeout: NodeJS.Timer;
59 private _terminal: ITerminal,
60 private _buffer: CircularList<any>,
61 private _rowContainer: HTMLElement,
62 private _selectionContainer: HTMLElement,
63 private _charMeasure: CharMeasure
66 this._initListeners();
69 this._model = new SelectionModel(_terminal);
70 this._lastMouseDownTime = 0;
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);
81 * Disables the selection manager. This is useful for when terminal mouse
85 this._model.selectionStart = null;
86 this._model.selectionEnd = null;
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);
96 * Enable the selection manager.
99 this._buffer.on('trim', this._bufferTrimListener);
100 this._rowContainer.addEventListener('mousedown', this._mouseDownListener);
104 * Gets the text currently selected.
106 public get selectionText(): string {
107 const start = this._model.finalSelectionStart;
108 const end = this._model.finalSelectionEnd;
109 if (!start || !end) {
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));
119 for (let i = start[1] + 1; i <= end[1] - 1; i++) {
120 result.push(this._translateBufferLineToString(this._buffer.get(i), true));
124 if (start[1] !== end[1]) {
125 result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0]));
127 console.log('selectionText result: ' + result);
128 return result.join('\n');
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
137 for (let i = 0; i < line.length; i++) {
138 lineString += line[i][1];
141 let finalEndCol = endCol || line.length
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) {
152 return lineString.substring(startCol, finalEndCol);
156 * Redraws the selection.
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 });
164 * Selects all text within the terminal.
166 public selectAll(): void {
167 this._model.isSelectAllActive = true;
172 * Handle the buffer being trimmed, adjust the selection position.
173 * @param amount The amount the buffer is being trimmed.
175 private _onTrim(amount: number) {
176 const needsRefresh = this._model.onTrim(amount);
182 // TODO: Handle splice/shiftElements in the buffer (just clear the selection?)
184 private _getMouseBufferCoords(event: MouseEvent): [number, number] {
185 const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows);
187 // Convert to 0-based
190 // Convert viewport coords to buffer coords
191 coords[1] += this._terminal.ydisp;
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) {
201 if (offset > terminalHeight) {
202 offset -= terminalHeight;
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));
211 * Handles te mousedown event, setting up for a new selection.
212 * @param event The mousedown event.
214 private _onMouseDown(event: MouseEvent) {
215 // Only action the primary button
216 if (event.button !== 0) {
220 this._setMouseClickCount();
221 console.log(this._clickCount);
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);
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);
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;
247 private _onDoubleClick(event: MouseEvent): void {
248 const coords = this._getMouseBufferCoords(event);
250 this._selectWordAt(coords);
254 private _onTripleClick(event: MouseEvent): void {
255 const coords = this._getMouseBufferCoords(event);
257 this._selectLineAt(coords[1]);
261 private _setMouseClickCount(): void {
262 let currentTime = (new Date()).getTime();
263 if (currentTime - this._lastMouseDownTime > CLEAR_MOUSE_DOWN_TIME) {
264 this._clickCount = 0;
266 this._lastMouseDownTime = currentTime;
269 // TODO: Invalidate click count if the position is different
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.
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;
288 // TODO: Only draw here if the selection changes
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);
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];
303 this._model.selectionEnd = [0, this._terminal.ydisp];
310 * Handles the mouseup event, removing the mousemove listener when
312 * @param event The mouseup event.
314 private _onMouseUp(event: MouseEvent) {
315 this._dragScrollAmount = 0;
316 if (!this._model.selectionStart) {
319 this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
320 this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
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.
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) !== ' ') {
336 while (endCol < line.length && line.charAt(endCol) !== ' ') {
339 this._model.selectionStart = [startCol, coords[1]];
340 this._model.selectionStartLength = endCol - startCol;
343 private _selectLineAt(line: number): void {
344 this._model.selectionStart = [0, line];
345 this._model.selectionStartLength = this._terminal.cols;