Merge remote-tracking branch 'origin/master' into 426_alt_scroll

This commit is contained in:
Daniel Imms
2018-03-05 11:48:40 -08:00
35 changed files with 4539 additions and 193 deletions
+287
View File
@@ -0,0 +1,287 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import * as Strings from './Strings';
import { ITerminal, IBuffer } from './Types';
import { isMac } from './shared/utils/Browser';
import { RenderDebouncer } from './utils/RenderDebouncer';
import { addDisposableListener } from './utils/Dom';
import { IDisposable } from 'xterm';
const MAX_ROWS_TO_READ = 20;
const ACTIVE_ITEM_ID_PREFIX = 'xterm-active-item-';
enum BoundaryPosition {
Top,
Bottom
}
export class AccessibilityManager implements IDisposable {
private _accessibilityTreeRoot: HTMLElement;
private _rowContainer: HTMLElement;
private _rowElements: HTMLElement[] = [];
private _liveRegion: HTMLElement;
private _liveRegionLineCount: number = 0;
private _renderRowsDebouncer: RenderDebouncer;
private _topBoundaryFocusListener: (e: FocusEvent) => void;
private _bottomBoundaryFocusListener: (e: FocusEvent) => void;
private _disposables: IDisposable[] = [];
/**
* This queue has a character pushed to it for keys that are pressed, if the
* next character added to the terminal is equal to the key char then it is
* not announced (added to live region) because it has already been announced
* by the textarea event (which cannot be canceled). There are some race
* condition cases if there is typing while data is streaming, but this covers
* the main case of typing into the prompt and inputting the answer to a
* question (Y/N, etc.).
*/
private _charsToConsume: string[] = [];
constructor(private _terminal: ITerminal) {
this._accessibilityTreeRoot = document.createElement('div');
this._accessibilityTreeRoot.classList.add('xterm-accessibility');
this._rowContainer = document.createElement('div');
this._rowContainer.classList.add('xterm-accessibility-tree');
for (let i = 0; i < this._terminal.rows; i++) {
this._rowElements[i] = this._createAccessibilityTreeNode();
this._rowContainer.appendChild(this._rowElements[i]);
}
this._topBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.Top);
this._bottomBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.Bottom);
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
this._refreshRowsDimensions();
this._accessibilityTreeRoot.appendChild(this._rowContainer);
this._renderRowsDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this));
this._refreshRows();
this._liveRegion = document.createElement('div');
this._liveRegion.classList.add('live-region');
this._liveRegion.setAttribute('aria-live', 'assertive');
this._accessibilityTreeRoot.appendChild(this._liveRegion);
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot);
this._disposables.push(this._renderRowsDebouncer);
this._disposables.push(this._terminal.addDisposableListener('resize', data => this._onResize(data.cols, data.rows)));
this._disposables.push(this._terminal.addDisposableListener('refresh', data => this._refreshRows(data.start, data.end)));
this._disposables.push(this._terminal.addDisposableListener('scroll', data => this._refreshRows()));
// Line feed is an issue as the prompt won't be read out after a command is run
this._disposables.push(this._terminal.addDisposableListener('a11y.char', (char) => this._onChar(char)));
this._disposables.push(this._terminal.addDisposableListener('linefeed', () => this._onChar('\n')));
this._disposables.push(this._terminal.addDisposableListener('a11y.tab', spaceCount => this._onTab(spaceCount)));
this._disposables.push(this._terminal.addDisposableListener('key', keyChar => this._onKey(keyChar)));
this._disposables.push(this._terminal.addDisposableListener('blur', () => this._clearLiveRegion()));
// TODO: Maybe renderer should fire an event on terminal when the characters change and that
// should be listened to instead? That would mean that the order of events are always
// guarenteed
this._disposables.push(this._terminal.addDisposableListener('dprchange', () => this._refreshRowsDimensions()));
this._disposables.push(this._terminal.renderer.addDisposableListener('resize', () => this._refreshRowsDimensions()));
// This shouldn't be needed on modern browsers but is present in case the
// media query that drives the dprchange event isn't supported
this._disposables.push(addDisposableListener(window, 'resize', () => this._refreshRowsDimensions()));
}
public dispose(): void {
this._terminal.element.removeChild(this._accessibilityTreeRoot);
this._disposables.forEach(d => d.dispose());
this._disposables = null;
this._accessibilityTreeRoot = null;
this._rowContainer = null;
this._liveRegion = null;
this._rowContainer = null;
this._rowElements = null;
}
private _onBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void {
const boundaryElement = <HTMLElement>e.target;
const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2];
// Don't scroll if the buffer top has reached the end in that direction
const posInSet = boundaryElement.getAttribute('aria-posinset');
const lastRowPos = position === BoundaryPosition.Top ? '1' : `${this._terminal.buffer.lines.length}`;
if (posInSet === lastRowPos) {
return;
}
// Don't scroll when the last focused item was not the second row (focus is going the other
// direction)
if (e.relatedTarget !== beforeBoundaryElement) {
return;
}
// Remove old boundary element from array
let topBoundaryElement: HTMLElement;
let bottomBoundaryElement: HTMLElement;
if (position === BoundaryPosition.Top) {
topBoundaryElement = boundaryElement;
bottomBoundaryElement = this._rowElements.pop();
this._rowContainer.removeChild(bottomBoundaryElement);
} else {
topBoundaryElement = this._rowElements.shift();
bottomBoundaryElement = boundaryElement;
this._rowContainer.removeChild(topBoundaryElement);
}
// Remove listeners from old boundary elements
topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener);
bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener);
// Add new element to array/DOM
if (position === BoundaryPosition.Top) {
const newElement = this._createAccessibilityTreeNode();
this._rowElements.unshift(newElement);
this._rowContainer.insertAdjacentElement('afterbegin', newElement);
} else {
const newElement = this._createAccessibilityTreeNode();
this._rowElements.push(newElement);
this._rowContainer.appendChild(newElement);
}
// Add listeners to new boundary elements
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
// Scroll up
this._terminal.scrollLines(position === BoundaryPosition.Top ? -1 : 1);
// Focus new boundary before element
this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2].focus();
// Prevent the standard behavior
e.preventDefault();
e.stopImmediatePropagation();
}
private _onResize(cols: number, rows: number): void {
// Remove bottom boundary listener
this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);
// Grow rows as required
for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) {
this._rowElements[i] = this._createAccessibilityTreeNode();
this._rowContainer.appendChild(this._rowElements[i]);
}
// Shrink rows as required
while (this._rowElements.length > rows) {
this._rowContainer.removeChild(this._rowElements.pop());
}
// Add bottom boundary listener
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
this._refreshRowsDimensions();
}
public _createAccessibilityTreeNode(): HTMLElement {
const element = document.createElement('div');
element.setAttribute('role', 'listitem');
element.tabIndex = -1;
this._refreshRowDimensions(element);
return element;
}
private _onTab(spaceCount: number): void {
for (let i = 0; i < spaceCount; i++) {
this._onChar(' ');
}
}
private _onChar(char: string): void {
if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
if (this._charsToConsume.length > 0) {
// Have the screen reader ignore the char if it was just input
const shiftedChar = this._charsToConsume.shift();
if (shiftedChar !== char) {
this._announceCharacter(char);
}
} else {
this._announceCharacter(char);
}
if (char === '\n') {
this._liveRegionLineCount++;
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
this._liveRegion.textContent += Strings.tooMuchOutput;
}
}
// Only detach/attach on mac as otherwise messages can go unaccounced
if (isMac) {
if (this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) {
setTimeout(() => {
this._accessibilityTreeRoot.appendChild(this._liveRegion);
}, 0);
}
}
}
}
private _clearLiveRegion(): void {
this._liveRegion.textContent = '';
this._liveRegionLineCount = 0;
// Only detach/attach on mac as otherwise messages can go unaccounced
if (isMac) {
if (this._liveRegion.parentNode) {
this._accessibilityTreeRoot.removeChild(this._liveRegion);
}
}
}
private _onKey(keyChar: string): void {
this._clearLiveRegion();
this._charsToConsume.push(keyChar);
}
private _refreshRows(start?: number, end?: number): void {
this._renderRowsDebouncer.refresh(start, end);
}
private _renderRows(start: number, end: number): void {
const buffer: IBuffer = this._terminal.buffer;
const setSize = buffer.lines.length.toString();
for (let i = start; i <= end; i++) {
const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true);
const posInSet = (buffer.ydisp + i + 1).toString();
const element = this._rowElements[i];
element.textContent = lineData.length === 0 ? Strings.blankLine : lineData;
element.setAttribute('aria-posinset', posInSet);
element.setAttribute('aria-setsize', setSize);
}
}
private _refreshRowsDimensions(): void {
if (!this._terminal.renderer.dimensions.actualCellHeight) {
return;
}
const buffer: IBuffer = this._terminal.buffer;
for (let i = 0; i < this._terminal.rows; i++) {
this._refreshRowDimensions(this._rowElements[i]);
}
}
private _refreshRowDimensions(element: HTMLElement): void {
element.style.height = `${this._terminal.renderer.dimensions.actualCellHeight}px`;
}
private _announceCharacter(char: string): void {
if (char === ' ') {
// Always use nbsp for spaces in order to preserve the space between characters in
// voiceover's caption window
this._liveRegion.innerHTML += '&nbsp;';
} else {
this._liveRegion.textContent += char;
}
}
}
+25 -5
View File
@@ -3,10 +3,11 @@
* @license MIT
*/
import { IEventEmitter } from 'xterm';
import { XtermListener } from './Types';
import { IEventEmitter, IDisposable } from 'xterm';
export class EventEmitter implements IEventEmitter {
private _events: {[type: string]: ((...args: any[]) => void)[]};
private _events: {[type: string]: XtermListener[]};
constructor() {
// Restore the previous events if available, this will happen if the
@@ -14,12 +15,31 @@ export class EventEmitter implements IEventEmitter {
this._events = this._events || {};
}
public on(type: string, listener: ((...args: any[]) => void)): void {
public on(type: string, listener: XtermListener): void {
this._events[type] = this._events[type] || [];
this._events[type].push(listener);
}
public off(type: string, listener: ((...args: any[]) => void)): void {
/**
* Adds a disposabe listener to the EventEmitter, returning the disposable.
* @param type The event type.
* @param handler The handler for the listener.
*/
public addDisposableListener(type: string, handler: XtermListener): IDisposable {
this.on(type, handler);
return {
dispose: () => {
if (!handler) {
// Already disposed
return;
}
this.off(type, handler);
handler = null;
}
};
}
public off(type: string, listener: XtermListener): void {
if (!this._events[type]) {
return;
}
@@ -51,7 +71,7 @@ export class EventEmitter implements IEventEmitter {
}
}
public listeners(type: string): ((...args: any[]) => void)[] {
public listeners(type: string): XtermListener[] {
return this._events[type] || [];
}
+8
View File
@@ -31,6 +31,10 @@ export class InputHandler implements IInputHandler {
char = this._terminal.charset[char];
}
if (this._terminal.options.screenReaderMode) {
this._terminal.emit('a11y.char', char);
}
let row = this._terminal.buffer.y + this._terminal.buffer.ybase;
// insert combining char in last cell
@@ -162,7 +166,11 @@ export class InputHandler implements IInputHandler {
* Horizontal Tab (HT) (Ctrl-I).
*/
public tab(): void {
const originalX = this._terminal.buffer.x;
this._terminal.buffer.x = this._terminal.buffer.nextStop();
if (this._terminal.options.screenReaderMode) {
this._terminal.emit('a11y.tab', this._terminal.buffer.x - originalX);
}
}
/**
+7
View File
@@ -150,6 +150,7 @@ export class Linkifier extends EventEmitter implements ILinkifier {
validationCallback: options.validationCallback,
hoverTooltipCallback: options.tooltipCallback,
hoverLeaveCallback: options.leaveCallback,
willLinkActivate: options.willLinkActivate,
priority: options.priority || 0
};
this._addLinkMatcherToList(matcher);
@@ -290,6 +291,12 @@ export class Linkifier extends EventEmitter implements ILinkifier {
if (matcher.hoverLeaveCallback) {
matcher.hoverLeaveCallback();
}
},
e => {
if (matcher.willLinkActivate) {
return matcher.willLinkActivate(e, uri);
}
return true;
}
));
}
+1 -1
View File
@@ -28,7 +28,7 @@ class TestSelectionManager extends SelectionManager {
public get model(): SelectionModel { return this._model; }
public selectLineAt(line: number): void { this._selectLineAt(line); }
public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords); }
public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); }
// Disable DOM interaction
public enable(): void {}
+45 -9
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { ITerminal, ICircularList, ISelectionManager, IBuffer, LineData, CharData } from './Types';
import { ITerminal, ICircularList, ISelectionManager, IBuffer, LineData, CharData, XtermListener } from './Types';
import { MouseHelper } from './utils/MouseHelper';
import * as Browser from './shared/utils/Browser';
import { CharMeasure } from './utils/CharMeasure';
@@ -101,7 +101,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
private _mouseMoveListener: EventListener;
private _mouseUpListener: EventListener;
private _trimListener: (...args: any[]) => void;
private _trimListener: XtermListener;
private _mouseDownTimeStamp: number;
@@ -253,6 +253,37 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd });
}
/**
* Checks if the current click was inside the current selection
* @param event The mouse event
*/
public isClickInSelection(event: MouseEvent): boolean {
const coords = this._getMouseBufferCoords(event);
const start = this._model.finalSelectionStart;
const end = this._model.finalSelectionEnd;
if (!start || !end) {
return false;
}
return (coords[1] > start[1] && coords[1] < end[1]) ||
(start[1] === end[1] && coords[1] === start[1] && coords[0] > start[0] && coords[0] < end[0]) ||
(start[1] < end[1] && coords[1] === end[1] && coords[0] < end[0]);
}
/**
* Selects word at the current mouse event coordinates.
* @param event The mouse event.
*/
public selectWordAtCursor(event: MouseEvent): void {
const coords = this._getMouseBufferCoords(event);
if (coords) {
this._selectWordAt(coords, false);
this._model.selectionEnd = null;
this.refresh(true);
}
}
/**
* Selects all text within the terminal.
*/
@@ -278,7 +309,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
* @param event The mouse event.
*/
private _getMouseBufferCoords(event: MouseEvent): [number, number] {
const coords = this._terminal.mouseHelper.getCoords(event, this._terminal.element, this._charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows, true);
const coords = this._terminal.mouseHelper.getCoords(event, this._terminal.screenElement, this._charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows, true);
if (!coords) {
return null;
}
@@ -298,7 +329,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
* @param event The mouse event.
*/
private _getMouseEventScrollAmount(event: MouseEvent): number {
let offset = MouseHelper.getCoordsRelativeToElement(event, this._terminal.element)[1];
let offset = MouseHelper.getCoordsRelativeToElement(event, this._terminal.screenElement)[1];
const terminalHeight = this._terminal.rows * Math.ceil(this._charMeasure.height * this._terminal.options.lineHeight);
if (offset >= 0 && offset <= terminalHeight) {
return 0;
@@ -445,7 +476,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
const coords = this._getMouseBufferCoords(event);
if (coords) {
this._activeSelectionMode = SelectionMode.WORD;
this._selectWordAt(coords);
this._selectWordAt(coords, true);
}
}
@@ -602,7 +633,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
* Gets positional information for the word at the coordinated specified.
* @param coords The coordinates to get the word at.
*/
private _getWordAt(coords: [number, number]): IWordPosition {
private _getWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean): IWordPosition {
const bufferLine = this._buffer.lines.get(coords[1]);
if (!bufferLine) {
return null;
@@ -708,15 +739,20 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
- leftLongCharOffset // The number of additional chars left of the initial char added by columns with strings longer than 1 (emojis)
- rightLongCharOffset); // The number of additional chars right of the initial char (inclusive) added by columns with strings longer than 1 (emojis)
if (!allowWhitespaceOnlySelection && line.slice(startIndex, endIndex).trim() === '') {
return null;
}
return { start, length };
}
/**
* Selects the word at the coordinates specified.
* @param coords The coordinates to get the word at.
* @param allowWhitespaceOnlySelection If whitespace should be selected
*/
protected _selectWordAt(coords: [number, number]): void {
const wordPosition = this._getWordAt(coords);
protected _selectWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean): void {
const wordPosition = this._getWordAt(coords, allowWhitespaceOnlySelection);
if (wordPosition) {
this._model.selectionStart = [wordPosition.start, coords[1]];
this._model.selectionStartLength = wordPosition.length;
@@ -728,7 +764,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
* @param coords The coordinates to get the word at.
*/
private _selectToWordAt(coords: [number, number]): void {
const wordPosition = this._getWordAt(coords);
const wordPosition = this._getWordAt(coords, true);
if (wordPosition) {
this._model.selectionEnd = [this._model.areSelectionValuesReversed() ? wordPosition.start : (wordPosition.start + wordPosition.length), coords[1]];
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ITerminal, ISoundManager } from './Types';
// Source: https://freesound.org/people/altemark/sounds/45759/
// This sound is released under the Creative Commons Attribution 3.0 Unported
// (CC BY 3.0) license. It was created by 'altemark'. No modifications have been
// made, apart from the conversion to base64.
export const DEFAULT_BELL_SOUND = 'data:audio/wav;base64,UklGRigBAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQBAADpAFgCwAMlBZoG/wdmCcoKRAypDQ8PbRDBEQQTOxRtFYcWlBePGIUZXhoiG88bcBz7HHIdzh0WHlMeZx51HmkeUx4WHs8dah0AHXwc3hs9G4saxRnyGBIYGBcQFv8U4RPAEoYRQBACD70NWwwHC6gJOwjWBloF7gOBAhABkf8b/qv8R/ve+Xf4Ife79W/0JfPZ8Z/wde9N7ijtE+wU6xvqM+lb6H7nw+YX5mrlxuQz5Mzje+Ma49fioeKD4nXiYeJy4pHitOL04j/jn+MN5IPkFOWs5U3mDefM55/ogOl36m7rdOyE7abuyu8D8Unyj/Pg9D/2qfcb+Yn6/vuK/Qj/lAAlAg==';
export class SoundManager implements ISoundManager {
private _audioContext: AudioContext;
constructor(
private _terminal: ITerminal
) {
}
public playBellSound(): void {
const audioContextCtor: typeof AudioContext = (<any>window).AudioContext || (<any>window).webkitAudioContext;
if (!this._audioContext && audioContextCtor) {
this._audioContext = new audioContextCtor();
}
if (this._audioContext) {
const bellAudioSource = this._audioContext.createBufferSource();
const context = this._audioContext;
this._audioContext.decodeAudioData(this.base64ToArrayBuffer(this.removeMimeType(this._terminal.options.bellSound)), (buffer) => {
bellAudioSource.buffer = buffer;
bellAudioSource.connect(context.destination);
bellAudioSource.start(0);
});
} else {
console.warn('Sorry, but the Web Audio API is not supported by your browser. Please, consider upgrading to the latest version');
}
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = window.atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
private removeMimeType(dataURI: string): string {
// Split the input to get the mime-type and the data itself
const splitUri = dataURI.split(',');
// Return only the data
return splitUri[1];
}
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
export let blankLine = 'Blank line';
export let promptLabel = 'Terminal input';
export let tooMuchOutput = 'Too much output to announce, navigate to rows manually to read';
+93
View File
@@ -578,6 +578,99 @@ describe('term.js addons', () => {
assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 122 }).key, '\x1b[23;5~');
assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 123 }).key, '\x1b[24;5~');
});
// Characters using ctrl+alt sequences
it('should return proper sequence for ctrl+alt+a', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, ctrlKey: true, keyCode: 65 }).key, '\x1b\x01');
});
// Characters using alt sequences (numbers)
it('should return proper sequences for alt+0', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 48 }).key, '\x1b0');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 48 }).key, '\x1b)');
});
it('should return proper sequences for alt+1', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 49 }).key, '\x1b1');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 49 }).key, '\x1b!');
});
it('should return proper sequences for alt+2', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 50 }).key, '\x1b2');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 50 }).key, '\x1b@');
});
it('should return proper sequences for alt+3', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 51 }).key, '\x1b3');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 51 }).key, '\x1b#');
});
it('should return proper sequences for alt+4', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 52 }).key, '\x1b4');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 52 }).key, '\x1b$');
});
it('should return proper sequences for alt+5', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 53 }).key, '\x1b5');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 53 }).key, '\x1b%');
});
it('should return proper sequences for alt+6', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 54 }).key, '\x1b6');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 54 }).key, '\x1b^');
});
it('should return proper sequences for alt+7', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 55 }).key, '\x1b7');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 55 }).key, '\x1b&');
});
it('should return proper sequences for alt+8', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 56 }).key, '\x1b8');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 56 }).key, '\x1b*');
});
it('should return proper sequences for alt+9', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 57 }).key, '\x1b9');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 57 }).key, '\x1b(');
});
// Characters using alt sequences (special chars)
it('should return proper sequences for alt+;', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 186 }).key, '\x1b;');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 186 }).key, '\x1b:');
});
it('should return proper sequences for alt+=', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 187 }).key, '\x1b=');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 187 }).key, '\x1b+');
});
it('should return proper sequences for alt+,', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 188 }).key, '\x1b,');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 188 }).key, '\x1b<');
});
it('should return proper sequences for alt+-', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 189 }).key, '\x1b-');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 189 }).key, '\x1b_');
});
it('should return proper sequences for alt+.', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 190 }).key, '\x1b.');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 190 }).key, '\x1b>');
});
it('should return proper sequences for alt+/', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 191 }).key, '\x1b/');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 191 }).key, '\x1b?');
});
it('should return proper sequences for alt+~', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 192 }).key, '\x1b`');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 192 }).key, '\x1b~');
});
it('should return proper sequences for alt+[', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 219 }).key, '\x1b[');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 219 }).key, '\x1b{');
});
it('should return proper sequences for alt+\\', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 220 }).key, '\x1b\\');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 220 }).key, '\x1b|');
});
it('should return proper sequences for alt+]', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 221 }).key, '\x1b]');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 221 }).key, '\x1b}');
});
it('should return proper sequences for alt+\'', () => {
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 222 }).key, '\x1b\'');
assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 222 }).key, '\x1b"');
});
});
describe('Third level shift', () => {
+107 -61
View File
@@ -39,12 +39,43 @@ import { Linkifier } from './Linkifier';
import { SelectionManager } from './SelectionManager';
import { CharMeasure } from './utils/CharMeasure';
import * as Browser from './shared/utils/Browser';
import * as Strings from './Strings';
import { MouseHelper } from './utils/MouseHelper';
import { CHARSETS } from './Charsets';
import { BELL_SOUND } from './utils/Sounds';
import { DEFAULT_BELL_SOUND, SoundManager } from './SoundManager';
import { DEFAULT_ANSI_COLORS } from './renderer/ColorManager';
import { MouseZoneManager } from './input/MouseZoneManager';
import { ITheme } from 'xterm';
import { AccessibilityManager } from './AccessibilityManager';
import { ScreenDprMonitor } from './utils/ScreenDprMonitor';
import { ITheme, ILocalizableStrings } from 'xterm';
// reg + shift key mappings for digits and special chars
const KEYCODE_KEY_MAPPINGS = {
// digits 0-9
48: ['0', ')'],
49: ['1', '!'],
50: ['2', '@'],
51: ['3', '#'],
52: ['4', '$'],
53: ['5', '%'],
54: ['6', '^'],
55: ['7', '&'],
56: ['8', '*'],
57: ['9', '('],
// special chars
186: [';', ':'],
187: ['=', '+'],
188: [',', '<'],
189: ['-', '_'],
190: ['.', '>'],
191: ['/', '?'],
192: ['`', '~'],
219: ['[', '{'],
220: ['\\', '|'],
221: [']', '}'],
222: ['\'', '"']
};
// Let it work inside Node.js for automated testing purposes.
const document = (typeof window !== 'undefined') ? window.document : null;
@@ -69,7 +100,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = {
termName: 'xterm',
cursorBlink: false,
cursorStyle: 'block',
bellSound: BELL_SOUND,
bellSound: DEFAULT_BELL_SOUND,
bellStyle: 'none',
enableBold: true,
fontFamily: 'courier-new, courier, monospace',
@@ -80,6 +111,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = {
letterSpacing: 0,
scrollback: 1000,
screenKeys: false,
screenReaderMode: false,
debug: false,
macOptionIsMeta: false,
cancelEvents: false,
@@ -87,7 +119,8 @@ const DEFAULT_OPTIONS: ITerminalOptions = {
useFlowControl: false,
allowTransparency: false,
tabStopWidth: 8,
theme: null
theme: null,
rightClickSelectsWord: Browser.isMac
// programFeatures: false,
// focusKeys: false,
};
@@ -95,6 +128,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = {
export class Terminal extends EventEmitter implements ITerminal, IInputHandlingTerminal {
public textarea: HTMLTextAreaElement;
public element: HTMLElement;
public screenElement: HTMLElement;
/**
* The HTMLElement that the terminal is created in, set by Terminal.open.
@@ -108,7 +142,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
private helperContainer: HTMLElement;
private compositionView: HTMLElement;
private charSizeStyleElement: HTMLStyleElement;
private bellAudioElement: HTMLAudioElement;
private visualBellTimer: number;
public browser: IBrowser = <any>Browser;
@@ -190,6 +224,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
private userScrolling: boolean;
private inputHandler: InputHandler;
public soundManager: SoundManager;
private parser: Parser;
public renderer: IRenderer;
public selectionManager: SelectionManager;
@@ -200,6 +235,8 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
public charMeasure: CharMeasure;
private _mouseZoneManager: IMouseZoneManager;
public mouseHelper: MouseHelper;
private _accessibilityManager: AccessibilityManager;
private _screenDprMonitor: ScreenDprMonitor;
public cols: number;
public rows: number;
@@ -292,6 +329,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.selectionManager = this.selectionManager || null;
this.linkifier = this.linkifier || new Linkifier(this);
this._mouseZoneManager = this._mouseZoneManager || null;
this.soundManager = this.soundManager || new SoundManager(this);
// Create the terminal's buffers and set the current buffer
this.buffers = new BufferSet(this);
@@ -308,6 +346,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
return this.buffers.active;
}
public static get strings(): ILocalizableStrings {
return Strings;
}
/**
* back_color_erase feature for xterm.
*/
@@ -440,9 +482,19 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.buffers.resize(this.cols, this.rows);
this.viewport.syncScrollArea();
break;
case 'screenReaderMode':
if (value) {
if (!this._accessibilityManager) {
this._accessibilityManager = new AccessibilityManager(this);
}
} else {
if (this._accessibilityManager) {
this._accessibilityManager.dispose();
this._accessibilityManager = null;
}
}
break;
case 'tabStopWidth': this.buffers.setupTabStops(); break;
case 'bellSound':
case 'bellStyle': this.syncBellSound(); break;
}
// Inform renderer of changes
if (this.renderer) {
@@ -474,6 +526,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
* Binds the desired blur behavior on a given terminal object.
*/
private _onTextAreaBlur(): void {
// Text can safely be removed on blur. Doing it earlier could interfere with
// screen readers reading it out.
this.textarea.value = '';
this.refresh(this.buffer.y, this.buffer.y);
if (this.sendFocus) {
this.send(C0.ESC + '[O');
@@ -506,12 +561,12 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
// Firefox doesn't appear to fire the contextmenu event on right click
on(this.element, 'mousedown', (event: MouseEvent) => {
if (event.button === 2) {
rightClickHandler(event, this.textarea, this.selectionManager);
rightClickHandler(event, this.textarea, this.selectionManager, this.options.rightClickSelectsWord);
}
});
} else {
on(this.element, 'contextmenu', (event: MouseEvent) => {
rightClickHandler(event, this.textarea, this.selectionManager);
rightClickHandler(event, this.textarea, this.selectionManager, this.options.rightClickSelectsWord);
});
}
@@ -554,16 +609,8 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
}
}, true);
on(this.textarea, 'keydown', (ev: KeyboardEvent) => {
this._keyDown(ev);
}, true);
on(this.textarea, 'keypress', (ev: KeyboardEvent) => {
this._keyPress(ev);
// Truncate the textarea's value, since it is not needed
this.textarea.value = '';
}, true);
on(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true);
on(this.textarea, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true);
on(this.textarea, 'compositionstart', () => this.compositionHelper.compositionstart());
on(this.textarea, 'compositionupdate', (e: CompositionEvent) => this.compositionHelper.compositionupdate(e));
on(this.textarea, 'compositionend', () => this.compositionHelper.compositionend());
@@ -591,6 +638,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.document = this.parent.ownerDocument;
this.body = <HTMLBodyElement>this.document.body;
this._screenDprMonitor = new ScreenDprMonitor();
this._screenDprMonitor.setListener(() => this.emit('dprchange', window.devicePixelRatio));
// Create main element container
this.element = this.document.createElement('div');
this.element.classList.add('terminal');
@@ -608,18 +658,24 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.viewportScrollArea.classList.add('xterm-scroll-area');
this.viewportElement.appendChild(this.viewportScrollArea);
this._mouseZoneManager = new MouseZoneManager(this);
this.on('scroll', () => this._mouseZoneManager.clearAll());
this.linkifier.attachToDom(this._mouseZoneManager);
this.screenElement = document.createElement('div');
this.screenElement.classList.add('xterm-screen');
// Create the container that will hold helpers like the textarea for
// capturing DOM Events. Then produce the helpers.
this.helperContainer = document.createElement('div');
this.helperContainer.classList.add('xterm-helpers');
fragment.appendChild(this.helperContainer);
this.screenElement.appendChild(this.helperContainer);
fragment.appendChild(this.screenElement);
this._mouseZoneManager = new MouseZoneManager(this);
this.on('scroll', () => this._mouseZoneManager.clearAll());
this.linkifier.attachToDom(this._mouseZoneManager);
this.textarea = document.createElement('textarea');
this.textarea.classList.add('xterm-helper-textarea');
// TODO: New API to set title? This could say "Terminal bash input", etc.
this.textarea.setAttribute('aria-label', Strings.promptLabel);
this.textarea.setAttribute('aria-multiline', 'false');
this.textarea.setAttribute('autocorrect', 'off');
this.textarea.setAttribute('autocapitalize', 'off');
this.textarea.setAttribute('spellcheck', 'false');
@@ -637,9 +693,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.helperContainer.appendChild(this.charSizeStyleElement);
this.charMeasure = new CharMeasure(document, this.helperContainer);
// Preload audio, this relied on helperContainer
this.syncBellSound();
// Performance: Add viewport and helper elements from the fragment
this.element.appendChild(fragment);
@@ -652,6 +705,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.on('resize', () => this.renderer.onResize(this.cols, this.rows, false));
this.on('blur', () => this.renderer.onBlur());
this.on('focus', () => this.renderer.onFocus());
this.on('dprchange', () => this.renderer.onWindowResize(window.devicePixelRatio));
// dprchange should handle this case, we need this as well for browsers that don't support the
// matchMedia query.
window.addEventListener('resize', () => this.renderer.onWindowResize(window.devicePixelRatio));
this.charMeasure.on('charsizechanged', () => this.renderer.onResize(this.cols, this.rows, true));
this.renderer.on('resize', (dimensions) => this.viewport.syncScrollArea());
@@ -674,6 +731,12 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.mouseHelper = new MouseHelper(this.renderer);
if (this.options.screenReaderMode) {
// Note that this must be done *after* the renderer is created in order to
// ensure the correct order of the dprchange event
this._accessibilityManager = new AccessibilityManager(this);
}
// Measure the character size
this.charMeasure.measure(this.options);
@@ -734,7 +797,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
button = getButton(ev);
// get mouse coordinates
pos = self.mouseHelper.getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows);
pos = self.mouseHelper.getRawByteCoords(ev, self.screenElement, self.charMeasure, self.options.lineHeight, self.cols, self.rows);
if (!pos) return;
sendEvent(button, pos);
@@ -760,7 +823,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
// ^[[M 3<^[[M@4<^[[M@5<^[[M@6<^[[M@7<^[[M#7<
function sendMove(ev: MouseEvent): void {
let button = pressed;
let pos = self.mouseHelper.getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows);
let pos = self.mouseHelper.getRawByteCoords(ev, self.screenElement, self.charMeasure, self.options.lineHeight, self.cols, self.rows);
if (!pos) return;
// buttons marked as motions
@@ -1054,7 +1117,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
*/
public refresh(start: number, end: number): void {
if (this.renderer) {
this.renderer.queueRefresh(start, end);
this.renderer.refreshRows(start, end);
}
}
@@ -1616,8 +1679,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
// page up
if (ev.shiftKey) {
result.scrollLines = -(this.rows - 1);
} else if (modifiers) {
result.key = C0.ESC + '[5;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[5~';
}
@@ -1626,8 +1687,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
// page down
if (ev.shiftKey) {
result.scrollLines = this.rows - 1;
} else if (modifiers) {
result.key = C0.ESC + '[6;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[6~';
}
@@ -1741,14 +1800,15 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
// ^] - Operating System Command (OSC)
result.key = String.fromCharCode(29);
}
} else if ((!this.browser.isMac || this.options.macOptionIsMeta) && ev.altKey && !ev.ctrlKey && !ev.metaKey) {
} else if ((!this.browser.isMac || this.options.macOptionIsMeta) && ev.altKey && !ev.metaKey) {
// On macOS this is a third level shift when !macOptionIsMeta. Use <Esc> instead.
if (ev.keyCode >= 65 && ev.keyCode <= 90) {
result.key = C0.ESC + String.fromCharCode(ev.keyCode + 32);
} else if (ev.keyCode === 192) {
result.key = C0.ESC + '`';
} else if (ev.keyCode >= 48 && ev.keyCode <= 57) {
result.key = C0.ESC + (ev.keyCode - 48);
const keyMapping = KEYCODE_KEY_MAPPINGS[ev.keyCode];
const key = keyMapping && keyMapping[!ev.shiftKey ? 0 : 1];
if (key) {
result.key = C0.ESC + key;
} else if (ev.keyCode >= 65 && ev.keyCode <= 90) {
const keyCode = ev.ctrlKey ? ev.keyCode - 64 : ev.keyCode + 32;
result.key = C0.ESC + String.fromCharCode(keyCode);
}
} else if (this.browser.isMac && !ev.altKey && !ev.ctrlKey && ev.metaKey) {
if (ev.keyCode === 65) { // cmd + a
@@ -1844,7 +1904,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
*/
public bell(): void {
this.emit('bell');
if (this.soundBell()) this.bellAudioElement.play();
if (this.soundBell()) {
this.soundManager.playBellSound();
}
if (this.visualBell()) {
this.element.classList.add('visual-bell-active');
@@ -1901,7 +1963,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.rows = y;
this.buffers.setupTabStops(this.cols);
this.charMeasure.measure(this.options);
if (this.charMeasure) {
this.charMeasure.measure(this.options);
}
this.refresh(0, this.rows - 1);
this.emit('resize', {cols: x, rows: y});
@@ -2163,24 +2227,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
// return this.options.bellStyle === 'sound' ||
// this.options.bellStyle === 'both';
}
private syncBellSound(): void {
// Don't update anything if the terminal has not been opened yet
if (!this.element) {
return;
}
if (this.soundBell() && this.bellAudioElement) {
this.bellAudioElement.setAttribute('src', this.options.bellSound);
} else if (this.soundBell()) {
this.bellAudioElement = document.createElement('audio');
this.bellAudioElement.setAttribute('preload', 'auto');
this.bellAudioElement.setAttribute('src', this.options.bellSound);
this.helperContainer.appendChild(this.bellAudioElement);
} else if (this.bellAudioElement) {
this.helperContainer.removeChild(this.bellAudioElement);
}
}
}
/**
+20 -1
View File
@@ -3,12 +3,14 @@
* @license MIT
*/
import { Terminal as PublicTerminal, ITerminalOptions as IPublicTerminalOptions, IEventEmitter } from 'xterm';
import { Terminal as PublicTerminal, ITerminalOptions as IPublicTerminalOptions, IEventEmitter as IPublicEventEmitter, IEventEmitter } from 'xterm';
import { IColorSet, IRenderer } from './renderer/Types';
import { IMouseZoneManager } from './input/Types';
export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;
export type XtermListener = (...args: any[]) => void;
export type CharData = [number, string, number, number];
export type LineData = CharData[];
@@ -85,6 +87,7 @@ export interface IInputHandlingTerminal extends IEventEmitter {
}
export interface IViewport {
scrollBarWidth: number;
syncScrollArea(): void;
onWheel(ev: WheelEvent): void;
onTouchStart(ev: TouchEvent): void;
@@ -161,6 +164,7 @@ export interface ILinkMatcher {
matchIndex?: number;
validationCallback?: LinkMatcherValidationCallback;
priority?: number;
willLinkActivate?: (event: MouseEvent, uri: string) => boolean;
}
export interface ICharset {
@@ -174,6 +178,7 @@ export interface ILinkHoverEvent {
}
export interface ITerminal extends PublicTerminal, IElementAccessor, IBufferAccessor, ILinkifierAccessor {
screenElement: HTMLElement;
selectionManager: ISelectionManager;
charMeasure: ICharMeasure;
renderer: IRenderer;
@@ -187,6 +192,7 @@ export interface ITerminal extends PublicTerminal, IElementAccessor, IBufferAcce
buffers: IBufferSet;
isFocused: boolean;
mouseHelper: IMouseHelper;
viewport: IViewport;
bracketedPasteMode: boolean;
applicationCursor: boolean;
@@ -285,6 +291,8 @@ export interface ISelectionManager {
disable(): void;
enable(): void;
setSelection(row: number, col: number, length: number): void;
isClickInSelection(event: MouseEvent): boolean;
selectWordAtCursor(event: MouseEvent): void;
}
export interface ILinkifier extends IEventEmitter {
@@ -321,6 +329,13 @@ export interface ILinkMatcherOptions {
* default value is 0.
*/
priority?: number;
/**
* A callback that fires when the mousedown and click events occur that
* determines whether a link will be activated upon click. This enables
* only activating a link when a certain modifier is held down, if not the
* mouse event will continue propagation (eg. double click to select word).
*/
willLinkActivate?: (event: MouseEvent, uri: string) => boolean;
}
export interface IBrowser {
@@ -334,3 +349,7 @@ export interface IBrowser {
isIphone: boolean;
isMSWindows: boolean;
}
export interface ISoundManager {
playBellSound(): void;
}
+15 -7
View File
@@ -7,11 +7,14 @@ import { IColorSet } from './renderer/Types';
import { ITerminal, IViewport } from './Types';
import { CharMeasure } from './utils/CharMeasure';
const FALLBACK_SCROLL_BAR_WIDTH = 15;
/**
* Represents the viewport of a terminal, the visible area within the larger buffer of output.
* Logic for the virtual scroll bar is included in this object.
*/
export class Viewport implements IViewport {
public scrollBarWidth: number = 0;
private currentRowHeight: number = 0;
private lastRecordedBufferLength: number = 0;
private lastRecordedViewportHeight: number = 0;
@@ -31,6 +34,10 @@ export class Viewport implements IViewport {
private scrollArea: HTMLElement,
private charMeasure: CharMeasure
) {
// Measure the width of the scrollbar. If it is 0 we can assume it's an OSX overlay scrollbar.
// Unfortunately the overlay scrollbar would be hidden underneath the screen element in that case,
// therefore we account for a standard amount to make it visible
this.scrollBarWidth = (this.viewportElement.offsetWidth - this.scrollArea.offsetWidth) || FALLBACK_SCROLL_BAR_WIDTH;
this.viewportElement.addEventListener('scroll', this.onScroll.bind(this));
// Perform this async to ensure the CharMeasure is ready.
@@ -48,13 +55,8 @@ export class Viewport implements IViewport {
private refresh(): void {
if (this.charMeasure.height > 0) {
this.currentRowHeight = this.terminal.renderer.dimensions.scaledCellHeight / window.devicePixelRatio;
if (this.lastRecordedViewportHeight !== this.terminal.renderer.dimensions.canvasHeight) {
this.lastRecordedViewportHeight = this.terminal.renderer.dimensions.canvasHeight;
this.viewportElement.style.height = this.lastRecordedViewportHeight + 'px';
}
const newBufferHeight = Math.round(this.currentRowHeight * this.lastRecordedBufferLength);
this.lastRecordedViewportHeight = this.viewportElement.offsetHeight;
const newBufferHeight = Math.round(this.currentRowHeight * this.lastRecordedBufferLength) + (this.lastRecordedViewportHeight - this.terminal.renderer.dimensions.canvasHeight);
if (this.lastRecordedBufferHeight !== newBufferHeight) {
this.lastRecordedBufferHeight = newBufferHeight;
this.scrollArea.style.height = this.lastRecordedBufferHeight + 'px';
@@ -93,6 +95,12 @@ export class Viewport implements IViewport {
* @param ev The scroll event.
*/
private onScroll(ev: Event): void {
// Don't attempt to scroll if the element is not visible, otherwise scrollTop will be corrupt
// which causes the terminal to scroll the buffer to the top
if (!this.viewportElement.offsetParent) {
return;
}
const newRow = Math.round(this.viewportElement.scrollTop / this.currentRowHeight);
const diff = newRow - this.terminal.buffer.ydisp;
this.terminal.scrollLines(diff, true);
+10 -5
View File
@@ -28,17 +28,22 @@ export function proposeGeometry(term: Terminal): IGeometry {
}
const parentElementStyle = window.getComputedStyle(term.element.parentElement);
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')) - 17);
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
const elementStyle = window.getComputedStyle(term.element);
const elementPaddingVer = parseInt(elementStyle.getPropertyValue('padding-top')) + parseInt(elementStyle.getPropertyValue('padding-bottom'));
const elementPaddingHor = parseInt(elementStyle.getPropertyValue('padding-right')) + parseInt(elementStyle.getPropertyValue('padding-left'));
const elementPadding = {
top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
right: parseInt(elementStyle.getPropertyValue('padding-right')),
left: parseInt(elementStyle.getPropertyValue('padding-left'))
};
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
const elementPaddingHor = elementPadding.right + elementPadding.left;
const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor;
const availableWidth = parentElementWidth - elementPaddingHor - (<any>term).viewport.scrollBarWidth;
const geometry = {
cols: Math.floor(availableWidth / (<any>term).renderer.dimensions.actualCellWidth),
rows: Math.floor(availableHeight / (<any>term).renderer.dimensions.actualCellHeight)
};
return geometry;
}
+6 -1
View File
@@ -116,10 +116,15 @@ export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextA
* @param ev The original right click event to be handled.
* @param textarea The terminal's textarea.
* @param selectionManager The terminal's selection manager.
* @param shouldSelectWord If true and there is no selection the current word will be selected
*/
export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager): void {
export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager, shouldSelectWord: boolean): void {
moveTextAreaUnderMouseCursor(ev, textarea);
if (shouldSelectWord && !selectionManager.isClickInSelection(ev)) {
selectionManager.selectWordAtCursor(ev);
}
// Get textarea ready to copy from the context menu
textarea.value = selectionManager.selectionText;
textarea.select();
+9 -8
View File
@@ -151,10 +151,10 @@ export class MouseZoneManager implements IMouseZoneManager {
// components from handling the mouse event.
const zone = this._findZoneEventAt(e);
if (zone) {
// TODO: When link modifier support is added, the event should only be
// cancelled when the modifier is held (see #1021)
e.preventDefault();
e.stopImmediatePropagation();
if (zone.willLinkActivate(e)) {
e.preventDefault();
e.stopImmediatePropagation();
}
}
}
@@ -169,7 +169,7 @@ export class MouseZoneManager implements IMouseZoneManager {
}
private _findZoneEventAt(e: MouseEvent): IMouseZone {
const coords = this._terminal.mouseHelper.getCoords(e, this._terminal.element, this._terminal.charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows);
const coords = this._terminal.mouseHelper.getCoords(e, this._terminal.screenElement, this._terminal.charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows);
if (!coords) {
return null;
}
@@ -189,9 +189,10 @@ export class MouseZone implements IMouseZone {
public x2: number,
public y: number,
public clickCallback: (e: MouseEvent) => any,
public hoverCallback?: (e: MouseEvent) => any,
public tooltipCallback?: (e: MouseEvent) => any,
public leaveCallback?: () => void
public hoverCallback: (e: MouseEvent) => any,
public tooltipCallback: (e: MouseEvent) => any,
public leaveCallback: () => void,
public willLinkActivate: (e: MouseEvent) => boolean
) {
}
}
+4 -3
View File
@@ -13,7 +13,8 @@ export interface IMouseZone {
x2: number;
y: number;
clickCallback: (e: MouseEvent) => any;
hoverCallback?: (e: MouseEvent) => any;
tooltipCallback?: (e: MouseEvent) => any;
leaveCallback?: () => any;
hoverCallback: (e: MouseEvent) => any | undefined;
tooltipCallback: (e: MouseEvent) => any | undefined;
leaveCallback: () => any | undefined;
willLinkActivate: (e: MouseEvent) => boolean;
}
+14 -40
View File
@@ -13,13 +13,12 @@ import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions } from './Types';
import { ITerminal } from '../Types';
import { LinkRenderLayer } from './LinkRenderLayer';
import { EventEmitter } from '../EventEmitter';
import { RenderDebouncer } from '../utils/RenderDebouncer';
import { ScreenDprMonitor } from '../utils/ScreenDprMonitor';
import { ITheme } from 'xterm';
export class Renderer extends EventEmitter implements IRenderer {
/** A queue of the rows to be refreshed */
private _refreshRowsQueue: {start: number, end: number}[] = [];
private _refreshAnimationFrame = null;
private _renderDebouncer: RenderDebouncer;
private _renderLayers: IRenderLayer[];
private _devicePixelRatio: number;
@@ -38,10 +37,10 @@ export class Renderer extends EventEmitter implements IRenderer {
}
this._renderLayers = [
new TextRenderLayer(this._terminal.element, 0, this.colorManager.colors, this._terminal.options.allowTransparency),
new SelectionRenderLayer(this._terminal.element, 1, this.colorManager.colors),
new LinkRenderLayer(this._terminal.element, 2, this.colorManager.colors, this._terminal),
new CursorRenderLayer(this._terminal.element, 3, this.colorManager.colors)
new TextRenderLayer(this._terminal.screenElement, 0, this.colorManager.colors, this._terminal.options.allowTransparency),
new SelectionRenderLayer(this._terminal.screenElement, 1, this.colorManager.colors),
new LinkRenderLayer(this._terminal.screenElement, 2, this.colorManager.colors, this._terminal),
new CursorRenderLayer(this._terminal.screenElement, 3, this.colorManager.colors)
];
this.dimensions = {
scaledCharWidth: null,
@@ -61,6 +60,7 @@ export class Renderer extends EventEmitter implements IRenderer {
this._updateDimensions();
this.onOptionsChanged();
this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this));
this._screenDprMonitor = new ScreenDprMonitor();
this._screenDprMonitor.setListener(() => this.onWindowResize(window.devicePixelRatio));
@@ -120,6 +120,10 @@ export class Renderer extends EventEmitter implements IRenderer {
this._terminal.refresh(0, this._terminal.rows - 1);
}
// Resize the screen
this._terminal.screenElement.style.width = `${this.dimensions.canvasWidth}px`;
this._terminal.screenElement.style.height = `${this.dimensions.canvasHeight}px`;
this.emit('resize', {
width: this.dimensions.canvasWidth,
height: this.dimensions.canvasHeight
@@ -168,47 +172,19 @@ export class Renderer extends EventEmitter implements IRenderer {
* @param {number} start The start row.
* @param {number} end The end row.
*/
public queueRefresh(start: number, end: number): void {
public refreshRows(start: number, end: number): void {
if (this._isPaused) {
this._needsFullRefresh = true;
return;
}
this._refreshRowsQueue.push({ start: start, end: end });
if (!this._refreshAnimationFrame) {
this._refreshAnimationFrame = window.requestAnimationFrame(this._refreshLoop.bind(this));
}
this._renderDebouncer.refresh(start, end);
}
/**
* Performs the refresh loop callback, calling refresh only if a refresh is
* necessary before queueing up the next one.
*/
private _refreshLoop(): void {
let start;
let end;
if (this._refreshRowsQueue.length > 4) {
// Just do a full refresh when 5+ refreshes are queued
start = 0;
end = this._terminal.rows - 1;
} else {
// Get start and end rows that need refreshing
start = this._refreshRowsQueue[0].start;
end = this._refreshRowsQueue[0].end;
for (let i = 1; i < this._refreshRowsQueue.length; i++) {
if (this._refreshRowsQueue[i].start < start) {
start = this._refreshRowsQueue[i].start;
}
if (this._refreshRowsQueue[i].end > end) {
end = this._refreshRowsQueue[i].end;
}
}
}
this._refreshRowsQueue = [];
this._refreshAnimationFrame = null;
// Render
start = Math.max(start, 0);
end = Math.min(end, this._terminal.rows - 1);
private _renderRows(start: number, end: number): void {
this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end));
this._terminal.emit('refresh', {start, end});
}
@@ -271,7 +247,5 @@ export class Renderer extends EventEmitter implements IRenderer {
// differ.
this.dimensions.actualCellHeight = this.dimensions.canvasHeight / this._terminal.rows;
this.dimensions.actualCellWidth = this.dimensions.canvasWidth / this._terminal.cols;
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ export interface IRenderer extends IEventEmitter {
onCursorMove(): void;
onOptionsChanged(): void;
clear(): void;
queueRefresh(start: number, end: number): void;
refreshRows(start: number, end: number): void;
}
export interface IColorManager {
+31
View File
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'xterm';
/**
* Adds a disposabe listener to a node in the DOM, returning the disposable.
* @param type The event type.
* @param handler The handler for the listener.
*/
export function addDisposableListener(
node: Element | Window | Document,
type: string,
handler: (e: any) => void,
useCapture?: boolean
): IDisposable {
node.addEventListener(type, handler, useCapture);
return {
dispose: () => {
if (!handler) {
// Already disposed
return;
}
node.removeEventListener(type, handler, useCapture);
node = null;
handler = null;
}
};
}
+51
View File
@@ -0,0 +1,51 @@
import { ITerminal } from '../Types';
import { IDisposable } from 'xterm';
/**
* Debounces calls to render terminal rows using animation frames.
*/
export class RenderDebouncer implements IDisposable {
private _rowStart: number;
private _rowEnd: number;
private _animationFrame: number = null;
constructor(
private _terminal: ITerminal,
private _callback: (start: number, end: number) => void
) {
}
public dispose(): void {
if (this._animationFrame) {
window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = null;
}
}
public refresh(rowStart?: number, rowEnd?: number): void {
rowStart = rowStart || 0;
rowEnd = rowEnd || this._terminal.rows - 1;
this._rowStart = this._rowStart !== undefined ? Math.min(this._rowStart, rowStart) : rowStart;
this._rowEnd = this._rowEnd !== undefined ? Math.max(this._rowEnd, rowEnd) : rowEnd;
if (this._animationFrame) {
return;
}
this._animationFrame = window.requestAnimationFrame(() => this._innerRefresh());
}
private _innerRefresh(): void {
// Clamp values
this._rowStart = Math.max(this._rowStart, 0);
this._rowEnd = Math.min(this._rowEnd, this._terminal.rows - 1);
// Run render callback
this._callback(this._rowStart, this._rowEnd);
// Reset debouncer
this._rowStart = null;
this._rowEnd = null;
this._animationFrame = null;
}
}
-10
View File
@@ -1,10 +0,0 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
// Source: https://freesound.org/people/altemark/sounds/45759/
// This sound is released under the Creative Commons Attribution 3.0 Unported
// (CC BY 3.0) license. It was created by 'altemark'. No modifications have been
// made, apart from the conversion to base64.
export const BELL_SOUND = 'data:audio/wav;base64,UklGRigBAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQBAADpAFgCwAMlBZoG/wdmCcoKRAypDQ8PbRDBEQQTOxRtFYcWlBePGIUZXhoiG88bcBz7HHIdzh0WHlMeZx51HmkeUx4WHs8dah0AHXwc3hs9G4saxRnyGBIYGBcQFv8U4RPAEoYRQBACD70NWwwHC6gJOwjWBloF7gOBAhABkf8b/qv8R/ve+Xf4Ife79W/0JfPZ8Z/wde9N7ijtE+wU6xvqM+lb6H7nw+YX5mrlxuQz5Mzje+Ma49fioeKD4nXiYeJy4pHitOL04j/jn+MN5IPkFOWs5U3mDefM55/ogOl36m7rdOyE7abuyu8D8Unyj/Pg9D/2qfcb+Yn6/vuK/Qj/lAAlAg==';
+24 -8
View File
@@ -4,12 +4,13 @@
*/
import { IColorSet, IRenderer, IRenderDimensions, IColorManager } from '../renderer/Types';
import { LineData, IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ICircularList, ILinkifier, IMouseHelper, ILinkMatcherOptions } from '../Types';
import { LineData, IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ICircularList, ILinkifier, IMouseHelper, ILinkMatcherOptions, XtermListener } from '../Types';
import { Buffer } from '../Buffer';
import * as Browser from '../shared/utils/Browser';
import { ITheme } from 'xterm';
import { ITheme, IDisposable } from 'xterm';
export class MockTerminal implements ITerminal {
static string: any;
getOption(key: any): any {
throw new Error('Method not implemented.');
}
@@ -80,6 +81,7 @@ export class MockTerminal implements ITerminal {
isFocused: boolean;
options: ITerminalOptions = {};
element: HTMLElement;
screenElement: HTMLElement;
rowContainer: HTMLElement;
selectionContainer: HTMLElement;
selectionManager: ISelectionManager;
@@ -96,6 +98,7 @@ export class MockTerminal implements ITerminal {
scrollback: number;
buffers: IBufferSet;
buffer: IBuffer;
viewport: IViewport;
applicationCursor: boolean;
handler(data: string): void {
throw new Error('Method not implemented.');
@@ -103,12 +106,18 @@ export class MockTerminal implements ITerminal {
on(event: string, callback: () => void): void {
throw new Error('Method not implemented.');
}
off(type: string, listener: (...args: any[]) => void): void {
off(type: string, listener: XtermListener): void {
throw new Error('Method not implemented.');
}
addDisposableListener(type: string, handler: XtermListener): IDisposable {
throw new Error('Method not implemented.');
}
scrollLines(disp: number, suppressScrollEvent: boolean): void {
throw new Error('Method not implemented.');
}
scrollToRow(absoluteRow: number): number {
throw new Error('Method not implemented.');
}
cancel(ev: Event, force?: boolean): void {
throw new Error('Method not implemented.');
}
@@ -248,15 +257,18 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal {
setOption(key: string, value: any): void {
this.options[key] = value;
}
on(type: string, listener: (...args: any[]) => void): void {
on(type: string, listener: XtermListener): void {
throw new Error('Method not implemented.');
}
off(type: string, listener: (...args: any[]) => void): void {
off(type: string, listener: XtermListener): void {
throw new Error('Method not implemented.');
}
emit(type: string, data?: any): void {
throw new Error('Method not implemented.');
}
addDisposableListener(type: string, handler: XtermListener): IDisposable {
throw new Error('Method not implemented.');
}
}
export class MockBuffer implements IBuffer {
@@ -284,15 +296,18 @@ export class MockBuffer implements IBuffer {
export class MockRenderer implements IRenderer {
colorManager: IColorManager;
on(type: string, listener: (...args: any[]) => void): void {
on(type: string, listener: XtermListener): void {
throw new Error('Method not implemented.');
}
off(type: string, listener: (...args: any[]) => void): void {
off(type: string, listener: XtermListener): void {
throw new Error('Method not implemented.');
}
emit(type: string, data?: any): void {
throw new Error('Method not implemented.');
}
addDisposableListener(type: string, handler: XtermListener): IDisposable {
throw new Error('Method not implemented.');
}
dimensions: IRenderDimensions;
setTheme(theme: ITheme): IColorSet { return <IColorSet>{}; }
onResize(cols: number, rows: number, didCharSizeChange: boolean): void {}
@@ -304,10 +319,11 @@ export class MockRenderer implements IRenderer {
onOptionsChanged(): void {}
onWindowResize(devicePixelRatio: number): void {}
clear(): void {}
queueRefresh(start: number, end: number): void {}
refreshRows(start: number, end: number): void {}
}
export class MockViewport implements IViewport {
scrollBarWidth: number = 0;
onThemeChanged(colors: IColorSet): void {
throw new Error('Method not implemented.');
}
+33 -1
View File
@@ -96,9 +96,18 @@
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm canvas {
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
@@ -123,3 +132,26 @@
.xterm:not(.enable-mouse-events) {
cursor: text;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 100;
color: transparent;
}
.xterm .xterm-accessibility-tree:focus [id^="xterm-active-item-"] {
outline: 1px solid #F80;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}