From 399779692d667a6bb35b44cc33c3d5245a33edca Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 19 Dec 2017 12:57:58 -0800 Subject: [PATCH] Initial prototype --- src/AccessibilityManager.ts | 137 ++++++++++++++++++++++++++++++++++++ src/InputHandler.ts | 4 ++ src/Interfaces.ts | 5 ++ src/Terminal.ts | 19 +++++ src/xterm.css | 21 ++++++ 5 files changed, 186 insertions(+) create mode 100644 src/AccessibilityManager.ts diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts new file mode 100644 index 00000000..41931477 --- /dev/null +++ b/src/AccessibilityManager.ts @@ -0,0 +1,137 @@ +import { ITerminal, IBuffer, IDisposable } from './Interfaces'; + +export class AccessibilityManager implements IDisposable { + private _accessibilityTreeRoot: HTMLElement; + private _rowContainer: HTMLElement; + private _rowElements: HTMLElement[] = []; + private _liveRegion: HTMLElement; + + 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('accessibility'); + this._rowContainer = document.createElement('div'); + this._rowContainer.classList.add('accessibility-tree'); + for (let i = 0; i < this._terminal.rows; i++) { + this._rowElements[i] = document.createElement('div'); + this._rowContainer.appendChild(this._rowElements[i]); + } + this._accessibilityTreeRoot.appendChild(this._rowContainer); + + this._liveRegion = document.createElement('div'); + this._liveRegion.classList.add('live-region'); + this._liveRegion.setAttribute('aria-live', 'polite'); + this._accessibilityTreeRoot.appendChild(this._liveRegion); + + this._terminal.element.appendChild(this._accessibilityTreeRoot); + + this._addTerminalEventListener('resize', data => this._onResize(data.cols, data.rows)); + this._addTerminalEventListener('refresh', data => this._refreshRows(data.start, data.end)); + // Line feed is an issue as the prompt won't be read out after a command is run + // this._terminal.on('lineFeed', () => this._onLineFeed()); + this._addTerminalEventListener('a11y.char', (char) => this._onChar(char)); + this._addTerminalEventListener('lineFeed', () => this._onChar('\n')); + this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions()); + this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); + } + + private _addTerminalEventListener(type: string, listener: (...args: any[]) => any): void { + this._terminal.on(type, listener); + this._disposables.push({ + dispose: () => { + this._terminal.off(type, listener); + } + }); + } + + public dispose(): void { + this._terminal.element.removeChild(this._accessibilityTreeRoot); + this._accessibilityTreeRoot = null; + this._rowContainer = null; + this._liveRegion = null; + this._rowContainer = null; + this._rowElements = null; + this._disposables.forEach(d => d.dispose()); + this._disposables = null; + } + + private _onResize(cols: number, rows: number): void { + for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) { + this._rowElements[i] = document.createElement('div'); + this._rowContainer.appendChild(this._rowElements[i]); + } + // TODO: Handle case when rows reduces + + this._refreshRowsDimensions(); + } + + private _onChar(char: string): void { + if (this._charsToConsume.length > 0) { + // Have the screen reader ignore the char if it was just input + if (this._charsToConsume.shift() !== char) { + this._liveRegion.textContent += char; + } + } else { + this._liveRegion.textContent += char; + } + // TODO: Clear at some point + // TOOD: Handle heaps of data + + // This is temporary, should refresh at a much slower rate + this._refreshRows(); + } + + private _onKey(keyChar: string): void { + this._charsToConsume.push(keyChar); + } + + // private _onLineFeed(): void { + // const buffer: IBuffer = (this._terminal.buffer); + // const newLine = buffer.lines.get(buffer.ybase + buffer.y); + // // Only use the data when the new line is ready + // if (!(newLine).isWrapped) { + // this._accessibilityTreeRoot.textContent += `${this._getWrappedLineData(buffer, buffer.ybase + buffer.y - 1)}\n`; + // } + // } + + // private _getWrappedLineData(buffer: IBuffer, lineIndex: number): string { + // let lineData = buffer.translateBufferLineToString(lineIndex, true); + // while (lineIndex >= 0 && (buffer.lines.get(lineIndex--)).isWrapped) { + // lineData = buffer.translateBufferLineToString(lineIndex, true) + lineData; + // } + // return lineData; + // } + + // TODO: Hook up to refresh when the renderer refreshes the range? Slower to prevent layout thrashing? + private _refreshRows(start?: number, end?: number): void { + const buffer: IBuffer = (this._terminal.buffer); + start = start || 0; + end = end || this._terminal.rows - 1; + for (let i = start; i <= end; i++) { + const lineData = buffer.translateBufferLineToString(buffer.ybase + i, true); + this._rowElements[i].textContent = lineData; + } + } + + private _refreshRowsDimensions(): void { + const buffer: IBuffer = (this._terminal.buffer); + const dimensions = this._terminal.renderer.dimensions; + for (let i = 0; i < this._terminal.rows; i++) { + this._rowElements[i].style.height = `${dimensions.actualCellHeight}px`; + } + // TODO: Verify it works on macOS and varying zoom levels + // TODO: Fire when window resizes + } +} diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 512cf878..46479236 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -32,6 +32,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 diff --git a/src/Interfaces.ts b/src/Interfaces.ts index f90cb8c0..423f8d11 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -145,6 +145,7 @@ export interface ITerminalOptions { lineHeight?: number; rows?: number; screenKeys?: boolean; + screenReaderMode?: boolean; scrollback?: number; tabStopWidth?: number; termName?: string; @@ -352,3 +353,7 @@ export interface ITheme { brightCyan?: string; brightWhite?: string; } + +export interface IDisposable { + dispose(): void; +} diff --git a/src/Terminal.ts b/src/Terminal.ts index 9e6312a5..aa9a3771 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -46,6 +46,7 @@ import { IMouseZoneManager } from './input/Interfaces'; import { MouseZoneManager } from './input/MouseZoneManager'; import { initialize as initializeCharAtlas } from './renderer/CharAtlas'; import { IRenderer } from './renderer/Interfaces'; +import { AccessibilityManager } from './AccessibilityManager'; // Declares required for loadAddon declare var exports: any; @@ -85,6 +86,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = { letterSpacing: 0, scrollback: 1000, screenKeys: false, + screenReaderMode: false, debug: false, cancelEvents: false, disableStdin: false, @@ -204,6 +206,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT public charMeasure: CharMeasure; private _mouseZoneManager: IMouseZoneManager; public mouseHelper: MouseHelper; + private _accessibilityManager: AccessibilityManager; public cols: number; public rows: number; @@ -427,6 +430,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; @@ -661,6 +676,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.mouseHelper = new MouseHelper(this.renderer); + if (this.options.screenReaderMode) { + this._accessibilityManager = new AccessibilityManager(this); + } + // Measure the character size this.charMeasure.measure(this.options); diff --git a/src/xterm.css b/src/xterm.css index 35e92f63..085d910a 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -122,3 +122,24 @@ .xterm:not(.enable-mouse-events) { cursor: text; } + +.xterm .accessibility { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 100; +} + +.xterm .accessibility-tree { + color: transparent; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +}