Merge remote-tracking branch 'ups/master' into 687_macos_ci

This commit is contained in:
Daniel Imms
2017-07-08 17:19:47 -07:00
28 changed files with 13115 additions and 258 deletions
+6 -1
View File
@@ -56,8 +56,13 @@ export class InputHandler implements IInputHandler {
this._terminal.x = 0;
this._terminal.y++;
if (this._terminal.y > this._terminal.scrollBottom) {
// Insert a new line, scroll and mark as a wrapped line
this._terminal.y--;
this._terminal.scroll();
this._terminal.scroll(true);
} else {
// The line already exists (eg. the initial viewport), mark it as a
// wrapped line
this._terminal.lines.get(this._terminal.y).isWrapped = true;
}
} else {
if (ch_width === 2) // FIXME: check for xterm behavior
+12 -7
View File
@@ -17,6 +17,7 @@ class TestLinkifier extends Linkifier {
}
describe('Linkifier', () => {
let dom: jsdom.JSDOM;
let window: Window;
let document: Document;
@@ -24,13 +25,11 @@ describe('Linkifier', () => {
let rows: HTMLElement[];
let linkifier: TestLinkifier;
beforeEach(done => {
jsdom.env('', (err, w) => {
window = w;
document = window.document;
linkifier = new TestLinkifier();
done();
});
beforeEach(() => {
dom = new jsdom.JSDOM('');
window = dom.window;
document = window.document;
linkifier = new TestLinkifier();
});
function addRow(html: string) {
@@ -119,6 +118,12 @@ describe('Linkifier', () => {
it('should match a link immediately after a link at the end of a text node', done => {
assertLinkifiesRow('<span>foo bar</span>baz', /bar|baz/, '<span>foo <a>bar</a></span><a>baz</a>', done);
});
it('should not duplicate text after a unicode character (wrapped in a span)', done => {
// This is a regression test for an issue that came about when using
// an oh-my-zsh theme that added the large blue diamond unicode
// character (U+1F537) which caused the path to be duplicated. See #642.
assertLinkifiesRow('echo \'<span class="xterm-normal-char">🔷</span>foo\'', /foo/, 'echo \'<span class="xterm-normal-char">🔷</span><a>foo</a>\'', done);
});
});
describe('validationCallback', () => {
+24 -10
View File
@@ -200,7 +200,7 @@ export class Linkifier {
* Linkifies a row given a specific handler.
* @param {HTMLElement} row The row to linkify.
* @param {LinkMatcher} matcher The link matcher for this line.
* @return The link element if it was added, otherwise undefined.
* @return The link element(s) that were added.
*/
private _doLinkifyRow(row: HTMLElement, matcher: LinkMatcher): HTMLElement[] {
// Iterate over nodes as we want to consider text nodes
@@ -235,8 +235,21 @@ export class Linkifier {
element.innerHTML = '';
element.appendChild(linkElement);
}
} else if (node.childNodes.length > 1) {
// Matches part of string in an element with multiple child nodes
for (let j = 0; j < node.childNodes.length; j++) {
const childNode = node.childNodes[j];
const childSearchIndex = childNode.textContent.indexOf(uri);
if (childSearchIndex !== -1) {
// Match found in currentNode
this._replaceNodeSubstringWithNode(childNode, linkElement, uri, childSearchIndex);
// Don't need to count nodesAdded by replacing the node as this
// is a child node, not a top-level node.
break;
}
}
} else {
// Matches part of string
// Matches part of string in a single text node
const nodesAdded = this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
// No need to consider the new nodes
i += nodesAdded;
@@ -308,25 +321,26 @@ export class Linkifier {
* @return The number of nodes to skip when searching for the next uri.
*/
private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): number {
let node = targetNode;
if (node.nodeType !== 3/*Node.TEXT_NODE*/) {
node = node.childNodes[0];
// If the targetNode is a non-text node with a single child, make the child
// the new targetNode.
if (targetNode.childNodes.length === 1) {
targetNode = targetNode.childNodes[0];
}
// The targetNode will be either a text node or a <span>. The text node
// (targetNode or its only-child) needs to be replaced with newNode plus new
// text nodes potentially on either side.
if (node.childNodes.length === 0 && node.nodeType !== 3/*Node.TEXT_NODE*/) {
if (targetNode.nodeType !== 3/*Node.TEXT_NODE*/) {
throw new Error('targetNode must be a text node or only contain a single text node');
}
const fullText = node.textContent;
const fullText = targetNode.textContent;
if (substringIndex === 0) {
// Replace with <newNode><textnode>
const rightText = fullText.substring(substring.length);
const rightTextNode = this._document.createTextNode(rightText);
this._replaceNode(node, newNode, rightTextNode);
this._replaceNode(targetNode, newNode, rightTextNode);
return 0;
}
@@ -334,7 +348,7 @@ export class Linkifier {
// Replace with <textnode><newNode>
const leftText = fullText.substring(0, substringIndex);
const leftTextNode = this._document.createTextNode(leftText);
this._replaceNode(node, leftTextNode, newNode);
this._replaceNode(targetNode, leftTextNode, newNode);
return 0;
}
@@ -343,7 +357,7 @@ export class Linkifier {
const leftTextNode = this._document.createTextNode(leftText);
const rightText = fullText.substring(substringIndex + substring.length);
const rightTextNode = this._document.createTextNode(rightText);
this._replaceNode(node, leftTextNode, newNode, rightTextNode);
this._replaceNode(targetNode, leftTextNode, newNode, rightTextNode);
return 1;
}
}
+6 -6
View File
@@ -200,7 +200,8 @@ export class Renderer {
}
currentElement = this._spanElementObjectPool.acquire();
if (data === -1) {
currentElement.classList.add('reverse-video', 'terminal-cursor');
currentElement.classList.add('reverse-video');
currentElement.classList.add('terminal-cursor');
} else {
let bg = data & 0x1ff;
let fg = (data >> 9) & 0x1ff;
@@ -353,9 +354,8 @@ export class Renderer {
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));
}
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._terminal.cols, middleRowsCount));
// Draw final row
if (viewportCappedStartRow !== viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewporttartRow
@@ -371,9 +371,9 @@ export class Renderer {
* @param colStart The start column.
* @param colEnd The end columns.
*/
private _createSelectionElement(row: number, colStart: number, colEnd: number): HTMLElement {
private _createSelectionElement(row: number, colStart: number, colEnd: number, rowCount: number = 1): HTMLElement {
const element = document.createElement('div');
element.style.height = `${this._terminal.charMeasure.height}px`;
element.style.height = `${rowCount * 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`;
+59 -9
View File
@@ -31,6 +31,7 @@ class TestSelectionManager extends SelectionManager {
}
describe('SelectionManager', () => {
let dom: jsdom.JSDOM;
let window: Window;
let document: Document;
@@ -39,15 +40,13 @@ describe('SelectionManager', () => {
let rowContainer: HTMLElement;
let selectionManager: TestSelectionManager;
beforeEach(done => {
jsdom.env('', (err, w) => {
window = w;
document = window.document;
buffer = new CircularList<any>(100);
terminal = <any>{ cols: 80, rows: 2 };
selectionManager = new TestSelectionManager(terminal, buffer, rowContainer, null);
done();
});
beforeEach(() => {
dom = new jsdom.JSDOM('');
window = dom.window;
document = window.document;
buffer = new CircularList<any>(100);
terminal = <any>{ cols: 80, rows: 2 };
selectionManager = new TestSelectionManager(terminal, buffer, rowContainer, null);
});
function stringToRow(text: string): [number, string, number][] {
@@ -142,6 +141,41 @@ describe('SelectionManager', () => {
selectionManager.selectWordAt([14, 0]);
assert.equal(selectionManager.selectionText, 'foo');
});
it('should select up to non-path characters that are commonly adjacent to paths', () => {
buffer.push(stringToRow('(cd)[ef]{gh}\'ij"'));
selectionManager.selectWordAt([0, 0]);
assert.equal(selectionManager.selectionText, '(cd');
selectionManager.selectWordAt([1, 0]);
assert.equal(selectionManager.selectionText, 'cd');
selectionManager.selectWordAt([2, 0]);
assert.equal(selectionManager.selectionText, 'cd');
selectionManager.selectWordAt([3, 0]);
assert.equal(selectionManager.selectionText, 'cd)');
selectionManager.selectWordAt([4, 0]);
assert.equal(selectionManager.selectionText, '[ef');
selectionManager.selectWordAt([5, 0]);
assert.equal(selectionManager.selectionText, 'ef');
selectionManager.selectWordAt([6, 0]);
assert.equal(selectionManager.selectionText, 'ef');
selectionManager.selectWordAt([7, 0]);
assert.equal(selectionManager.selectionText, 'ef]');
selectionManager.selectWordAt([8, 0]);
assert.equal(selectionManager.selectionText, '{gh');
selectionManager.selectWordAt([9, 0]);
assert.equal(selectionManager.selectionText, 'gh');
selectionManager.selectWordAt([10, 0]);
assert.equal(selectionManager.selectionText, 'gh');
selectionManager.selectWordAt([11, 0]);
assert.equal(selectionManager.selectionText, 'gh}');
selectionManager.selectWordAt([12, 0]);
assert.equal(selectionManager.selectionText, '\'ij');
selectionManager.selectWordAt([13, 0]);
assert.equal(selectionManager.selectionText, 'ij');
selectionManager.selectWordAt([14, 0]);
assert.equal(selectionManager.selectionText, 'ij');
selectionManager.selectWordAt([15, 0]);
assert.equal(selectionManager.selectionText, 'ij"');
});
});
describe('_selectLineAt', () => {
@@ -166,4 +200,20 @@ describe('SelectionManager', () => {
assert.equal(selectionManager.selectionText, '1\n2\n3\n4\n5');
});
});
describe('hasSelection', () => {
it('should return whether there is a selection', () => {
selectionManager.model.selectionStart = [0, 0];
selectionManager.model.selectionStartLength = 0;
assert.equal(selectionManager.hasSelection, false);
selectionManager.model.selectionEnd = [0, 0];
assert.equal(selectionManager.hasSelection, false);
selectionManager.model.selectionEnd = [1, 0];
assert.equal(selectionManager.hasSelection, true);
selectionManager.model.selectionEnd = [0, 1];
assert.equal(selectionManager.hasSelection, true);
selectionManager.model.selectionEnd = [1, 1];
assert.equal(selectionManager.hasSelection, true);
});
});
});
+110 -23
View File
@@ -2,10 +2,11 @@
* @license MIT
*/
import * as Mouse from './utils/Mouse';
import * as Browser from './utils/Browser';
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';
@@ -37,6 +38,12 @@ const CLEAR_MOUSE_DOWN_TIME = 400;
*/
const CLEAR_MOUSE_DISTANCE = 10;
/**
* A string containing all characters that are considered word separated by the
* double click to select work logic.
*/
const WORD_SEPARATORS = ' ()[]{}\'"';
// TODO: Move these constants elsewhere, they belong in a buffer or buffer
// data/line class.
const LINE_DATA_CHAR_INDEX = 1;
@@ -45,6 +52,23 @@ const LINE_DATA_WIDTH_INDEX = 2;
const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160);
const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g');
/**
* Represents a position of a word on a line.
*/
interface IWordPosition {
start: number;
length: number;
}
/**
* A selection mode, this drives how the selection behaves on mouse move.
*/
enum SelectionMode {
NORMAL,
WORD,
LINE
}
/**
* A class that manages the selection of the terminal. With help from
* SelectionModel, SelectionManager handles with all logic associated with
@@ -80,9 +104,9 @@ export class SelectionManager extends EventEmitter {
private _clickCount: number;
/**
* Whether line select mode is active, this occurs after a triple click.
* The current selection mode.
*/
private _isLineSelectModeActive: boolean;
private _activeSelectionMode: SelectionMode;
/**
* A setInterval timer that is active while the mouse is down whose callback
@@ -112,7 +136,7 @@ export class SelectionManager extends EventEmitter {
this._model = new SelectionModel(_terminal);
this._lastMouseDownTime = 0;
this._isLineSelectModeActive = false;
this._activeSelectionMode = SelectionMode.NORMAL;
}
/**
@@ -154,13 +178,19 @@ export class SelectionManager extends EventEmitter {
*/
public setBuffer(buffer: CircularList<any>): void {
this._buffer = buffer;
this.clearSelection();
}
/**
* Gets whether there is an active text selection.
*/
public get hasSelection(): boolean {
return !!this._model.finalSelectionStart && !!this._model.finalSelectionEnd;
const start = this._model.finalSelectionStart;
const end = this._model.finalSelectionEnd;
if (!start || !end) {
return false;
}
return start[0] !== end[0] || start[1] !== end[1];
}
/**
@@ -180,19 +210,31 @@ export class SelectionManager extends EventEmitter {
// Get middle rows
for (let i = start[1] + 1; i <= end[1] - 1; i++) {
result.push(this._translateBufferLineToString(this._buffer.get(i), true));
const bufferLine = this._buffer.get(i);
const lineText = this._translateBufferLineToString(bufferLine, true);
if (bufferLine.isWrapped) {
result[result.length - 1] += lineText;
} else {
result.push(lineText);
}
}
// Get final row
if (start[1] !== end[1]) {
result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0]));
const bufferLine = this._buffer.get(end[1]);
const lineText = this._translateBufferLineToString(bufferLine, true, 0, end[0]);
if (bufferLine.isWrapped) {
result[result.length - 1] += lineText;
} else {
result.push(lineText);
}
}
// Format string by replacing non-breaking space chars with regular spaces
// and joining the array into a multi-line string.
const formattedResult = result.map(line => {
return line.replace(ALL_NON_BREAKING_SPACE_REGEX, ' ');
}).join('\n');
}).join(Browser.isMSWindows ? '\r\n' : '\n');
return formattedResult;
}
@@ -257,11 +299,23 @@ export class SelectionManager extends EventEmitter {
/**
* Queues a refresh, redrawing the selection on the next opportunity.
* @param isNewSelection Whether the selection should be registered as a new
* selection on Linux.
*/
public refresh(): void {
public refresh(isNewSelection?: boolean): void {
// Queue the refresh for the renderer
if (!this._refreshAnimationFrame) {
this._refreshAnimationFrame = window.requestAnimationFrame(() => this._refresh());
}
// If the platform is Linux and the refresh call comes from a mouse event,
// we need to update the selection for middle click to paste selection.
if (Browser.isLinux && isNewSelection) {
const selectionText = this.selectionText;
if (selectionText.length) {
this.emit('newselection', this.selectionText);
}
}
}
/**
@@ -297,7 +351,7 @@ export class SelectionManager extends EventEmitter {
* @param event The mouse event.
*/
private _getMouseBufferCoords(event: MouseEvent): [number, number] {
const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows);
const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows, true);
// Convert to 0-based
coords[0]--;
coords[1]--;
@@ -336,6 +390,9 @@ export class SelectionManager extends EventEmitter {
return;
}
// Tell the browser not to start a regular selection
event.preventDefault();
// Reset drag scroll state
this._dragScrollAmount = 0;
@@ -354,7 +411,7 @@ export class SelectionManager extends EventEmitter {
}
this._addMouseDownListeners();
this.refresh();
this.refresh(true);
}
/**
@@ -396,7 +453,7 @@ export class SelectionManager extends EventEmitter {
private _onSingleClick(event: MouseEvent): void {
this._model.selectionStartLength = 0;
this._model.isSelectAllActive = false;
this._isLineSelectModeActive = false;
this._activeSelectionMode = SelectionMode.NORMAL;
this._model.selectionStart = this._getMouseBufferCoords(event);
if (this._model.selectionStart) {
this._model.selectionEnd = null;
@@ -416,6 +473,7 @@ export class SelectionManager extends EventEmitter {
private _onDoubleClick(event: MouseEvent): void {
const coords = this._getMouseBufferCoords(event);
if (coords) {
this._activeSelectionMode = SelectionMode.WORD;
this._selectWordAt(coords);
}
}
@@ -428,7 +486,7 @@ export class SelectionManager extends EventEmitter {
private _onTripleClick(event: MouseEvent): void {
const coords = this._getMouseBufferCoords(event);
if (coords) {
this._isLineSelectModeActive = true;
this._activeSelectionMode = SelectionMode.LINE;
this._selectLineAt(coords[1]);
}
}
@@ -473,12 +531,14 @@ export class SelectionManager extends EventEmitter {
this._model.selectionEnd = this._getMouseBufferCoords(event);
// Select the entire line if line select mode is active.
if (this._isLineSelectModeActive) {
if (this._activeSelectionMode === SelectionMode.LINE) {
if (this._model.selectionEnd[1] < this._model.selectionStart[1]) {
this._model.selectionEnd[0] = 0;
} else {
this._model.selectionEnd[0] = this._terminal.cols;
}
} else if (this._activeSelectionMode === SelectionMode.WORD) {
this._selectToWordAt(this._model.selectionEnd);
}
// Determine the amount of scrolling that will happen.
@@ -506,7 +566,7 @@ export class SelectionManager extends EventEmitter {
if (!previousSelectionEnd ||
previousSelectionEnd[0] !== this._model.selectionEnd[0] ||
previousSelectionEnd[1] !== this._model.selectionEnd[1]) {
this.refresh();
this.refresh(true);
}
}
@@ -552,11 +612,10 @@ export class SelectionManager extends EventEmitter {
}
/**
* Selects the word at the coordinates specified. Words are defined as all
* non-whitespace characters.
* Gets positional information for the word at the coordinated specified.
* @param coords The coordinates to get the word at.
*/
protected _selectWordAt(coords: [number, number]): void {
private _getWordAt(coords: [number, number]): IWordPosition {
const bufferLine = this._buffer.get(coords[1]);
const line = this._translateBufferLineToString(bufferLine, false);
@@ -595,7 +654,7 @@ export class SelectionManager extends EventEmitter {
endCol++;
}
// Expand the string in both directions until a space is hit
while (startIndex > 0 && line.charAt(startIndex - 1) !== ' ') {
while (startIndex > 0 && !this._isCharWordSeparator(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++;
@@ -604,7 +663,7 @@ export class SelectionManager extends EventEmitter {
startIndex--;
startCol--;
}
while (endIndex + 1 < line.length && line.charAt(endIndex + 1) !== ' ') {
while (endIndex + 1 < line.length && !this._isCharWordSeparator(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++;
@@ -615,9 +674,37 @@ export class SelectionManager extends EventEmitter {
}
}
// Record the resulting selection
this._model.selectionStart = [startIndex + charOffset - leftWideCharCount, coords[1]];
this._model.selectionStartLength = Math.min(endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/, this._terminal.cols);
const start = startIndex + charOffset - leftWideCharCount;
const length = Math.min(endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/, this._terminal.cols);
return {start, length};
}
/**
* Selects the word at the coordinates specified.
* @param coords The coordinates to get the word at.
*/
protected _selectWordAt(coords: [number, number]): void {
const wordPosition = this._getWordAt(coords);
this._model.selectionStart = [wordPosition.start, coords[1]];
this._model.selectionStartLength = wordPosition.length;
}
/**
* Sets the selection end to the word at the coordinated specified.
* @param coords The coordinates to get the word at.
*/
private _selectToWordAt(coords: [number, number]): void {
const wordPosition = this._getWordAt(coords);
this._model.selectionEnd = [this._model.areSelectionValuesReversed() ? wordPosition.start : (wordPosition.start + wordPosition.length), coords[1]];
}
/**
* Gets whether the character is considered a word separator by the select
* word logic.
* @param char The character to check.
*/
private _isCharWordSeparator(char: string): boolean {
return WORD_SEPARATORS.indexOf(char) >= 0;
}
/**
+1 -3
View File
@@ -11,8 +11,6 @@ class TestSelectionModel extends SelectionModel {
) {
super(terminal);
}
public areSelectionValuesReversed(): boolean { return this._areSelectionValuesReversed(); }
}
describe('SelectionManager', () => {
@@ -39,7 +37,7 @@ describe('SelectionManager', () => {
});
});
describe('_areSelectionValuesReversed', () => {
describe('areSelectionValuesReversed', () => {
it('should return true when the selection end is before selection start', () => {
model.selectionStart = [1, 0];
model.selectionEnd = [0, 0];
+3 -3
View File
@@ -59,7 +59,7 @@ export class SelectionModel {
return this.selectionStart;
}
return this._areSelectionValuesReversed() ? this.selectionEnd : this.selectionStart;
return this.areSelectionValuesReversed() ? this.selectionEnd : this.selectionStart;
}
/**
@@ -76,7 +76,7 @@ export class SelectionModel {
}
// Use the selection start if the end doesn't exist or they're reversed
if (!this.selectionEnd || this._areSelectionValuesReversed()) {
if (!this.selectionEnd || this.areSelectionValuesReversed()) {
return [this.selectionStart[0] + this.selectionStartLength, this.selectionStart[1]];
}
@@ -93,7 +93,7 @@ export class SelectionModel {
/**
* Returns whether the selection start and end are reversed.
*/
protected _areSelectionValuesReversed(): boolean {
public areSelectionValuesReversed(): boolean {
const start = this.selectionStart;
const end = this.selectionEnd;
return start[1] > end[1] || (start[1] === end[1] && start[0] > end[0]);
+23
View File
@@ -13,6 +13,7 @@ export class Viewport {
private currentRowHeight: number;
private lastRecordedBufferLength: number;
private lastRecordedViewportHeight: number;
private lastTouchY: number;
/**
* Creates a new Viewport.
@@ -121,4 +122,26 @@ export class Viewport {
// Prevent the page from scrolling when the terminal scrolls
ev.preventDefault();
};
/**
* Handles the touchstart event, recording the touch occurred.
* @param ev The touch event.
*/
public onTouchStart(ev: TouchEvent) {
this.lastTouchY = ev.touches[0].pageY;
};
/**
* Handles the touchmove event, scrolling the viewport if the position shifted.
* @param ev The touch event.
*/
public onTouchMove(ev: TouchEvent) {
let deltaY = this.lastTouchY - ev.touches[0].pageY;
this.lastTouchY = ev.touches[0].pageY;
if (deltaY === 0) {
return;
}
this.viewportElement.scrollTop += deltaY;
ev.preventDefault();
};
}
+1 -1
View File
@@ -9,6 +9,6 @@ describe('evaluatePastedTextProcessing', function () {
windowsProcessedText = Clipboard.prepareTextForTerminal(pastedText, true);
assert.equal(processedText, 'foo\r\nbar\r\n');
assert.equal(windowsProcessedText, 'foo\nbar\n');
assert.equal(windowsProcessedText, 'foo\rbar\r');
});
});
+19 -9
View File
@@ -22,7 +22,7 @@ declare var window: IWindow;
*/
export function prepareTextForTerminal(text: string, isMSWindows: boolean): string {
if (isMSWindows) {
return text.replace(/\r?\n/g, '\n');
return text.replace(/\r?\n/g, '\r');
}
return text;
}
@@ -75,12 +75,11 @@ export function pasteHandler(ev: ClipboardEvent, term: ITerminal) {
}
/**
* Bind to right-click event and allow right-click copy and paste.
* @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.
* Moves the textarea under the mouse cursor and focuses it.
* @param ev The original right click event to be handled.
* @param textarea The terminal's textarea.
*/
export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager) {
export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextAreaElement) {
// Bring textarea at the cursor position
textarea.style.position = 'fixed';
textarea.style.width = '20px';
@@ -89,10 +88,7 @@ export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement,
textarea.style.top = (ev.clientY - 10) + 'px';
textarea.style.zIndex = '1000';
// Get textarea ready to copy from the context menu
textarea.value = selectionManager.selectionText;
textarea.focus();
textarea.select();
// Reset the terminal textarea's styling
setTimeout(function () {
@@ -104,3 +100,17 @@ export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement,
textarea.style.zIndex = null;
}, 4);
}
/**
* Bind to right-click event and allow right-click copy and paste.
* @param ev The original right click event to be handled.
* @param textarea The terminal's textarea.
* @param selectionManager The terminal's selection manager.
*/
export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager) {
moveTextAreaUnderMouseCursor(ev, textarea);
// Get textarea ready to copy from the context menu
textarea.value = selectionManager.selectionText;
textarea.select();
}
+25 -5
View File
@@ -55,6 +55,11 @@ describe('xterm.js', function() {
it('should throw when setting a non-existant option', function() {
assert.throws(xterm.setOption.bind(xterm, 'fake', true));
});
it('should not allow scrollback less than number of rows', function() {
let setOptionCall = xterm.setOption.bind(xterm, 'scrollback', xterm.rows - 1);
assert.equal(setOptionCall(), false);
});
});
describe('clear', function() {
@@ -371,12 +376,17 @@ describe('xterm.js', function() {
});
});
describe('attachCustomEventHandler', function () {
describe('attachCustomKeyEventHandler', function () {
var evKeyDown = {
preventDefault: function() {},
stopPropagation: function() {},
type: 'keydown'
}
var evKeyPress = {
preventDefault: function() {},
stopPropagation: function() {},
type: 'keypress'
}
beforeEach(function() {
xterm.handler = function() {};
@@ -387,29 +397,39 @@ describe('xterm.js', function() {
bind: function() {
return function () { return true; }
}
},
keypress: {
bind: function() {
return function () { return true; }
}
}
}
});
it('should process the keydown event based on what the handler returns', function () {
it('should process the keydown/keypress event based on what the handler returns', function () {
assert.equal(xterm.keyDown(Object.assign({}, evKeyDown, { keyCode: 77 })), true);
xterm.attachCustomKeydownHandler(function (ev) {
assert.equal(xterm.keyPress(Object.assign({}, evKeyPress, { keyCode: 77 })), true);
xterm.attachCustomKeyEventHandler(function (ev) {
return ev.keyCode === 77;
});
assert.equal(xterm.keyDown(Object.assign({}, evKeyDown, { keyCode: 77 })), true);
xterm.attachCustomKeydownHandler(function (ev) {
assert.equal(xterm.keyPress(Object.assign({}, evKeyPress, { keyCode: 77 })), true);
xterm.attachCustomKeyEventHandler(function (ev) {
return ev.keyCode !== 77;
});
assert.equal(xterm.keyDown(Object.assign({}, evKeyDown, { keyCode: 77 })), false);
assert.equal(xterm.keyPress(Object.assign({}, evKeyPress, { keyCode: 77 })), false);
});
it('should alive after reset(ESC c Full Reset (RIS))', function () {
xterm.attachCustomKeydownHandler(function (ev) {
xterm.attachCustomKeyEventHandler(function (ev) {
return ev.keyCode !== 77;
});
assert.equal(xterm.keyDown(Object.assign({}, evKeyDown, { keyCode: 77 })), false);
assert.equal(xterm.keyPress(Object.assign({}, evKeyPress, { keyCode: 77 })), false);
xterm.reset();
assert.equal(xterm.keyDown(Object.assign({}, evKeyDown, { keyCode: 77 })), false);
assert.equal(xterm.keyPress(Object.assign({}, evKeyPress, { keyCode: 77 })), false);
});
});
+1
View File
@@ -20,3 +20,4 @@ export const isMac = contains(['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'], pla
export const isIpad = platform === 'iPad';
export const isIphone = platform === 'iPhone';
export const isMSWindows = contains(['Windows', 'Win16', 'Win32', 'WinCE'], platform);
export const isLinux = platform.indexOf('Linux') >= 0;
+8 -10
View File
@@ -7,21 +7,19 @@ import { ICharMeasure, ITerminal } from '../Interfaces';
import { CharMeasure } from './CharMeasure';
describe('CharMeasure', () => {
let dom: jsdom.JSDOM;
let window: Window;
let document: Document;
let container: HTMLElement;
let charMeasure: ICharMeasure;
beforeEach(done => {
jsdom.env('', (err, w) => {
window = w;
document = window.document;
container = document.createElement('div');
document.body.appendChild(container);
charMeasure = new CharMeasure(document, container);
done();
});
beforeEach(() => {
dom = new jsdom.JSDOM('');
window = dom.window;
document = window.document;
container = document.createElement('div');
document.body.appendChild(container);
charMeasure = new CharMeasure(document, container);
});
describe('measure', () => {
+8 -3
View File
@@ -30,12 +30,17 @@ export function getCoordsRelativeToElement(event: MouseEvent, element: HTMLEleme
* @param event The mouse event.
* @param rowContainer The terminal's row container.
* @param charMeasure The char measure object used to determine character sizes.
* @param colCount The number of columns in the terminal.
* @param rowCount The number of rows n the terminal.
* @param isSelection Whether the request is for the selection or not. This will
* apply an offset to the x value such that the left half of the cell will
* select that cell and the right half will select the next cell.
*/
export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number): [number, number] {
export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number, isSelection?: boolean): [number, number] {
const coords = getCoordsRelativeToElement(event, rowContainer);
// Convert to cols/rows
coords[0] = Math.ceil(coords[0] / charMeasure.width);
// Convert to cols/rows.
coords[0] = Math.ceil((coords[0] + (isSelection ? charMeasure.width / 2 : 0)) / charMeasure.width);
coords[1] = Math.ceil(coords[1] / charMeasure.height);
// Ensure coordinates are within the terminal viewport.
+4 -1
View File
@@ -192,11 +192,14 @@
position: absolute;
top: 0;
left: 0;
z-index: 1;
opacity: 0.3;
pointer-events: none;
}
.terminal .xterm-selection div {
position: absolute;
background-color: #555;
background-color: #fff;
}
/*
+115 -41
View File
@@ -13,7 +13,7 @@
import { CompositionHelper } from './CompositionHelper';
import { EventEmitter } from './EventEmitter';
import { Viewport } from './Viewport';
import { rightClickHandler, pasteHandler, copyHandler } from './handlers/Clipboard';
import { rightClickHandler, moveTextAreaUnderMouseCursor, pasteHandler, copyHandler } from './handlers/Clipboard';
import { CircularList } from './utils/CircularList';
import { C0 } from './EscapeSequences';
import { InputHandler } from './InputHandler';
@@ -167,7 +167,7 @@ function Terminal(options) {
this.queue = '';
this.scrollTop = 0;
this.scrollBottom = this.rows - 1;
this.customKeydownHandler = null;
this.customKeyEventHandler = null;
this.cursorBlinkInterval = null;
// modes
@@ -419,6 +419,15 @@ Terminal.prototype.setOption = function(key, value) {
}
switch (key) {
case 'scrollback':
if (value < this.rows) {
let msg = 'Setting the scrollback value less than the number of rows ';
msg += `(${this.rows}) is not allowed.`;
console.warn(msg);
return false;
}
if (this.options[key] !== value) {
if (this.lines.length > value) {
const amountToTrim = this.lines.length - value;
@@ -537,9 +546,11 @@ Terminal.prototype.initGlobal = function() {
on(this.textarea, 'paste', pasteHandlerWrapper);
on(this.element, 'paste', pasteHandlerWrapper);
// Handle right click context menus
if (term.browser.isFirefox) {
// Firefox doesn't appear to fire the contextmenu event on right click
on(this.element, 'mousedown', event => {
if (ev.button == 2) {
if (event.button == 2) {
rightClickHandler(event, this.textarea, this.selectionManager);
}
});
@@ -548,6 +559,19 @@ Terminal.prototype.initGlobal = function() {
rightClickHandler(event, this.textarea, this.selectionManager);
});
}
// Move the textarea under the cursor when middle clicking on Linux to ensure
// middle click to paste selection works. This only appears to work in Chrome
// at the time is writing.
if (term.browser.isLinux) {
// Use auxclick event over mousedown the latter doesn't seem to work. Note
// that the regular click event doesn't fire for the middle mouse button.
on(this.element, 'auxclick', event => {
if (event.button === 1) {
moveTextAreaUnderMouseCursor(event, this.textarea, this.selectionManager);
}
});
}
};
/**
@@ -637,7 +661,6 @@ Terminal.prototype.open = function(parent, focus) {
this.element.classList.add('xterm-theme-' + this.theme);
this.setCursorBlinking(this.options.cursorBlink);
this.element.style.height;
this.element.setAttribute('tabindex', 0);
this.viewportElement = document.createElement('div');
@@ -647,8 +670,7 @@ 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.
// Create the selection container.
this.selectionContainer = document.createElement('div');
this.selectionContainer.classList.add('xterm-selection');
this.element.appendChild(this.selectionContainer);
@@ -703,7 +725,17 @@ 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.selectionManager.on('refresh', data => {
this.renderer.refreshSelection(data.start, data.end);
});
this.selectionManager.on('newselection', text => {
// If there's a new selection, put it into the textarea, focus and select it
// in order to register it as a selection on the OS. This event is fired
// only on Linux to enable middle click to paste selection.
this.textarea.value = text;
this.textarea.focus();
this.textarea.select();
});
this.on('scroll', () => this.selectionManager.refresh());
this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh());
@@ -779,7 +811,8 @@ Terminal.loadAddon = function(addon, callback) {
Terminal.prototype.updateCharSizeStyles = function() {
this.charSizeStyleElement.textContent =
`.xterm-wide-char{width:${this.charMeasure.width * 2}px;}` +
`.xterm-normal-char{width:${this.charMeasure.width}px;}`;
`.xterm-normal-char{width:${this.charMeasure.width}px;}` +
`.xterm-rows > div{height:${this.charMeasure.height}px;}`;
}
/**
@@ -1062,6 +1095,18 @@ Terminal.prototype.bindMouse = function() {
self.viewport.onWheel(ev);
return self.cancel(ev);
});
on(el, 'touchstart', function(ev) {
if (self.mouseEvents) return;
self.viewport.onTouchStart(ev);
return self.cancel(ev);
});
on(el, 'touchmove', function(ev) {
if (self.mouseEvents) return;
self.viewport.onTouchMove(ev);
return self.cancel(ev);
});
};
/**
@@ -1102,7 +1147,7 @@ Terminal.prototype.queueLinkification = function(start, end) {
this.linkifier.linkifyRow(i);
}
}
}
};
/**
* Display the cursor element
@@ -1116,8 +1161,10 @@ Terminal.prototype.showCursor = function() {
/**
* Scroll the terminal down 1 row, creating a blank line.
* @param {boolean} isWrapped Whether the new line is wrapped from the previous
* line.
*/
Terminal.prototype.scroll = function() {
Terminal.prototype.scroll = function(isWrapped) {
var row;
// Make room for the new row in lines
@@ -1144,10 +1191,10 @@ Terminal.prototype.scroll = function() {
if (row === this.lines.length) {
// Optimization: pushing is faster than splicing when they amount to the same behavior
this.lines.push(this.blankLine());
this.lines.push(this.blankLine(undefined, isWrapped));
} else {
// add our new line
this.lines.splice(row, 0, this.blankLine());
this.lines.splice(row, 0, this.blankLine(undefined, isWrapped));
}
if (this.scrollTop !== 0) {
@@ -1211,25 +1258,25 @@ Terminal.prototype.scrollDisp = function(disp, suppressScrollEvent) {
*/
Terminal.prototype.scrollPages = function(pageCount) {
this.scrollDisp(pageCount * (this.rows - 1));
}
};
/**
* Scrolls the display of the terminal to the top.
*/
Terminal.prototype.scrollToTop = function() {
this.scrollDisp(-this.ydisp);
}
};
/**
* Scrolls the display of the terminal to the bottom.
*/
Terminal.prototype.scrollToBottom = function() {
this.scrollDisp(this.ybase - this.ydisp);
}
};
/**
* Writes text to the terminal.
* @param {string} text The text to write to the terminal.
* @param {string} data The text to write to the terminal.
*/
Terminal.prototype.write = function(data) {
this.writeBuffer.push(data);
@@ -1253,7 +1300,7 @@ Terminal.prototype.write = function(data) {
self.innerWrite();
});
}
}
};
Terminal.prototype.innerWrite = function() {
var writeBatch = this.writeBuffer.splice(0, WRITE_BATCH_SIZE);
@@ -1295,29 +1342,41 @@ Terminal.prototype.innerWrite = function() {
/**
* Writes text to the terminal, followed by a break line character (\n).
* @param {string} text The text to write to the terminal.
* @param {string} data The text to write to the terminal.
*/
Terminal.prototype.writeln = function(data) {
this.write(data + '\r\n');
};
/**
* Attaches a custom keydown handler which is run before keys are processed, giving consumers of
* xterm.js ultimate control as to what keys should be processed by the terminal and what keys
* should not.
* DEPRECATED: only for backward compatibility. Please use attachCustomKeyEventHandler() instead.
* @param {function} customKeydownHandler The custom KeyboardEvent handler to attach. This is a
* function that takes a KeyboardEvent, allowing consumers to stop propogation and/or prevent
* the default action. The function returns whether the event should be processed by xterm.js.
*/
Terminal.prototype.attachCustomKeydownHandler = function(customKeydownHandler) {
this.customKeydownHandler = customKeydownHandler;
}
let message = 'attachCustomKeydownHandler() is DEPRECATED and will be removed soon. Please use attachCustomKeyEventHandler() instead.';
console.warn(message);
this.attachCustomKeyEventHandler(customKeydownHandler);
};
/**
* Attaches a custom key event handler which is run before keys are processed, giving consumers of
* xterm.js ultimate control as to what keys should be processed by the terminal and what keys
* should not.
* @param {function} customKeyEventHandler The custom KeyboardEvent handler to attach. This is a
* function that takes a KeyboardEvent, allowing consumers to stop propogation and/or prevent
* the default action. The function returns whether the event should be processed by xterm.js.
*/
Terminal.prototype.attachCustomKeyEventHandler = function(customKeyEventHandler) {
this.customKeyEventHandler = customKeyEventHandler;
};
/**
* Attaches a http(s) link handler, forcing web links to behave differently to
* regular <a> tags. This will trigger a refresh as links potentially need to be
* reconstructed. Calling this with null will remove the handler.
* @param {LinkHandler} handler The handler callback function.
* @param {LinkMatcherHandler} handler The handler callback function.
*/
Terminal.prototype.setHypertextLinkHandler = function(handler) {
if (!this.linkifier) {
@@ -1326,7 +1385,7 @@ Terminal.prototype.setHypertextLinkHandler = function(handler) {
this.linkifier.setHypertextLinkHandler(handler);
// Refresh to force links to refresh
this.refresh(0, this.rows - 1);
}
};
/**
* Attaches a validation callback for hypertext links. This is useful to use
@@ -1334,14 +1393,14 @@ Terminal.prototype.setHypertextLinkHandler = function(handler) {
* @param {LinkMatcherValidationCallback} callback The callback to use, this can
* be cleared with null.
*/
Terminal.prototype.setHypertextValidationCallback = function(handler) {
Terminal.prototype.setHypertextValidationCallback = function(callback) {
if (!this.linkifier) {
throw new Error('Cannot attach a hypertext validation callback before Terminal.open is called');
}
this.linkifier.setHypertextValidationCallback(handler);
this.linkifier.setHypertextValidationCallback(callback);
// Refresh to force links to refresh
this.refresh(0, this.rows - 1);
}
};
/**
* Registers a link matcher, allowing custom link patterns to be matched and
@@ -1349,7 +1408,7 @@ Terminal.prototype.setHypertextValidationCallback = function(handler) {
* @param {RegExp} regex The regular expression to search for, specifically
* this searches the textContent of the rows. You will want to use \s to match
* a space ' ' character for example.
* @param {LinkHandler} handler The callback when the link is called.
* @param {LinkMatcherHandler} handler The callback when the link is called.
* @param {LinkMatcherOptions} [options] Options for the link matcher.
* @return {number} The ID of the new matcher, this can be used to deregister.
*/
@@ -1359,7 +1418,7 @@ Terminal.prototype.registerLinkMatcher = function(regex, handler, options) {
this.refresh(0, this.rows - 1);
return matcherId;
}
}
};
/**
* Deregisters a link matcher if it has been registered.
@@ -1371,14 +1430,14 @@ Terminal.prototype.deregisterLinkMatcher = function(matcherId) {
this.refresh(0, this.rows - 1);
}
}
}
};
/**
* Gets whether the terminal has an active selection.
*/
Terminal.prototype.hasSelection = function() {
return this.selectionManager.hasSelection;
}
};
/**
* Gets the terminal's current selection, this is useful for implementing copy
@@ -1386,21 +1445,21 @@ Terminal.prototype.hasSelection = function() {
*/
Terminal.prototype.getSelection = function() {
return this.selectionManager.selectionText;
}
};
/**
* Clears the current terminal selection.
*/
Terminal.prototype.clearSelection = function() {
this.selectionManager.clearSelection();
}
};
/**
* Selects all text within the terminal.
*/
Terminal.prototype.selectAll = function() {
this.selectionManager.selectAll();
}
};
/**
* Handle a keydown event
@@ -1409,7 +1468,7 @@ Terminal.prototype.selectAll = function() {
* @param {KeyboardEvent} ev The keydown event to be handled.
*/
Terminal.prototype.keyDown = function(ev) {
if (this.customKeydownHandler && this.customKeydownHandler(ev) === false) {
if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) {
return false;
}
@@ -1774,6 +1833,10 @@ Terminal.prototype.setgCharset = function(g, charset) {
Terminal.prototype.keyPress = function(ev) {
var key;
if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) {
return false;
}
this.cancel(ev);
if (ev.charCode) {
@@ -1799,7 +1862,7 @@ Terminal.prototype.keyPress = function(ev) {
this.showCursor();
this.handler(key);
return false;
return true;
};
/**
@@ -1864,6 +1927,10 @@ Terminal.prototype.resize = function(x, y) {
return;
}
if (y > this.getOption('scrollback')) {
this.setOption('scrollback', y)
}
var line
, el
, i
@@ -1905,7 +1972,7 @@ Terminal.prototype.resize = function(x, y) {
// There is room above the buffer and there are no empty elements below the line,
// scroll up
this.ybase--;
addToY++
addToY++;
if (this.ydisp > 0) {
// Viewport is at the top of the buffer, must increase downwards
this.ydisp--;
@@ -2106,8 +2173,9 @@ Terminal.prototype.eraseLine = function(y) {
/**
* Return the data array of a blank line
* @param {number} cur First bunch of data for each "blank" character.
* @param {boolean} isWrapped Whether the new line is wrapped from the previous line.
*/
Terminal.prototype.blankLine = function(cur) {
Terminal.prototype.blankLine = function(cur, isWrapped) {
var attr = cur
? this.eraseAttr()
: this.defAttr;
@@ -2116,6 +2184,12 @@ Terminal.prototype.blankLine = function(cur) {
, line = []
, i = 0;
// TODO: It is not ideal that this is a property on an array, a buffer line
// class should be added that will hold this data and other useful functions.
if (isWrapped) {
line.isWrapped = isWrapped;
}
for (; i < this.cols; i++) {
line[i] = ch;
}
@@ -2225,10 +2299,10 @@ Terminal.prototype.reverseIndex = function() {
Terminal.prototype.reset = function() {
this.options.rows = this.rows;
this.options.cols = this.cols;
var customKeydownHandler = this.customKeydownHandler;
var customKeyEventHandler = this.customKeyEventHandler;
var cursorBlinkInterval = this.cursorBlinkInterval;
Terminal.call(this, this.options);
this.customKeydownHandler = customKeydownHandler;
this.customKeyEventHandler = customKeyEventHandler;
this.cursorBlinkInterval = cursorBlinkInterval;
this.refresh(0, this.rows - 1);
this.viewport.syncScrollArea();