const removed = this._terminal.lines.get(this._terminal.y + this._terminal.ybase).pop();
if (removed[2] === 0
&& this._terminal.lines.get(row)[this._terminal.cols - 2]
- && this._terminal.lines.get(row)[this._terminal.cols - 2][2] === 2)
+ && this._terminal.lines.get(row)[this._terminal.cols - 2][2] === 2) {
this._terminal.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1];
+ }
// insert empty cell at cursor
this._terminal.lines.get(row).splice(this._terminal.x, 0, [this._terminal.curAttr, ' ', 1]);
this._terminal.vt200Mouse = params[0] === 1000;
this._terminal.normalMouse = params[0] > 1000;
this._terminal.mouseEvents = true;
- this._terminal.element.style.cursor = 'default';
+ this._terminal.element.classList.add('enable-mouse-events');
+ this._terminal.selectionManager.disable();
this._terminal.log('Binding to mouse events.');
break;
case 1004: // send focusin/focusout events
this._terminal.vt200Mouse = false;
this._terminal.normalMouse = false;
this._terminal.mouseEvents = false;
- this._terminal.element.style.cursor = '';
+ this._terminal.element.classList.remove('enable-mouse-events');
+ this._terminal.selectionManager.enable();
break;
case 1004: // send focusin/focusout events
this._terminal.sendFocus = false;
export interface ITerminal {
element: HTMLElement;
rowContainer: HTMLElement;
+ selectionContainer: HTMLElement;
+ charMeasure: ICharMeasure;
textarea: HTMLTextAreaElement;
ybase: number;
ydisp: number;
emit(event: string, data: any);
}
+export interface ISelectionManager {
+ selectionText: string;
+}
+
export interface ICharMeasure {
width: number;
height: number;
this._terminal.emit('refresh', {element: this._terminal.element, start: start, end: end});
};
+
+ public refreshSelection(start: [number, number], end: [number, number]) {
+ console.log('renderer, refresh:', start, end);
+
+ // Remove all selections
+ while (this._terminal.selectionContainer.children.length) {
+ this._terminal.selectionContainer.removeChild(this._terminal.selectionContainer.children[0]);
+ }
+
+ // Selection does not exist
+ if (!start || !end) {
+ return;
+ }
+
+ // Translate from buffer position to viewport position
+ const viewportStartRow = start[1] - this._terminal.ydisp;
+ const viewportEndRow = end[1] - this._terminal.ydisp;
+ const viewportCappedStartRow = Math.max(viewportStartRow, 0);
+ const viewportCappedEndRow = Math.min(viewportEndRow, this._terminal.rows - 1);
+
+ // No need to draw the selection
+ if (viewportCappedStartRow >= this._terminal.rows || viewportCappedEndRow < 0) {
+ return;
+ }
+
+ console.log('viewportStartRow', viewportCappedStartRow);
+ console.log('viewportEndRow', viewportCappedEndRow);
+
+ // TODO: Only redraw selections when necessary
+
+ // TODO: Fix selection on the first row going out the left of the terminal
+ // TODO: Fix selection on the last row not going to the last column
+
+ // Create the selections
+ const documentFragment = document.createDocumentFragment();
+ // Draw first row
+ const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
+ const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols;
+ documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
+ // Draw middle rows
+ for (let i = viewportCappedStartRow + 1; i < viewportCappedEndRow; i++) {
+ documentFragment.appendChild(this._createSelectionElement(i, 0, this._terminal.cols));
+ }
+ // Draw final row
+ if (viewportCappedStartRow !== viewportCappedEndRow) {
+ // Only draw viewportEndRow if it's not the same as viewporttartRow
+ const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._terminal.cols;
+ documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol));
+ }
+ this._terminal.selectionContainer.appendChild(documentFragment);
+ }
+
+ private _createSelectionElement(row: number, colStart: number, colEnd: number): HTMLElement {
+ const element = document.createElement('div');
+ // TODO: Move into a generated <style> element
+ element.style.height = `${this._terminal.charMeasure.height}px`;
+
+ element.style.top = `${row * this._terminal.charMeasure.height}px`;
+ element.style.left = `${colStart * this._terminal.charMeasure.width}px`;
+ element.style.width = `${this._terminal.charMeasure.width * (colEnd - colStart)}px`;
+ return element;
+ }
}
--- /dev/null
+/**
+ * @license MIT
+ */
+import jsdom = require('jsdom');
+import { assert } from 'chai';
+import { ITerminal } from './Interfaces';
+import { CharMeasure } from './utils/CharMeasure';
+import { CircularList } from './utils/CircularList';
+import { SelectionManager } from './SelectionManager';
+
+class TestSelectionManager extends SelectionManager {
+ constructor(
+ terminal: ITerminal,
+ buffer: CircularList<any>,
+ rowContainer: HTMLElement,
+ charMeasure: CharMeasure
+ ) {
+ super(terminal, buffer, rowContainer, charMeasure);
+ }
+
+ public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords); }
+
+ // Disable DOM interaction
+ public enable(): void {}
+ public disable(): void {}
+ public refresh(): void {}
+}
+
+describe('SelectionManager', () => {
+ let window: Window;
+ let document: Document;
+
+ let buffer: CircularList<any>;
+ let rowContainer: HTMLElement;
+ let selectionManager: TestSelectionManager;
+
+ beforeEach(done => {
+ jsdom.env('', (err, w) => {
+ window = w;
+ document = window.document;
+ buffer = new CircularList<any>(100);
+ selectionManager = new TestSelectionManager(null, buffer, rowContainer, null);
+ done();
+ });
+ });
+
+ function stringToRow(text: string): [number, string, number][] {
+ let result: [number, string, number][] = [];
+ for (let i = 0; i < text.length; i++) {
+ result.push([0, text.charAt(i), 1]);
+ }
+ return result;
+ }
+
+ describe('_selectWordAt', () => {
+ it('should expand selection for normal width chars', () => {
+ buffer.push(stringToRow('foo bar'));
+ selectionManager.selectWordAt([0, 0]);
+ assert.equal(selectionManager.selectionText, 'foo');
+ selectionManager.selectWordAt([1, 0]);
+ assert.equal(selectionManager.selectionText, 'foo');
+ selectionManager.selectWordAt([2, 0]);
+ assert.equal(selectionManager.selectionText, 'foo');
+ selectionManager.selectWordAt([3, 0]);
+ assert.equal(selectionManager.selectionText, ' ');
+ selectionManager.selectWordAt([4, 0]);
+ assert.equal(selectionManager.selectionText, 'bar');
+ selectionManager.selectWordAt([5, 0]);
+ assert.equal(selectionManager.selectionText, 'bar');
+ selectionManager.selectWordAt([6, 0]);
+ assert.equal(selectionManager.selectionText, 'bar');
+ });
+ it('should expand selection for whitespace', () => {
+ buffer.push(stringToRow('a b'));
+ selectionManager.selectWordAt([0, 0]);
+ assert.equal(selectionManager.selectionText, 'a');
+ selectionManager.selectWordAt([1, 0]);
+ assert.equal(selectionManager.selectionText, ' ');
+ selectionManager.selectWordAt([2, 0]);
+ assert.equal(selectionManager.selectionText, ' ');
+ selectionManager.selectWordAt([3, 0]);
+ assert.equal(selectionManager.selectionText, ' ');
+ selectionManager.selectWordAt([4, 0]);
+ assert.equal(selectionManager.selectionText, 'b');
+ });
+ it('should expand selection for wide characters', () => {
+ // Wide characters use a special format
+ buffer.push([
+ [null, '中', 2],
+ [null, '', 0],
+ [null, '文', 2],
+ [null, '', 0],
+ [null, ' ', 1],
+ [null, 'a', 1],
+ [null, '中', 2],
+ [null, '', 0],
+ [null, '文', 2],
+ [null, '', 0],
+ [null, 'b', 1],
+ [null, ' ', 1],
+ [null, 'f', 1],
+ [null, 'o', 1],
+ [null, 'o', 1]
+ ]);
+ // Ensure wide characters take up 2 columns
+ selectionManager.selectWordAt([0, 0]);
+ assert.equal(selectionManager.selectionText, '中文');
+ selectionManager.selectWordAt([1, 0]);
+ assert.equal(selectionManager.selectionText, '中文');
+ selectionManager.selectWordAt([2, 0]);
+ assert.equal(selectionManager.selectionText, '中文');
+ selectionManager.selectWordAt([3, 0]);
+ assert.equal(selectionManager.selectionText, '中文');
+ selectionManager.selectWordAt([4, 0]);
+ assert.equal(selectionManager.selectionText, ' ');
+ // Ensure wide characters work when wrapped in normal width characters
+ selectionManager.selectWordAt([5, 0]);
+ assert.equal(selectionManager.selectionText, 'a中文b');
+ selectionManager.selectWordAt([6, 0]);
+ assert.equal(selectionManager.selectionText, 'a中文b');
+ selectionManager.selectWordAt([7, 0]);
+ assert.equal(selectionManager.selectionText, 'a中文b');
+ selectionManager.selectWordAt([8, 0]);
+ assert.equal(selectionManager.selectionText, 'a中文b');
+ selectionManager.selectWordAt([9, 0]);
+ assert.equal(selectionManager.selectionText, 'a中文b');
+ selectionManager.selectWordAt([10, 0]);
+ assert.equal(selectionManager.selectionText, 'a中文b');
+ selectionManager.selectWordAt([11, 0]);
+ assert.equal(selectionManager.selectionText, ' ');
+ // Ensure normal width characters work fine in a line containing wide characters
+ selectionManager.selectWordAt([12, 0]);
+ assert.equal(selectionManager.selectionText, 'foo');
+ selectionManager.selectWordAt([13, 0]);
+ assert.equal(selectionManager.selectionText, 'foo');
+ selectionManager.selectWordAt([14, 0]);
+ assert.equal(selectionManager.selectionText, 'foo');
+ });
+ });
+});
--- /dev/null
+/**
+ * @license MIT
+ */
+
+import { CharMeasure } from './utils/CharMeasure';
+import { CircularList } from './utils/CircularList';
+import { EventEmitter } from './EventEmitter';
+import * as Mouse from './utils/Mouse';
+import { ITerminal } from './Interfaces';
+import { SelectionModel } from './SelectionModel';
+
+/**
+ * The number of pixels the mouse needs to be above or below the viewport in
+ * order to scroll at the maximum speed.
+ */
+const DRAG_SCROLL_MAX_THRESHOLD = 100;
+
+/**
+ * The maximum scrolling speed
+ */
+const DRAG_SCROLL_MAX_SPEED = 5;
+
+/**
+ * The number of milliseconds between drag scroll updates.
+ */
+const DRAG_SCROLL_INTERVAL = 100;
+
+/**
+ * The amount of time before mousedown events are no stacked to create double
+ * click events.
+ */
+const CLEAR_MOUSE_DOWN_TIME = 400;
+
+// TODO: Move these constants elsewhere
+const LINE_DATA_CHAR_INDEX = 1;
+const LINE_DATA_WIDTH_INDEX = 2;
+
+export class SelectionManager extends EventEmitter {
+ private _model: SelectionModel;
+
+ /**
+ * The amount to scroll every drag scroll update (depends on how far the mouse
+ * drag is above or below the terminal).
+ */
+ private _dragScrollAmount: number;
+
+ /**
+ * The last time the mousedown event fired, this is used to track double and
+ * triple clicks.
+ */
+ private _lastMouseDownTime: number;
+
+ private _clickCount: number;
+
+ private _bufferTrimListener: any;
+ private _mouseMoveListener: EventListener;
+ private _mouseDownListener: EventListener;
+ private _mouseUpListener: EventListener;
+
+ private _dragScrollTimeout: NodeJS.Timer;
+
+ constructor(
+ private _terminal: ITerminal,
+ private _buffer: CircularList<any>,
+ private _rowContainer: HTMLElement,
+ private _charMeasure: CharMeasure
+ ) {
+ super();
+ this._initListeners();
+ this.enable();
+
+ this._model = new SelectionModel(_terminal);
+ this._lastMouseDownTime = 0;
+ }
+
+ private _initListeners() {
+ this._bufferTrimListener = (amount: number) => this._onTrim(amount);
+ this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event);
+ this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event);
+ this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event);
+ }
+
+ /**
+ * Disables the selection manager. This is useful for when terminal mouse
+ * are enabled.
+ */
+ public disable() {
+ this._model.selectionStart = null;
+ this._model.selectionEnd = null;
+ this.refresh();
+ this._buffer.off('trim', this._bufferTrimListener);
+ this._rowContainer.removeEventListener('mousedown', this._mouseDownListener);
+ this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
+ this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
+ clearInterval(this._dragScrollTimeout);
+ }
+
+ /**
+ * Enable the selection manager.
+ */
+ public enable() {
+ this._buffer.on('trim', this._bufferTrimListener);
+ this._rowContainer.addEventListener('mousedown', this._mouseDownListener);
+ }
+
+ /**
+ * Gets the text currently selected.
+ */
+ public get selectionText(): string {
+ const start = this._model.finalSelectionStart;
+ const end = this._model.finalSelectionEnd;
+ if (!start || !end) {
+ return '';
+ }
+
+ // Get first row
+ const startRowEndCol = start[1] === end[1] ? end[0] : null;
+ let result: string[] = [];
+ result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol));
+
+ // Get middle rows
+ for (let i = start[1] + 1; i <= end[1] - 1; i++) {
+ result.push(this._translateBufferLineToString(this._buffer.get(i), true));
+ }
+
+ // Get final row
+ if (start[1] !== end[1]) {
+ result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0]));
+ }
+ console.log('selectionText result: "' + result + '"');
+ return result.join('\n');
+ }
+
+ private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string {
+ // TODO: This function should live in a buffer or buffer line class
+
+ // Get full line
+ let lineString = '';
+ let widthAdjustedStartCol = startCol;
+ let widthAdjustedEndCol = endCol;
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+ lineString += char[LINE_DATA_CHAR_INDEX];
+ // Adjust start and end cols for wide characters if they affect their
+ // column indexes
+ if (char[LINE_DATA_WIDTH_INDEX] === 0) {
+ if (startCol >= i) {
+ widthAdjustedStartCol--;
+ }
+ if (endCol >= i) {
+ widthAdjustedEndCol--;
+ }
+ }
+ }
+
+ // Calculate the final end col by trimming whitespace on the right of the
+ // line if needed.
+ let finalEndCol = widthAdjustedEndCol || line.length;
+ if (trimRight) {
+ const rightWhitespaceIndex = lineString.search(/\s+$/);
+ if (rightWhitespaceIndex !== -1) {
+ finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex);
+ }
+ // Return the empty string if only trimmed whitespace is selected
+ if (finalEndCol <= widthAdjustedStartCol) {
+ return '';
+ }
+ }
+
+ return lineString.substring(widthAdjustedStartCol, finalEndCol);
+ }
+
+ /**
+ * Redraws the selection.
+ */
+ public refresh(): void {
+ // TODO: Figure out when to refresh the selection vs when to refresh the viewport
+ this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd });
+ }
+
+ /**
+ * Selects all text within the terminal.
+ */
+ public selectAll(): void {
+ this._model.isSelectAllActive = true;
+ this.refresh();
+ }
+
+ /**
+ * Handle the buffer being trimmed, adjust the selection position.
+ * @param amount The amount the buffer is being trimmed.
+ */
+ private _onTrim(amount: number) {
+ const needsRefresh = this._model.onTrim(amount);
+ if (needsRefresh) {
+ this.refresh();
+ }
+ }
+
+ // TODO: Handle splice/shiftElements in the buffer (just clear the selection?)
+
+ private _getMouseBufferCoords(event: MouseEvent): [number, number] {
+ const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows);
+ console.log(coords);
+ // Convert to 0-based
+ coords[0]--;
+ coords[1]--;
+ // Convert viewport coords to buffer coords
+ coords[1] += this._terminal.ydisp;
+ return coords;
+ }
+
+ private _getMouseEventScrollAmount(event: MouseEvent): number {
+ let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1];
+ const terminalHeight = this._terminal.rows * this._charMeasure.height;
+ if (offset >= 0 && offset <= terminalHeight) {
+ return 0;
+ }
+ if (offset > terminalHeight) {
+ offset -= terminalHeight;
+ }
+
+ offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD);
+ offset /= DRAG_SCROLL_MAX_THRESHOLD;
+ return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1));
+ }
+
+ /**
+ * Handles te mousedown event, setting up for a new selection.
+ * @param event The mousedown event.
+ */
+ private _onMouseDown(event: MouseEvent) {
+ // Only action the primary button
+ if (event.button !== 0) {
+ return;
+ }
+
+ this._setMouseClickCount();
+ console.log(this._clickCount);
+
+ if (event.shiftKey) {
+ this._onShiftClick(event);
+ } else {
+ if (this._clickCount === 1) {
+ this._onSingleClick(event);
+ } else if (this._clickCount === 2) {
+ this._onDoubleClick(event);
+ } else if (this._clickCount === 3) {
+ this._onTripleClick(event);
+ }
+ }
+
+ // Listen on the document so that dragging outside of viewport works
+ this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener);
+ this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener);
+ this._dragScrollTimeout = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL);
+ this.refresh();
+ }
+
+ private _onShiftClick(event: MouseEvent): void {
+ if (this._model.selectionStart) {
+ this._model.selectionEnd = this._getMouseBufferCoords(event);
+ }
+ }
+
+ private _onSingleClick(event: MouseEvent): void {
+ this._model.selectionStartLength = 0;
+ this._model.isSelectAllActive = false;
+ this._model.selectionStart = this._getMouseBufferCoords(event);
+ if (this._model.selectionStart) {
+ this._model.selectionEnd = null;
+ // If the mouse is over the second half of a wide character, adjust the
+ // selection to cover the whole character
+ const char = this._buffer.get(this._model.selectionStart[1])[this._model.selectionStart[0]];
+ if (char[LINE_DATA_WIDTH_INDEX] === 0) {
+ this._model.selectionStart[0]++;
+ }
+ }
+ }
+
+ private _onDoubleClick(event: MouseEvent): void {
+ const coords = this._getMouseBufferCoords(event);
+ if (coords) {
+ this._selectWordAt(coords);
+ }
+ }
+
+ private _onTripleClick(event: MouseEvent): void {
+ const coords = this._getMouseBufferCoords(event);
+ if (coords) {
+ this._selectLineAt(coords[1]);
+ }
+ }
+
+ private _setMouseClickCount(): void {
+ let currentTime = (new Date()).getTime();
+ if (currentTime - this._lastMouseDownTime > CLEAR_MOUSE_DOWN_TIME) {
+ this._clickCount = 0;
+ }
+ this._lastMouseDownTime = currentTime;
+ this._clickCount++;
+
+ // TODO: Invalidate click count if the position is different
+ }
+
+ /**
+ * Handles the mousemove event when the mouse button is down, recording the
+ * end of the selection and refreshing the selection.
+ * @param event The mousemove event.
+ */
+ private _onMouseMove(event: MouseEvent) {
+ this._model.selectionEnd = this._getMouseBufferCoords(event);
+ // TODO: Perhaps the actual selection setting could be merged into _dragScroll?
+ this._dragScrollAmount = this._getMouseEventScrollAmount(event);
+ // If the cursor was above or below the viewport, make sure it's at the
+ // start or end of the viewport respectively
+ if (this._dragScrollAmount > 0) {
+ this._model.selectionEnd[0] = this._terminal.cols - 1;
+ } else if (this._dragScrollAmount < 0) {
+ this._model.selectionEnd[0] = 0;
+ }
+
+ // If the character is a wide character include the cell to the right in the
+ // selection.
+ const char = this._buffer.get(this._model.selectionEnd[1])[this._model.selectionEnd[0]];
+ if (char[2] === 0) {
+ this._model.selectionEnd[0]++;
+ }
+
+ // TODO: Only draw here if the selection changes
+ this.refresh();
+ }
+
+ private _dragScroll() {
+ if (this._dragScrollAmount) {
+ this._terminal.scrollDisp(this._dragScrollAmount, false);
+ // Re-evaluate selection
+ if (this._dragScrollAmount > 0) {
+ this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows];
+ } else {
+ this._model.selectionEnd = [0, this._terminal.ydisp];
+ }
+ this.refresh();
+ }
+ }
+
+ /**
+ * Handles the mouseup event, removing the mousemove listener when
+ * appropriate.
+ * @param event The mouseup event.
+ */
+ private _onMouseUp(event: MouseEvent) {
+ this._dragScrollAmount = 0;
+ if (!this._model.selectionStart) {
+ return;
+ }
+ this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
+ this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
+ }
+
+ /**
+ * Converts a viewport column to the character index on the buffer line, the
+ * latter takes into account wide characters.
+ * @param coords The coordinates to find the 2 index for.
+ */
+ private _convertViewportColToCharacterIndex(bufferLine: any, coords: [number, number]): number {
+ let charIndex = coords[0];
+ for (let i = 0; coords[0] >= i; i++) {
+ const char = bufferLine[i];
+ if (char[LINE_DATA_WIDTH_INDEX] === 0) {
+ charIndex--;
+ }
+ }
+ return charIndex;
+ }
+
+ /**
+ * Selects the word at the coordinates specified. Words are defined as all
+ * non-whitespace characters.
+ * @param coords The coordinates to get the word at.
+ */
+ protected _selectWordAt(coords: [number, number]): void {
+ // TODO: Only fetch buffer line once for translate and convert functions
+ const bufferLine = this._buffer.get(coords[1]);
+ const line = this._translateBufferLineToString(bufferLine, false);
+
+ // Get actual index, taking into consideration wide characters
+ let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords);
+ let startIndex = endIndex;
+
+ // Record offset to be used later
+ const charOffset = coords[0] - startIndex;
+ let leftWideCharCount = 0;
+ let rightWideCharCount = 0;
+
+ if (line.charAt(startIndex) === ' ') {
+ // Expand until non-whitespace is hit
+ while (startIndex > 0 && line.charAt(startIndex - 1) === ' ') {
+ startIndex--;
+ }
+ while (endIndex < line.length && line.charAt(endIndex + 1) === ' ') {
+ endIndex++;
+ }
+ } else {
+ // Expand until whitespace is hit. This algorithm works by scanning left
+ // and right from the starting position, keeping both the index format
+ // (line) and the column format (bufferLine) in sync. When a wide
+ // character is hit, it is recorded and the column index is adjusted.
+ let startCol = coords[0];
+ let endCol = coords[0];
+ // Consider the initial position, skip it and increment the wide char
+ // variable
+ if (bufferLine[startCol][LINE_DATA_WIDTH_INDEX] === 0) {
+ leftWideCharCount++;
+ startCol--;
+ }
+ if (bufferLine[endCol][LINE_DATA_WIDTH_INDEX] === 2) {
+ rightWideCharCount++;
+ endCol++;
+ }
+ // Expand the string in both directions until a space is hit
+ while (startIndex > 0 && line.charAt(startIndex - 1) !== ' ') {
+ if (bufferLine[startCol - 1][LINE_DATA_WIDTH_INDEX] === 0) {
+ // If the next character is a wide char, record it and skip the column
+ leftWideCharCount++;
+ startCol--;
+ }
+ startIndex--;
+ startCol--;
+ }
+ while (endIndex + 1 < line.length && line.charAt(endIndex + 1) !== ' ') {
+ if (bufferLine[endCol + 1][LINE_DATA_WIDTH_INDEX] === 2) {
+ // If the next character is a wide char, record it and skip the column
+ rightWideCharCount++;
+ endCol++;
+ }
+ endIndex++;
+ endCol++;
+ }
+ }
+
+ // Record the resulting selection
+ this._model.selectionStart = [startIndex + charOffset - leftWideCharCount, coords[1]];
+ this._model.selectionStartLength = endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/;
+ }
+
+ private _selectLineAt(line: number): void {
+ this._model.selectionStart = [0, line];
+ this._model.selectionStartLength = this._terminal.cols;
+ }
+}
--- /dev/null
+/**
+ * @license MIT
+ */
+
+import { ITerminal } from './Interfaces';
+
+export class SelectionModel {
+ /**
+ * Whether select all is currently active.
+ */
+ public isSelectAllActive: boolean;
+
+ /**
+ * The [x, y] position the selection starts at.
+ */
+ public selectionStart: [number, number];
+
+ /**
+ * The minimal length of the selection from the start position. When double
+ * clicking on a word, the word will be selected which makes the selection
+ * start at the start of the word and makes this variable the length.
+ */
+ public selectionStartLength: number;
+
+ /**
+ * The [x, y] position the selection ends at.
+ */
+ public selectionEnd: [number, number];
+
+ constructor(
+ private _terminal: ITerminal
+ ) {
+ }
+
+ /**
+ * The final selection start, taking into consideration select all.
+ */
+ public get finalSelectionStart(): [number, number] {
+ if (this.isSelectAllActive) {
+ return [0, 0];
+ }
+
+ if (!this.selectionEnd) {
+ return this.selectionStart;
+ }
+
+ return this._areSelectionValuesReversed() ? this.selectionEnd : this.selectionStart;
+ }
+
+ /**
+ * The final selection end, taking into consideration select all, double click
+ * word selection and triple click line selection.
+ */
+ public get finalSelectionEnd(): [number, number] {
+ if (!this.selectionStart) {
+ return null;
+ }
+
+ if (this.isSelectAllActive) {
+ return [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows - 1];
+ }
+
+ // Use the selection start if the end doesn't exist or they're reversed
+ if (!this.selectionEnd || this._areSelectionValuesReversed()) {
+ return [this.selectionStart[0] + this.selectionStartLength, this.selectionStart[1]];
+ }
+
+ // Ensure the the word/line is selected after a double/triple click
+ if (this.selectionStartLength) {
+ // Select the larger of the two when start and end are on the same line
+ if (this.selectionEnd[1] === this.selectionStart[1]) {
+ return [Math.max(this.selectionStart[0] + this.selectionStartLength, this.selectionEnd[0]), this.selectionEnd[1]];
+ }
+ }
+ return this.selectionEnd;
+ }
+
+ /**
+ * Returns whether the selection start and end are reversed.
+ */
+ private _areSelectionValuesReversed(): boolean {
+ const start = this.selectionStart;
+ const end = this.selectionEnd;
+ return start[1] > end[1] || (start[1] === end[1] && start[0] > end[0]);
+ }
+
+ /**
+ * Handle the buffer being trimmed, adjust the selection position.
+ * @param amount The amount the buffer is being trimmed.
+ * @return Whether a refresh is necessary.
+ */
+ public onTrim(amount: number): boolean {
+ // Adjust the selection position based on the trimmed amount.
+ if (this.selectionStart) {
+ this.selectionStart[0] -= amount;
+ }
+ if (this.selectionEnd) {
+ this.selectionEnd[0] -= amount;
+ }
+
+ // The selection has moved off the buffer, clear it.
+ if (this.selectionEnd && this.selectionEnd[0] < 0) {
+ this.selectionStart = null;
+ this.selectionEnd = null;
+ return true;
+ }
+
+ // If the selection start is trimmed, ensure the start column is 0.
+ if (this.selectionStart && this.selectionStart[0] < 0) {
+ this.selectionStart[1] = 0;
+ }
+ return false;
+ }
+}
describe('evaluateCopiedTextProcessing', function () {
- it('should strip trailing whitespaces and replace nbsps with spaces', function () {
- let nonBreakingSpace = String.fromCharCode(160),
- copiedText = 'echo' + nonBreakingSpace + 'hello' + nonBreakingSpace,
- processedText = Clipboard.prepareTextForClipboard(copiedText);
-
- // No trailing spaces
- assert.equal(processedText.match(/\s+$/), null);
-
- // No non-breaking space
- assert.equal(processedText.indexOf(nonBreakingSpace), -1);
+ it('should replace non-breaking spaces with regular spaces', () => {
+ const nbsp = String.fromCharCode(160);
+ const result = Clipboard.prepareTextForClipboard(`foo${nbsp}bar\ntest${nbsp}${nbsp}`);
+ assert.equal(result, 'foo bar\ntest ');
});
});
* @license MIT
*/
-import { ITerminal } from '../Interfaces';
+import { ITerminal, ISelectionManager } from '../Interfaces';
interface IWindow extends Window {
clipboardData?: {
declare var window: IWindow;
+const SPACE_CHAR = String.fromCharCode(32);
+const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160);
+const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g');
+
/**
* Prepares text copied from terminal selection, to be saved in the clipboard by:
* 1. stripping all trailing white spaces
* @returns {string}
*/
export function prepareTextForClipboard(text: string): string {
- let space = String.fromCharCode(32),
- nonBreakingSpace = String.fromCharCode(160),
- allNonBreakingSpaces = new RegExp(nonBreakingSpace, 'g'),
- processedText = text.split('\n').map(function (line) {
- // Strip all trailing white spaces and convert all non-breaking spaces
- // to regular spaces.
- let processedLine = line.replace(/\s+$/g, '').replace(allNonBreakingSpaces, space);
-
- return processedLine;
- }).join('\n');
-
- return processedText;
+ // TODO: Pass an unjoined string array into this function so not splitting is needed
+ return text.split('\n').map(line => {
+ return line.replace(ALL_NON_BREAKING_SPACE_REGEX, SPACE_CHAR);
+ }).join('\n');
}
/**
* Binds copy functionality to the given terminal.
* @param {ClipboardEvent} ev The original copy event to be handled
*/
-export function copyHandler(ev: ClipboardEvent, term: ITerminal) {
+export function copyHandler(ev: ClipboardEvent, term: ITerminal, selectionManager: ISelectionManager) {
// We cast `window` to `any` type, because TypeScript has not declared the `clipboardData`
// property that we use below for Internet Explorer.
- let copiedText = window.getSelection().toString(),
- text = prepareTextForClipboard(copiedText);
+ let text = prepareTextForClipboard(selectionManager.selectionText);
if (term.browser.isMSIE) {
window.clipboardData.setData('Text', text);
/**
* Bind to right-click event and allow right-click copy and paste.
- *
- * **Logic**
- * If text is selected and right-click happens on selected text, then
- * do nothing to allow seamless copying.
- * If no text is selected or right-click is outside of the selection
- * area, then bring the terminal's input below the cursor, in order to
- * trigger the event on the textarea and allow-right click paste, without
- * caring about disappearing selection.
- * @param {MouseEvent} ev The original right click event to be handled
- * @param {Terminal} term The terminal on which to apply the handled paste event
+ * @param ev The original right click event to be handled
+ * @param term The terminal on which to apply the handled paste event
+ * @param selectionManager The terminal's selection manager.
*/
-export function rightClickHandler(ev: MouseEvent, term: ITerminal) {
- let s = document.getSelection(),
- selectedText = prepareTextForClipboard(s.toString()),
- clickIsOnSelection = false,
- x = ev.clientX,
- y = ev.clientY;
-
- if (s.rangeCount) {
- let r = s.getRangeAt(0),
- cr = r.getClientRects();
-
- for (let i = 0; i < cr.length; i++) {
- let rect = cr[i];
-
- clickIsOnSelection = (
- (x > rect.left) && (x < rect.right) &&
- (y > rect.top) && (y < rect.bottom)
- );
-
- if (clickIsOnSelection) {
- break;
- }
- }
- // If we clicked on selection and selection is not a single space,
- // then mark the right click as copy-only. We check for the single
- // space selection, as this can happen when clicking on an
- // and there is not much pointing in copying a single space.
- if (selectedText.match(/^\s$/) || !selectedText.length) {
- clickIsOnSelection = false;
- }
- }
-
+export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager) {
// Bring textarea at the cursor position
- if (!clickIsOnSelection) {
- term.textarea.style.position = 'fixed';
- term.textarea.style.width = '20px';
- term.textarea.style.height = '20px';
- term.textarea.style.left = (x - 10) + 'px';
- term.textarea.style.top = (y - 10) + 'px';
- term.textarea.style.zIndex = '1000';
- term.textarea.focus();
-
- // Reset the terminal textarea's styling
- setTimeout(function () {
- term.textarea.style.position = null;
- term.textarea.style.width = null;
- term.textarea.style.height = null;
- term.textarea.style.left = null;
- term.textarea.style.top = null;
- term.textarea.style.zIndex = null;
- }, 4);
- }
+ textarea.style.position = 'fixed';
+ textarea.style.width = '20px';
+ textarea.style.height = '20px';
+ textarea.style.left = (ev.clientX - 10) + 'px';
+ textarea.style.top = (ev.clientY - 10) + 'px';
+ textarea.style.zIndex = '1000';
+
+ // Get textarea ready to copy from the context menu
+ textarea.value = prepareTextForClipboard(selectionManager.selectionText);
+ textarea.focus();
+ textarea.select();
+
+ // Reset the terminal textarea's styling
+ setTimeout(function () {
+ textarea.style.position = null;
+ textarea.style.width = null;
+ textarea.style.height = null;
+ textarea.style.left = null;
+ textarea.style.top = null;
+ textarea.style.zIndex = null;
+ }, 4);
}
* @module xterm/utils/CircularList
* @license MIT
*/
-export class CircularList<T> {
- private _array: T[];
+import { EventEmitter } from '../EventEmitter';
+
+// TODO: Do we need the ID here?
+interface ListEntry<T> {
+ id: number;
+ value: T;
+}
+
+export class CircularList<T> extends EventEmitter {
+ private _array: ListEntry<T>[];
private _startIndex: number;
private _length: number;
+ private _nextId = 0;
+
constructor(maxLength: number) {
- this._array = new Array<T>(maxLength);
+ super();
+ this._array = new Array<ListEntry<T>>(maxLength);
this._startIndex = 0;
this._length = 0;
}
public set maxLength(newMaxLength: number) {
// Reconstruct array, starting at index 0. Only transfer values from the
// indexes 0 to length.
- let newArray = new Array<T>(newMaxLength);
+ let newArray = new Array<ListEntry<T>>(newMaxLength);
+ // Reset ids when maxLength is changed
+ this._nextId = 0;
for (let i = 0; i < Math.min(newMaxLength, this.length); i++) {
- newArray[i] = this._array[this._getCyclicIndex(i)];
+ newArray[i] = {
+ id: this._nextId++,
+ value: this._array[this._getCyclicIndex(i)].value
+ };
}
this._array = newArray;
this._startIndex = 0;
this._length = newLength;
}
- public get forEach(): (callbackfn: (value: T, index: number, array: T[]) => void) => void {
- return this._array.forEach;
+ public get forEach(): (callbackfn: (value: T, index: number) => void) => void {
+ return (callbackfn: (value: T, index: number) => void) => {
+ let i = 0;
+ let length = this.length;
+ for (let i = 0; i < length; i++) {
+ callbackfn(this.get(i), i);
+ }
+ };
}
/**
* @return The value corresponding to the index.
*/
public get(index: number): T {
+ return this.getEntry(index).value;
+ }
+
+ public getEntry(index: number): ListEntry<T> {
return this._array[this._getCyclicIndex(index)];
}
* @param value The value to set.
*/
public set(index: number, value: T): void {
- this._array[this._getCyclicIndex(index)] = value;
+ this._array[this._getCyclicIndex(index)].value = value;
+ }
+
+ private _setEntry(index: number, entry: ListEntry<T>): void {
+ this._array[this._getCyclicIndex(index)] = entry;
}
/**
* @param value The value to push onto the list.
*/
public push(value: T): void {
- this._array[this._getCyclicIndex(this._length)] = value;
+ this._array[this._getCyclicIndex(this._length)] = {
+ id: this._nextId,
+ value
+ };
if (this._length === this.maxLength) {
this._startIndex++;
if (this._startIndex === this.maxLength) {
this._startIndex = 0;
}
+ this.emit('trim', 1);
} else {
this._length++;
}
* @return The popped value.
*/
public pop(): T {
- return this._array[this._getCyclicIndex(this._length-- - 1)];
+ return this._array[this._getCyclicIndex(this._length-- - 1)].value;
}
/**
* @param items The items to insert.
*/
public splice(start: number, deleteCount: number, ...items: T[]): void {
+ // Delete items
if (deleteCount) {
for (let i = start; i < this._length - deleteCount; i++) {
this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)];
}
this._length -= deleteCount;
}
+
if (items && items.length) {
+ // Add items
for (let i = this._length - 1; i >= start; i--) {
this._array[this._getCyclicIndex(i + items.length)] = this._array[this._getCyclicIndex(i)];
}
for (let i = 0; i < items.length; i++) {
- this._array[this._getCyclicIndex(start + i)] = items[i];
+ this._array[this._getCyclicIndex(start + i)] = {
+ id: this._nextId,
+ value: items[i]
+ };
}
+ // Adjust length as needed
if (this._length + items.length > this.maxLength) {
- this._startIndex += (this._length + items.length) - this.maxLength;
+ const countToTrim = (this._length + items.length) - this.maxLength;
+ this._startIndex += countToTrim;
this._length = this.maxLength;
+ this.emit('trim', countToTrim);
} else {
this._length += items.length;
}
}
this._startIndex += count;
this._length -= count;
+ this.emit('trim', count);
}
public shiftElements(start: number, count: number, offset: number): void {
if (offset > 0) {
for (let i = count - 1; i >= 0; i--) {
- this.set(start + i + offset, this.get(start + i));
+ this._setEntry(start + i + offset, {
+ id: this._nextId++,
+ value: this.get(start + i)
+ });
}
const expandListBy = (start + count + offset) - this._length;
if (expandListBy > 0) {
while (this._length > this.maxLength) {
this._length--;
this._startIndex++;
+ this.emit('trim', 1);
}
}
} else {
import { CharMeasure } from './CharMeasure';
-/**
- * Gets coordinates within the terminal for a particular mouse event. The result
- * is returned as an array in the form [x, y] instead of an object as it's a
- * little faster and this function is used in some low level code.
- * @param event The mouse event.
- * @param rowContainer The terminal's row container.
- * @param charMeasure The char measure object used to determine character sizes.
- */
-export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure): [number, number] {
+export function getCoordsRelativeToElement(event: MouseEvent, element: HTMLElement): [number, number] {
// Ignore browsers that don't support MouseEvent.pageX
if (event.pageX == null) {
return null;
let x = event.pageX;
let y = event.pageY;
- let el = rowContainer;
// Converts the coordinates from being relative to the document to being
// relative to the terminal.
- while (el && el !== self.document.documentElement) {
- x -= el.offsetLeft;
- y -= el.offsetTop;
- el = 'offsetParent' in el ? <HTMLElement>el.offsetParent : <HTMLElement>el.parentElement;
+ while (element && element !== self.document.documentElement) {
+ x -= element.offsetLeft;
+ y -= element.offsetTop;
+ element = 'offsetParent' in element ? <HTMLElement>element.offsetParent : <HTMLElement>element.parentElement;
}
+ return [x, y];
+}
+
+/**
+ * Gets coordinates within the terminal for a particular mouse event. The result
+ * is returned as an array in the form [x, y] instead of an object as it's a
+ * little faster and this function is used in some low level code.
+ * @param event The mouse event.
+ * @param rowContainer The terminal's row container.
+ * @param charMeasure The char measure object used to determine character sizes.
+ */
+export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number): [number, number] {
+ const coords = getCoordsRelativeToElement(event, rowContainer);
// Convert to cols/rows
- x = Math.ceil(x / charMeasure.width);
- y = Math.ceil(y / charMeasure.height);
+ coords[0] = Math.ceil(coords[0] / charMeasure.width);
+ coords[1] = Math.ceil(coords[1] / charMeasure.height);
- return [x, y];
+ // Ensure coordinates are within the terminal viewport.
+ coords[0] = Math.min(Math.max(coords[0], 1), colCount + 1);
+ coords[1] = Math.min(Math.max(coords[1], 1), rowCount + 1);
+
+ return coords;
}
/**
* @param rowCount The number of rows in the terminal.
*/
export function getRawByteCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number): { x: number, y: number } {
- const coords = getCoords(event, rowContainer, charMeasure);
+ const coords = getCoords(event, rowContainer, charMeasure, colCount, rowCount);
let x = coords[0];
let y = coords[1];
- // Ensure coordinates are within the terminal viewport.
- x = Math.min(Math.max(x, 0), colCount);
- y = Math.min(Math.max(y, 0), rowCount);
-
// xterm sends raw bytes and starts at 32 (SP) for each.
x += 32;
y += 32;
font-family: courier-new, courier, monospace;
font-feature-settings: "liga" 0;
position: relative;
+ user-select: none;
}
.terminal.focus,
left: -9999em;
}
+.terminal.enable-mouse-events {
+ /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
+ cursor: default;
+}
+
+.terminal .xterm-selection {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.terminal .xterm-selection div {
+ position: absolute;
+ background-color: #555;
+}
+
/*
* Determine default colors for xterm.js
*/
import { Parser } from './Parser';
import { Renderer } from './Renderer';
import { Linkifier } from './Linkifier';
+import { SelectionManager } from './SelectionManager';
import { CharMeasure } from './utils/CharMeasure';
import * as Browser from './utils/Browser';
-import * as Keyboard from './utils/Keyboard';
+import * as Mouse from './utils/Mouse';
import { CHARSETS } from './Charsets';
import { getRawByteCoords } from './utils/Mouse';
this.parser = new Parser(this.inputHandler, this);
// Reuse renderer if the Terminal is being recreated via a Terminal.reset call.
this.renderer = this.renderer || null;
+ this.selectionManager = this.selectionManager || null;
this.linkifier = this.linkifier || new Linkifier();
// user input states
Terminal.bindBlur(this);
// Bind clipboard functionality
- on(this.element, 'copy', function (ev) {
- copyHandler.call(this, ev, term);
+ on(this.element, 'copy', event => {
+ copyHandler(event, term, term.selectionManager);
});
- on(this.textarea, 'paste', function (ev) {
- pasteHandler.call(this, ev, term);
- });
- on(this.element, 'paste', function (ev) {
- pasteHandler.call(this, ev, term);
- });
-
- function rightClickHandlerWrapper (ev) {
- rightClickHandler.call(this, ev, term);
- }
+ const pasteHandlerWrapper = event => pasteHandler(event, term);
+ on(this.textarea, 'paste', pasteHandlerWrapper);
+ on(this.element, 'paste', pasteHandlerWrapper);
if (term.browser.isFirefox) {
- on(this.element, 'mousedown', function (ev) {
+ on(this.element, 'mousedown', event => {
if (ev.button == 2) {
- rightClickHandlerWrapper(ev);
+ rightClickHandler(event, this.textarea, this.selectionManager);
}
});
} else {
- on(this.element, 'contextmenu', rightClickHandlerWrapper);
+ on(this.element, 'contextmenu', event => {
+ rightClickHandler(event, this.textarea, this.selectionManager);
+ });
}
};
this.viewportScrollArea.classList.add('xterm-scroll-area');
this.viewportElement.appendChild(this.viewportScrollArea);
+ // Create the selection container. This needs to be added before the
+ // rowContainer as the selection must be below the text.
+ this.selectionContainer = document.createElement('div');
+ this.selectionContainer.classList.add('xterm-selection');
+ this.element.appendChild(this.selectionContainer);
+
// Create the container that will hold the lines of the terminal and then
// produce the lines the lines.
this.rowContainer = document.createElement('div');
this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure);
this.renderer = new Renderer(this);
+ this.selectionManager = new SelectionManager(this, this.lines, this.rowContainer, this.charMeasure);
+ this.selectionManager.on('refresh', data => this.renderer.refreshSelection(data.start, data.end));
+ this.on('scroll', () => this.selectionManager.refresh());
+ this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh());
// Setup loop that draws to screen
this.refresh(0, this.rows - 1);
*/
Terminal.prototype.scrollDisp = function(disp, suppressScrollEvent) {
if (disp < 0) {
+ if (this.ydisp === 0) {
+ return;
+ }
this.userScrolling = true;
} else if (disp + this.ydisp >= this.ybase) {
this.userScrolling = false;
}
}
+/**
+ * Selects all text within the terminal.
+ */
+Terminal.prototype.selectAll = function() {
+ this.selectionManager.selectAll();
+}
+
/**
* Handle a keydown event
* Key Resources:
} else if (ev.keyCode >= 48 && ev.keyCode <= 57) {
result.key = C0.ESC + (ev.keyCode - 48);
}
+ } else if (this.browser.isMac && !ev.altKey && !ev.ctrlKey && ev.metaKey) {
+ if (ev.keyCode === 65) { // cmd + a
+ this.selectAll();
+ }
}
break;
}