]> git.proxmox.com Git - mirror_xterm.js.git/commitdiff
Merge remote-tracking branch 'ups/master' into 207_selection_manager
authorDaniel Imms <daimms@microsoft.com>
Tue, 6 Jun 2017 21:35:06 +0000 (14:35 -0700)
committerDaniel Imms <daimms@microsoft.com>
Tue, 6 Jun 2017 21:35:06 +0000 (14:35 -0700)
12 files changed:
src/InputHandler.ts
src/Interfaces.ts
src/Renderer.ts
src/SelectionManager.test.ts [new file with mode: 0644]
src/SelectionManager.ts [new file with mode: 0644]
src/SelectionModel.ts [new file with mode: 0644]
src/handlers/Clipboard.test.ts
src/handlers/Clipboard.ts
src/utils/CircularList.ts
src/utils/Mouse.ts
src/xterm.css
src/xterm.js

index a702308372307ca33ee40afe3f6893e490c6e03a..5210a92827c2f63e49429cb93be9a53be1bcdf9d 100644 (file)
@@ -75,8 +75,9 @@ export class InputHandler implements IInputHandler {
           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]);
@@ -903,7 +904,8 @@ export class InputHandler implements IInputHandler {
           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
@@ -1096,7 +1098,8 @@ export class InputHandler implements IInputHandler {
           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;
index ca228ce01bdfb99e8218a887c71a1de3b61be016..4b8576742d07a76bebabd643a2b8cd55f87b4860 100644 (file)
@@ -20,6 +20,8 @@ export interface IBrowser {
 export interface ITerminal {
   element: HTMLElement;
   rowContainer: HTMLElement;
+  selectionContainer: HTMLElement;
+  charMeasure: ICharMeasure;
   textarea: HTMLTextAreaElement;
   ybase: number;
   ydisp: number;
@@ -47,6 +49,10 @@ export interface ITerminal {
   emit(event: string, data: any);
 }
 
+export interface ISelectionManager {
+  selectionText: string;
+}
+
 export interface ICharMeasure {
   width: number;
   height: number;
index af95d51ee99cd94e6db1120c00e81be8a0848940..a1b4270dbd866110a71173740621b116a8e5f8ea 100644 (file)
@@ -318,6 +318,68 @@ export class Renderer {
 
     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;
+  }
 }
 
 
diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts
new file mode 100644 (file)
index 0000000..e35a057
--- /dev/null
@@ -0,0 +1,140 @@
+/**
+ * @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');
+    });
+  });
+});
diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts
new file mode 100644 (file)
index 0000000..ce4c0f2
--- /dev/null
@@ -0,0 +1,451 @@
+/**
+ * @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;
+  }
+}
diff --git a/src/SelectionModel.ts b/src/SelectionModel.ts
new file mode 100644 (file)
index 0000000..bcb7a9c
--- /dev/null
@@ -0,0 +1,114 @@
+/**
+ * @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;
+  }
+}
index 471389c4b98940949d7461234ec5d14fce3a866d..59ac76179fa07034ce277fda96e76bf0afdc22ec 100644 (file)
@@ -4,16 +4,10 @@ import * as Clipboard from './Clipboard';
 
 
 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  ');
   });
 });
 
index 0f3b9d04a662ed421fc158685af3ad1e67b946ac..120d6275d980db2c6467fadf25221cfa2aabaf1e 100644 (file)
@@ -5,7 +5,7 @@
  * @license MIT
  */
 
-import { ITerminal } from '../Interfaces';
+import { ITerminal, ISelectionManager } from '../Interfaces';
 
 interface IWindow extends Window {
   clipboardData?: {
@@ -16,6 +16,10 @@ interface IWindow extends Window {
 
 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
@@ -24,18 +28,10 @@ declare var window: IWindow;
  * @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');
 }
 
 /**
@@ -53,11 +49,10 @@ export function prepareTextForTerminal(text: string, isMSWindows: boolean): stri
  * 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);
@@ -102,67 +97,31 @@ export function pasteHandler(ev: ClipboardEvent, term: ITerminal) {
 
 /**
  * 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 &nbsp;
-    // 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);
 }
index b72c667fe13c5c96cdda0011402605beffa57684..b6fafe7ced4afac8988fcc4acd9bb753722f837a 100644 (file)
@@ -4,13 +4,24 @@
  * @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;
   }
@@ -22,9 +33,14 @@ export class CircularList<T> {
   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;
@@ -43,8 +59,14 @@ export class CircularList<T> {
     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);
+      }
+    };
   }
 
   /**
@@ -56,6 +78,10 @@ export class CircularList<T> {
    * @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)];
   }
 
@@ -68,7 +94,11 @@ export class CircularList<T> {
    * @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;
   }
 
   /**
@@ -77,12 +107,16 @@ export class CircularList<T> {
    * @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++;
     }
@@ -93,7 +127,7 @@ export class CircularList<T> {
    * @return The popped value.
    */
   public pop(): T {
-    return this._array[this._getCyclicIndex(this._length-- - 1)];
+    return this._array[this._getCyclicIndex(this._length-- - 1)].value;
   }
 
   /**
@@ -106,23 +140,32 @@ export class CircularList<T> {
    * @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;
       }
@@ -139,6 +182,7 @@ export class CircularList<T> {
     }
     this._startIndex += count;
     this._length -= count;
+    this.emit('trim', count);
   }
 
   public shiftElements(start: number, count: number, offset: number): void {
@@ -154,7 +198,10 @@ export class CircularList<T> {
 
     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) {
@@ -162,6 +209,7 @@ export class CircularList<T> {
         while (this._length > this.maxLength) {
           this._length--;
           this._startIndex++;
+          this.emit('trim', 1);
         }
       }
     } else {
index a9efdf93427217777ccbf80b3a3a065ca0cf814a..79c5d5c1160735a0f47ce37f161e3a787840b467 100644 (file)
@@ -4,15 +4,7 @@
 
 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;
@@ -20,21 +12,37 @@ export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeas
 
   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;
 }
 
 /**
@@ -48,14 +56,10 @@ export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeas
  * @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;
index efdc016982ae66a74fcdf39e5974e40f35974098..8b082bb47428ac97e9e3e5bb94e5ca08158f2bfd 100644 (file)
@@ -41,6 +41,7 @@
     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
  */
index bf359a92ef931dce6fc5467c24c25f38623ac845..2d74a2d50c9f1f75999739bcbbfa5fe0d67acba1 100644 (file)
@@ -20,9 +20,10 @@ import { InputHandler } from './InputHandler';
 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';
 
@@ -220,6 +221,7 @@ function Terminal(options) {
   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
@@ -519,28 +521,23 @@ Terminal.prototype.initGlobal = function() {
   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);
+    });
   }
 };
 
@@ -641,6 +638,12 @@ Terminal.prototype.open = function(parent, focus) {
   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');
@@ -690,6 +693,10 @@ Terminal.prototype.open = function(parent, focus) {
 
   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);
@@ -1166,6 +1173,9 @@ Terminal.prototype.scroll = function() {
  */
 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;
@@ -1354,6 +1364,13 @@ Terminal.prototype.deregisterLinkMatcher = function(matcherId) {
   }
 }
 
+/**
+ * Selects all text within the terminal.
+ */
+Terminal.prototype.selectAll = function() {
+  this.selectionManager.selectAll();
+}
+
 /**
  * Handle a keydown event
  * Key Resources:
@@ -1685,6 +1702,10 @@ Terminal.prototype.evaluateKeyEscapeSequence = function(ev) {
         } 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;
   }