Attention: The demo is a barebones implementation and is designed for the development and evaluation of xterm.js only. Exposing the demo to the public as is would introduce security risks for the host.
diff --git a/demo/main.js b/demo/main.js
index 264a205d..7feb7939 100644
--- a/demo/main.js
+++ b/demo/main.js
@@ -30,7 +30,8 @@ var terminalContainer = document.getElementById('terminal-container'),
macOptionIsMeta: document.querySelector('#option-mac-option-is-meta'),
scrollback: document.querySelector('#option-scrollback'),
tabstopwidth: document.querySelector('#option-tabstopwidth'),
- bellStyle: document.querySelector('#option-bell-style')
+ bellStyle: document.querySelector('#option-bell-style'),
+ screenReaderMode: document.querySelector('#option-screen-reader-mode')
},
colsElement = document.getElementById('cols'),
rowsElement = document.getElementById('rows'),
@@ -86,6 +87,9 @@ optionElements.scrollback.addEventListener('change', function () {
optionElements.tabstopwidth.addEventListener('change', function () {
term.setOption('tabStopWidth', parseInt(optionElements.tabstopwidth.value, 10));
});
+optionElements.screenReaderMode.addEventListener('change', function () {
+ term.setOption('screenReaderMode', optionElements.screenReaderMode.checked);
+});
createTerminal();
@@ -98,7 +102,8 @@ function createTerminal() {
macOptionIsMeta: optionElements.macOptionIsMeta.enabled,
cursorBlink: optionElements.cursorBlink.checked,
scrollback: parseInt(optionElements.scrollback.value, 10),
- tabStopWidth: parseInt(optionElements.tabstopwidth.value, 10)
+ tabStopWidth: parseInt(optionElements.tabstopwidth.value, 10),
+ screenReaderMode: optionElements.screenReaderMode.checked
});
window.term = term; // Expose `term` to window for debugging purposes
term.on('resize', function (size) {
diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts
new file mode 100644
index 00000000..cb915516
--- /dev/null
+++ b/src/AccessibilityManager.ts
@@ -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 = 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 += ' ';
+ } else {
+ this._liveRegion.textContent += char;
+ }
+ }
+}
diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts
index 440eab2b..cece1b9d 100644
--- a/src/EventEmitter.ts
+++ b/src/EventEmitter.ts
@@ -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] || [];
}
diff --git a/src/InputHandler.ts b/src/InputHandler.ts
index 9cdaddaa..18338ae7 100644
--- a/src/InputHandler.ts
+++ b/src/InputHandler.ts
@@ -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);
+ }
}
/**
diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts
index 5aa145e6..93957fa8 100644
--- a/src/SelectionManager.ts
+++ b/src/SelectionManager.ts
@@ -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;
diff --git a/src/SoundManager.ts b/src/SoundManager.ts
new file mode 100644
index 00000000..1c50cbbc
--- /dev/null
+++ b/src/SoundManager.ts
@@ -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 = (window).AudioContext || (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];
+ }
+}
diff --git a/src/Strings.ts b/src/Strings.ts
new file mode 100644
index 00000000..86f0ebe1
--- /dev/null
+++ b/src/Strings.ts
@@ -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';
diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts
index 946e9efb..12467d84 100644
--- a/src/Terminal.test.ts
+++ b/src/Terminal.test.ts
@@ -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', () => {
diff --git a/src/Terminal.ts b/src/Terminal.ts
index 9f8bc433..721996c9 100644
--- a/src/Terminal.ts
+++ b/src/Terminal.ts
@@ -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,
@@ -110,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 = Browser;
@@ -192,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;
@@ -202,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;
@@ -294,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);
@@ -310,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.
*/
@@ -442,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) {
@@ -476,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');
@@ -556,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());
@@ -593,6 +638,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.document = this.parent.ownerDocument;
this.body = 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');
@@ -625,6 +673,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
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');
@@ -642,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);
@@ -657,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());
@@ -679,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);
@@ -1046,7 +1104,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);
}
}
@@ -1729,14 +1787,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 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
@@ -1832,7 +1891,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');
@@ -2153,24 +2214,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);
- }
- }
}
/**
diff --git a/src/Types.ts b/src/Types.ts
index 3ea9fce6..f0185123 100644
--- a/src/Types.ts
+++ b/src/Types.ts
@@ -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[];
@@ -250,6 +252,7 @@ export interface IBuffer {
tabs: any;
scrollBottom: number;
scrollTop: number;
+ hasScrollback: boolean;
savedY: number;
savedX: number;
isCursorInViewport: boolean;
@@ -347,3 +350,7 @@ export interface IBrowser {
isIphone: boolean;
isMSWindows: boolean;
}
+
+export interface ISoundManager {
+ playBellSound(): void;
+}
diff --git a/src/handlers/AltClickHandler.ts b/src/handlers/AltClickHandler.ts
index c9c51cbe..7af32ee6 100644
--- a/src/handlers/AltClickHandler.ts
+++ b/src/handlers/AltClickHandler.ts
@@ -56,9 +56,13 @@ export class AltClickHandler {
* then moves to requested col.
*/
private _arrowSequences(): string {
- return this._resetStartingRow() +
- this._moveToRequestedRow() +
- this._moveToRequestedCol();
+ // The alt buffer should try to navigate between rows
+ if (!this._terminal.buffer.hasScrollback) {
+ return this._resetStartingRow() + this._moveToRequestedRow() + this._moveToRequestedCol();
+ }
+
+ // Only move horizontally for the normal buffer
+ return this._moveHorizontallyOnly();
}
/**
@@ -113,6 +117,11 @@ export class AltClickHandler {
).length, this._sequence(direction));
}
+ private _moveHorizontallyOnly(): string {
+ let direction = this._horizontalDirection();
+ return repeat(Math.abs(this._startCol - this._endCol), this._sequence(direction));
+ }
+
/**
* Utility functions
*/
diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts
index 3792a9da..fa1e34e6 100644
--- a/src/renderer/Renderer.ts
+++ b/src/renderer/Renderer.ts
@@ -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;
@@ -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));
@@ -121,7 +121,7 @@ export class Renderer extends EventEmitter implements IRenderer {
}
// Resize the screen
- this._terminal.screenElement.style.width = `${this.dimensions.canvasWidth + this._terminal.viewport.scrollBarWidth}px`;
+ this._terminal.screenElement.style.width = `${this.dimensions.canvasWidth}px`;
this._terminal.screenElement.style.height = `${this.dimensions.canvasHeight}px`;
this.emit('resize', {
@@ -172,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});
}
@@ -275,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;
-
}
-
}
diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts
index 52c06e04..6f6e5f0e 100644
--- a/src/renderer/Types.ts
+++ b/src/renderer/Types.ts
@@ -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 {
diff --git a/src/utils/Dom.ts b/src/utils/Dom.ts
new file mode 100644
index 00000000..889c88c2
--- /dev/null
+++ b/src/utils/Dom.ts
@@ -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;
+ }
+ };
+}
diff --git a/src/utils/RenderDebouncer.ts b/src/utils/RenderDebouncer.ts
new file mode 100644
index 00000000..ab88fb5f
--- /dev/null
+++ b/src/utils/RenderDebouncer.ts
@@ -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;
+ }
+}
diff --git a/src/utils/Sounds.ts b/src/utils/Sounds.ts
deleted file mode 100644
index be26d4a5..00000000
--- a/src/utils/Sounds.ts
+++ /dev/null
@@ -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==';
diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts
index d48ad12c..8d12120b 100644
--- a/src/utils/TestUtils.test.ts
+++ b/src/utils/TestUtils.test.ts
@@ -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.');
}
@@ -105,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.');
}
@@ -250,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 {
@@ -266,6 +276,7 @@ export class MockBuffer implements IBuffer {
lines: ICircularList<[number, string, number, number][]>;
ydisp: number;
ybase: number;
+ hasScrollback: boolean;
y: number;
x: number;
tabs: any;
@@ -286,15 +297,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 {}; }
onResize(cols: number, rows: number, didCharSizeChange: boolean): void {}
@@ -306,7 +320,7 @@ 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 {
diff --git a/src/xterm.css b/src/xterm.css
index 3edaf3ff..16eb283e 100644
--- a/src/xterm.css
+++ b/src/xterm.css
@@ -132,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;
+}
diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts
index 3ddacded..32c8b0a0 100644
--- a/typings/xterm.d.ts
+++ b/typings/xterm.d.ts
@@ -106,6 +106,13 @@ declare module 'xterm' {
*/
rows?: number;
+ /**
+ * Whether screen reader support is enabled. When on this will expose
+ * supporting elements in the DOM to support NVDA on Windows and VoiceOver
+ * on macOS.
+ */
+ screenReaderMode?: boolean;
+
/**
* The amount of scrollback in the terminal. Scrollback is the amount of rows
* that are retained when lines are scrolled beyond the initial viewport.
@@ -218,6 +225,20 @@ declare module 'xterm' {
on(type: string, listener: (...args: any[]) => void): void;
off(type: string, listener: (...args: any[]) => void): void;
emit(type: string, data?: any): void;
+ addDisposableListener(type: string, handler: (...args: any[]) => void): IDisposable;
+ }
+
+ /**
+ * An object that can be disposed via a dispose function.
+ */
+ export interface IDisposable {
+ dispose(): void;
+ }
+
+ export interface ILocalizableStrings {
+ blankLine: string;
+ promptLabel: string;
+ tooMuchOutput: string;
}
/**
@@ -244,6 +265,11 @@ declare module 'xterm' {
*/
cols: number;
+ /**
+ * Natural language strings that can be localized.
+ */
+ static strings: ILocalizableStrings;
+
/**
* Creates a new `Terminal` object.
*
@@ -325,6 +351,8 @@ declare module 'xterm' {
emit(type: string, data?: any): void;
+ addDisposableListener(type: string, handler: (...args: any[]) => void): IDisposable;
+
/**
* Resizes the terminal.
* @param x The number of columns to resize to.
@@ -519,6 +547,12 @@ declare module 'xterm' {
* @param value The option value.
*/
setOption(key: 'theme', value: ITheme): void;
+ /**
+ * Sets an option on the terminal.
+ * @param key The option key.
+ * @param value The option value.
+ */
+ setOption(key: 'cols' | 'rows', value: number): void;
/**
* Sets an option on the terminal.
* @param key The option key.