diff --git a/demo/index.html b/demo/index.html index a62cff0e3c..760d5990c5 100644 --- a/demo/index.html +++ b/demo/index.html @@ -70,6 +70,11 @@

Size

+
+

Accessibility

+

+ +

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.