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 264a205d38..7feb793966 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 0000000000..cb9155165d
--- /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 440eab2b68..cece1b9d98 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 9cdaddaae9..18338ae7c6 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 dc1d0682a3..eaf813c95e 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/Strings.ts b/src/Strings.ts
new file mode 100644
index 0000000000..86f0ebe131
--- /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.ts b/src/Terminal.ts
index 9f8bc43374..4c41fb5eea 100644
--- a/src/Terminal.ts
+++ b/src/Terminal.ts
@@ -39,12 +39,15 @@ 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_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';
// Let it work inside Node.js for automated testing purposes.
const document = (typeof window !== 'undefined') ? window.document : null;
@@ -80,6 +83,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = {
letterSpacing: 0,
scrollback: 1000,
screenKeys: false,
+ screenReaderMode: false,
debug: false,
macOptionIsMeta: false,
cancelEvents: false,
@@ -202,6 +206,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;
@@ -310,6 +316,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,6 +452,18 @@ 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;
@@ -476,6 +498,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 +581,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 +610,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 +645,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');
@@ -657,6 +680,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 +706,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 +1079,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);
}
}
diff --git a/src/Types.ts b/src/Types.ts
index 3ea9fce696..2906521ddd 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[];
diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts
index 3792a9dab8..2408f8088c 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));
@@ -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 52c06e0450..6f6e5f0ee3 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 0000000000..889c88c2f5
--- /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 0000000000..ab88fb5f0a
--- /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/TestUtils.test.ts b/src/utils/TestUtils.test.ts
index d48ad12caf..8ba1617951 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 {
@@ -286,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 {}; }
onResize(cols: number, rows: number, didCharSizeChange: boolean): void {}
@@ -306,7 +319,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 3edaf3ff5f..16eb283e70 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 3ddacdedf9..dd08b55e7b 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.