From 399779692d667a6bb35b44cc33c3d5245a33edca Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 19 Dec 2017 12:57:58 -0800 Subject: [PATCH 01/47] 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 0000000000..41931477e7 --- /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 512cf8782e..4647923682 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 f90cb8c047..423f8d1140 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 9e6312a594..aa9a377165 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 35e92f636e..085d910af0 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; +} From d03f5b0c2a5f1d8ed5ab8bc7232725184d34e13d Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 19 Dec 2017 15:16:28 -0800 Subject: [PATCH 02/47] Range of improvements for macOS VoiceOver --- src/AccessibilityManager.ts | 34 ++++++++++++++++------------------ src/InputHandler.ts | 3 +++ src/Terminal.ts | 15 +++++---------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 41931477e7..4b23fcf43b 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -40,11 +40,13 @@ export class AccessibilityManager implements IDisposable { 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')); + // Ensure \t is covered, if not a line of output from `ls` is read as one word + this._addTerminalEventListener('a11y.tab', () => this._onChar(' ')); this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions()); this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); + this._addTerminalEventListener('blur', () => this._clearLiveRegion()); } private _addTerminalEventListener(type: string, listener: (...args: any[]) => any): void { @@ -86,6 +88,10 @@ export class AccessibilityManager implements IDisposable { } else { this._liveRegion.textContent += char; } + + if (this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) { + this._accessibilityTreeRoot.appendChild(this._liveRegion); + } // TODO: Clear at some point // TOOD: Handle heaps of data @@ -93,27 +99,19 @@ export class AccessibilityManager implements IDisposable { this._refreshRows(); } + private _clearLiveRegion(): void { + if (this._liveRegion.parentNode) { + this._accessibilityTreeRoot.removeChild(this._liveRegion); + } + this._liveRegion.textContent = ''; + } + private _onKey(keyChar: string): void { + console.log('key event', keyChar); + this._clearLiveRegion(); 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); diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 4647923682..40dde4a49b 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -167,6 +167,9 @@ export class InputHandler implements IInputHandler { */ public tab(): void { this._terminal.buffer.x = this._terminal.buffer.nextStop(); + if (this._terminal.options.screenReaderMode) { + this._terminal.emit('a11y.tab'); + } } /** diff --git a/src/Terminal.ts b/src/Terminal.ts index a1e5586fbf..d45b12af2d 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -470,6 +470,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'); @@ -550,16 +553,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()); From ae957ecb3eea3f9350daa9e920f631e638a8bf46 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 19 Dec 2017 15:48:58 -0800 Subject: [PATCH 03/47] Get output reading working on Windows, refresh on window resize --- src/AccessibilityManager.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 4b23fcf43b..92ce6b5c11 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -1,3 +1,8 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + import { ITerminal, IBuffer, IDisposable } from './Interfaces'; export class AccessibilityManager implements IDisposable { @@ -47,6 +52,9 @@ export class AccessibilityManager implements IDisposable { this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions()); this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); this._addTerminalEventListener('blur', () => this._clearLiveRegion()); + // TODO: Dispose of this listener when disposed + // TODO: Only refresh when devicePixelRatio changed + window.addEventListener('resize', () => this._refreshRowsDimensions()); } private _addTerminalEventListener(type: string, listener: (...args: any[]) => any): void { @@ -89,10 +97,6 @@ export class AccessibilityManager implements IDisposable { this._liveRegion.textContent += char; } - if (this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) { - this._accessibilityTreeRoot.appendChild(this._liveRegion); - } - // TODO: Clear at some point // TOOD: Handle heaps of data // This is temporary, should refresh at a much slower rate @@ -100,14 +104,10 @@ export class AccessibilityManager implements IDisposable { } private _clearLiveRegion(): void { - if (this._liveRegion.parentNode) { - this._accessibilityTreeRoot.removeChild(this._liveRegion); - } this._liveRegion.textContent = ''; } private _onKey(keyChar: string): void { - console.log('key event', keyChar); this._clearLiveRegion(); this._charsToConsume.push(keyChar); } From 0d65cb3f69a1b042b079e9844c1891bc611b4bc9 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 19 Dec 2017 16:27:03 -0800 Subject: [PATCH 04/47] Add max rows to read to handle spam case --- src/AccessibilityManager.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 92ce6b5c11..2a15482d1a 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -5,11 +5,14 @@ import { ITerminal, IBuffer, IDisposable } from './Interfaces'; +const MAX_ROWS_TO_READ = 20; + export class AccessibilityManager implements IDisposable { private _accessibilityTreeRoot: HTMLElement; private _rowContainer: HTMLElement; private _rowElements: HTMLElement[] = []; private _liveRegion: HTMLElement; + private _liveRegionLineCount: number = 0; private _disposables: IDisposable[] = []; @@ -78,26 +81,38 @@ export class AccessibilityManager implements IDisposable { } private _onResize(cols: number, rows: number): void { + // Grow rows as required 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 + // Shrink rows as required + while (this._rowElements.length > rows) { + this._rowContainer.removeChild(this._rowElements.pop()); + } 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) { + 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 + if (this._charsToConsume.shift() !== char) { + this._liveRegion.textContent += char; + } + } else { this._liveRegion.textContent += char; } - } else { - this._liveRegion.textContent += char; - } - // TOOD: Handle heaps of data + if (char === '\n') { + this._liveRegionLineCount++; + if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) { + // TODO: Enable localization + this._liveRegion.textContent += 'Too much output to announce, navigate to rows manually to read'; + } + } + } // This is temporary, should refresh at a much slower rate this._refreshRows(); @@ -105,6 +120,7 @@ export class AccessibilityManager implements IDisposable { private _clearLiveRegion(): void { this._liveRegion.textContent = ''; + this._liveRegionLineCount = 0; } private _onKey(keyChar: string): void { From 5dc57a1305cd7eca10bf01793645441f711c2048 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 20 Dec 2017 11:06:32 -0800 Subject: [PATCH 05/47] Add detach logic back for mac only --- src/AccessibilityManager.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 2a15482d1a..bda634d0eb 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -4,6 +4,7 @@ */ import { ITerminal, IBuffer, IDisposable } from './Interfaces'; +import { isMac } from './utils/Browser'; const MAX_ROWS_TO_READ = 20; @@ -112,6 +113,13 @@ export class AccessibilityManager implements IDisposable { this._liveRegion.textContent += 'Too much output to announce, navigate to rows manually to read'; } } + + // Only detach/attach on mac as otherwise messages can go unaccounced + if (isMac) { + if (this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) { + this._accessibilityTreeRoot.appendChild(this._liveRegion); + } + } } // This is temporary, should refresh at a much slower rate @@ -121,6 +129,13 @@ export class AccessibilityManager implements IDisposable { 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 { From 17964a517cc282d007ac4abe7b95471836dfa49c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 3 Jan 2018 09:28:24 -0800 Subject: [PATCH 06/47] Add screenReaderMode to typings --- typings/xterm.d.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 83199e08b4..5ebe578e44 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -72,6 +72,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. From 5ca15cfe3be47841c9878f4467ab9f1f06fe0d47 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 3 Jan 2018 09:50:03 -0800 Subject: [PATCH 07/47] Fix linefeed event This was causing new lines to join words together --- src/AccessibilityManager.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index bda634d0eb..8de8d459d3 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -50,14 +50,14 @@ export class AccessibilityManager implements IDisposable { 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._addTerminalEventListener('a11y.char', (char) => this._onChar(char)); - this._addTerminalEventListener('lineFeed', () => this._onChar('\n')); + this._addTerminalEventListener('linefeed', () => this._onChar('\n')); // Ensure \t is covered, if not a line of output from `ls` is read as one word this._addTerminalEventListener('a11y.tab', () => this._onChar(' ')); this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions()); this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); this._addTerminalEventListener('blur', () => this._clearLiveRegion()); // TODO: Dispose of this listener when disposed - // TODO: Only refresh when devicePixelRatio changed + // TODO: Only refresh when devicePixelRatio changed (depends on PR #1172) window.addEventListener('resize', () => this._refreshRowsDimensions()); } @@ -97,9 +97,13 @@ export class AccessibilityManager implements IDisposable { private _onChar(char: string): void { if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) { + // \n needs to be printed as a space, otherwise it will be collapsed to + // "" in the DOM and the last and first words of the rows will be read + // as a single word if (this._charsToConsume.length > 0) { // Have the screen reader ignore the char if it was just input - if (this._charsToConsume.shift() !== char) { + const shiftedChar = this._charsToConsume.shift(); + if (shiftedChar !== char) { this._liveRegion.textContent += char; } } else { From 6d1064fc019a61d4d3503e471e6480d48ac0c31c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 3 Jan 2018 10:08:24 -0800 Subject: [PATCH 08/47] Set height of a11y rows on creation --- src/AccessibilityManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 8de8d459d3..20373bf952 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -37,6 +37,7 @@ export class AccessibilityManager implements IDisposable { this._rowElements[i] = document.createElement('div'); this._rowContainer.appendChild(this._rowElements[i]); } + this._refreshRowsDimensions(); this._accessibilityTreeRoot.appendChild(this._rowContainer); this._liveRegion = document.createElement('div'); From bb830eb1ae5b0800482c922890d447ff8ab5b205 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 5 Jan 2018 07:39:35 -0800 Subject: [PATCH 09/47] Rate limit row refresh, tweak live region --- src/AccessibilityManager.ts | 48 +++++++++++++++++++++++++++++++------ src/InputHandler.ts | 3 ++- src/renderer/Renderer.ts | 2 +- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 20373bf952..401175f236 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -15,6 +15,10 @@ export class AccessibilityManager implements IDisposable { private _liveRegion: HTMLElement; private _liveRegionLineCount: number = 0; + private _refreshRowStart: number; + private _refreshRowEnd: number; + private _refreshAnimationFrame: number = null; + private _disposables: IDisposable[] = []; /** @@ -42,7 +46,7 @@ export class AccessibilityManager implements IDisposable { this._liveRegion = document.createElement('div'); this._liveRegion.classList.add('live-region'); - this._liveRegion.setAttribute('aria-live', 'polite'); + this._liveRegion.setAttribute('aria-live', 'assertive'); this._accessibilityTreeRoot.appendChild(this._liveRegion); this._terminal.element.appendChild(this._accessibilityTreeRoot); @@ -52,8 +56,12 @@ export class AccessibilityManager implements IDisposable { // Line feed is an issue as the prompt won't be read out after a command is run this._addTerminalEventListener('a11y.char', (char) => this._onChar(char)); this._addTerminalEventListener('linefeed', () => this._onChar('\n')); - // Ensure \t is covered, if not a line of output from `ls` is read as one word - this._addTerminalEventListener('a11y.tab', () => this._onChar(' ')); + // Ensure \t is covered, if not 2 words separated by only a tab will be read as 1 word + this._addTerminalEventListener('a11y.tab', spaceCount => { + for (let i = 0; i < spaceCount; i++) { + this._onChar(' '); + } + }); this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions()); this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); this._addTerminalEventListener('blur', () => this._clearLiveRegion()); @@ -105,9 +113,18 @@ export class AccessibilityManager implements IDisposable { // Have the screen reader ignore the char if it was just input const shiftedChar = this._charsToConsume.shift(); if (shiftedChar !== char) { - this._liveRegion.textContent += char; + 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; + } } } else { + if (char === ' ') { + this._liveRegion.innerHTML += ' '; + } else this._liveRegion.textContent += char; } @@ -122,7 +139,9 @@ export class AccessibilityManager implements IDisposable { // Only detach/attach on mac as otherwise messages can go unaccounced if (isMac) { if (this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) { - this._accessibilityTreeRoot.appendChild(this._liveRegion); + setTimeout(() => { + this._accessibilityTreeRoot.appendChild(this._liveRegion); + }, 0); } } } @@ -150,13 +169,28 @@ export class AccessibilityManager implements IDisposable { // 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++) { + this._refreshRowStart = this._refreshRowStart ? Math.min(this._refreshRowStart, start) : start; + this._refreshRowEnd = this._refreshRowEnd ? Math.max(this._refreshRowEnd, end) : end; + + if (this._refreshAnimationFrame) { + return; + } + + this._refreshAnimationFrame = window.requestAnimationFrame(() => this._innerRefreshRows()); + } + + private _innerRefreshRows(): void { + console.log('innerRefreshRows'); + const buffer: IBuffer = (this._terminal.buffer); + for (let i = this._refreshRowStart; i <= this._refreshRowEnd; i++) { const lineData = buffer.translateBufferLineToString(buffer.ybase + i, true); this._rowElements[i].textContent = lineData; } + this._refreshRowStart = null; + this._refreshRowEnd = null; + this._refreshAnimationFrame = null; } private _refreshRowsDimensions(): void { diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 2fe3a5f595..5716ace4dd 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -166,9 +166,10 @@ 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.emit('a11y.tab', this._terminal.buffer.x - originalX); } } diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 36727eac5e..4cdf9c525e 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -17,7 +17,7 @@ import { EventEmitter } from '../EventEmitter'; 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 _refreshAnimationFrame: number = null; private _renderLayers: IRenderLayer[]; private _devicePixelRatio: number; From c332aeb51f34d3bb23befeb8f2f5a5843eda08d7 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 5 Jan 2018 07:51:22 -0800 Subject: [PATCH 10/47] Remove obsolete code and resolved TODOs --- src/AccessibilityManager.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 401175f236..6f80b08c1d 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -66,7 +66,7 @@ export class AccessibilityManager implements IDisposable { this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); this._addTerminalEventListener('blur', () => this._clearLiveRegion()); // TODO: Dispose of this listener when disposed - // TODO: Only refresh when devicePixelRatio changed (depends on PR #1172) + // TODO: Listen instead to when devicePixelRatio changed (depends on PR #1172) window.addEventListener('resize', () => this._refreshRowsDimensions()); } @@ -80,6 +80,10 @@ export class AccessibilityManager implements IDisposable { } public dispose(): void { + if (this._refreshAnimationFrame) { + window.cancelAnimationFrame(this._refreshAnimationFrame); + this._refreshAnimationFrame = null; + } this._terminal.element.removeChild(this._accessibilityTreeRoot); this._accessibilityTreeRoot = null; this._rowContainer = null; @@ -145,9 +149,6 @@ export class AccessibilityManager implements IDisposable { } } } - - // This is temporary, should refresh at a much slower rate - this._refreshRows(); } private _clearLiveRegion(): void { @@ -167,7 +168,6 @@ export class AccessibilityManager implements IDisposable { this._charsToConsume.push(keyChar); } - // TODO: Hook up to refresh when the renderer refreshes the range? Slower to prevent layout thrashing? private _refreshRows(start?: number, end?: number): void { start = start || 0; end = end || this._terminal.rows - 1; @@ -182,7 +182,6 @@ export class AccessibilityManager implements IDisposable { } private _innerRefreshRows(): void { - console.log('innerRefreshRows'); const buffer: IBuffer = (this._terminal.buffer); for (let i = this._refreshRowStart; i <= this._refreshRowEnd; i++) { const lineData = buffer.translateBufferLineToString(buffer.ybase + i, true); @@ -199,7 +198,5 @@ export class AccessibilityManager implements IDisposable { 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 } } From a846e8c08d323cebecf542af37865ba57c06041e Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 5 Jan 2018 08:15:21 -0800 Subject: [PATCH 11/47] Pull render animation frame logic into a helper class --- src/AccessibilityManager.ts | 30 ++++++---------------- src/renderer/Renderer.ts | 38 ++++----------------------- src/utils/RenderDebouncer.ts | 50 ++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 src/utils/RenderDebouncer.ts diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 6f80b08c1d..ed1904000b 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -5,6 +5,7 @@ import { ITerminal, IBuffer, IDisposable } from './Interfaces'; import { isMac } from './utils/Browser'; +import { RenderDebouncer } from './utils/RenderDebouncer'; const MAX_ROWS_TO_READ = 20; @@ -15,9 +16,7 @@ export class AccessibilityManager implements IDisposable { private _liveRegion: HTMLElement; private _liveRegionLineCount: number = 0; - private _refreshRowStart: number; - private _refreshRowEnd: number; - private _refreshAnimationFrame: number = null; + private _renderRowsDebouncer: RenderDebouncer; private _disposables: IDisposable[] = []; @@ -44,6 +43,8 @@ export class AccessibilityManager implements IDisposable { this._refreshRowsDimensions(); this._accessibilityTreeRoot.appendChild(this._rowContainer); + this._renderRowsDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._liveRegion = document.createElement('div'); this._liveRegion.classList.add('live-region'); this._liveRegion.setAttribute('aria-live', 'assertive'); @@ -80,10 +81,7 @@ export class AccessibilityManager implements IDisposable { } public dispose(): void { - if (this._refreshAnimationFrame) { - window.cancelAnimationFrame(this._refreshAnimationFrame); - this._refreshAnimationFrame = null; - } + this._renderRowsDebouncer.dispose(); this._terminal.element.removeChild(this._accessibilityTreeRoot); this._accessibilityTreeRoot = null; this._rowContainer = null; @@ -169,27 +167,15 @@ export class AccessibilityManager implements IDisposable { } private _refreshRows(start?: number, end?: number): void { - start = start || 0; - end = end || this._terminal.rows - 1; - this._refreshRowStart = this._refreshRowStart ? Math.min(this._refreshRowStart, start) : start; - this._refreshRowEnd = this._refreshRowEnd ? Math.max(this._refreshRowEnd, end) : end; - - if (this._refreshAnimationFrame) { - return; - } - - this._refreshAnimationFrame = window.requestAnimationFrame(() => this._innerRefreshRows()); + this._renderRowsDebouncer.refresh(start, end); } - private _innerRefreshRows(): void { + private _renderRows(start: number, end: number): void { const buffer: IBuffer = (this._terminal.buffer); - for (let i = this._refreshRowStart; i <= this._refreshRowEnd; i++) { + for (let i = start; i <= end; i++) { const lineData = buffer.translateBufferLineToString(buffer.ybase + i, true); this._rowElements[i].textContent = lineData; } - this._refreshRowStart = null; - this._refreshRowEnd = null; - this._refreshAnimationFrame = null; } private _refreshRowsDimensions(): void { diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 4cdf9c525e..3d2a4d954a 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -13,11 +13,10 @@ import { BaseRenderLayer } from './BaseRenderLayer'; import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions } from './Interfaces'; import { LinkRenderLayer } from './LinkRenderLayer'; import { EventEmitter } from '../EventEmitter'; +import { RenderDebouncer } from '../utils/RenderDebouncer'; export class Renderer extends EventEmitter implements IRenderer { - /** A queue of the rows to be refreshed */ - private _refreshRowsQueue: {start: number, end: number}[] = []; - private _refreshAnimationFrame: number = null; + private _renderDebouncer: RenderDebouncer; private _renderLayers: IRenderLayer[]; private _devicePixelRatio: number; @@ -53,6 +52,7 @@ export class Renderer extends EventEmitter implements IRenderer { }; this._devicePixelRatio = window.devicePixelRatio; this._updateDimensions(); + this._renderDebouncer = new RenderDebouncer(this._terminal, this._refreshLoop.bind(this)); } public onWindowResize(devicePixelRatio: number): void { @@ -129,42 +129,14 @@ export class Renderer extends EventEmitter implements IRenderer { * @param {number} end The end row. */ public queueRefresh(start: number, end: number): void { - 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 _refreshLoop(start: number, end: number): void { this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); this._terminal.emit('refresh', {start, end}); } diff --git a/src/utils/RenderDebouncer.ts b/src/utils/RenderDebouncer.ts new file mode 100644 index 0000000000..c494fb98e0 --- /dev/null +++ b/src/utils/RenderDebouncer.ts @@ -0,0 +1,50 @@ +import { ITerminal, IDisposable } from '../Interfaces'; + +/** + * 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 ? Math.min(this._rowStart, rowStart) : rowStart; + this._rowEnd = this._rowEnd ? 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; + } +} From e20d2caa0e3183a3d7f6982be309a2ed46faad52 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 5 Jan 2018 08:19:30 -0800 Subject: [PATCH 12/47] Use consistent refresh/render method names --- src/Terminal.ts | 2 +- src/renderer/Interfaces.ts | 2 +- src/renderer/Renderer.ts | 6 +++--- src/utils/TestUtils.test.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Terminal.ts b/src/Terminal.ts index 833c18c617..dbffb8a08c 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1041,7 +1041,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/renderer/Interfaces.ts b/src/renderer/Interfaces.ts index be1b39dd1c..74f80a5806 100644 --- a/src/renderer/Interfaces.ts +++ b/src/renderer/Interfaces.ts @@ -19,7 +19,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 IRenderLayer { diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 3d2a4d954a..26a57033bd 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -52,7 +52,7 @@ export class Renderer extends EventEmitter implements IRenderer { }; this._devicePixelRatio = window.devicePixelRatio; this._updateDimensions(); - this._renderDebouncer = new RenderDebouncer(this._terminal, this._refreshLoop.bind(this)); + this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); } public onWindowResize(devicePixelRatio: number): void { @@ -128,7 +128,7 @@ 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 { this._renderDebouncer.refresh(start, end); } @@ -136,7 +136,7 @@ export class Renderer extends EventEmitter implements IRenderer { * Performs the refresh loop callback, calling refresh only if a refresh is * necessary before queueing up the next one. */ - private _refreshLoop(start: number, end: number): void { + private _renderRows(start: number, end: number): void { this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); this._terminal.emit('refresh', {start, end}); } diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 80e2527746..ee28bd1122 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -240,7 +240,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 { From a19b05f8139754069fdd2bf0387b6111f92905ac Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Mon, 8 Jan 2018 09:53:06 -0800 Subject: [PATCH 13/47] Fix bad type check that caused rows to not refresh --- src/utils/RenderDebouncer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/RenderDebouncer.ts b/src/utils/RenderDebouncer.ts index c494fb98e0..5a9c93d6d4 100644 --- a/src/utils/RenderDebouncer.ts +++ b/src/utils/RenderDebouncer.ts @@ -24,8 +24,8 @@ export class RenderDebouncer implements IDisposable { public refresh(rowStart?: number, rowEnd?: number): void { rowStart = rowStart || 0; rowEnd = rowEnd || this._terminal.rows - 1; - this._rowStart = this._rowStart ? Math.min(this._rowStart, rowStart) : rowStart; - this._rowEnd = this._rowEnd ? Math.max(this._rowEnd, rowEnd) : rowEnd; + this._rowStart = this._rowStart !== null ? Math.min(this._rowStart, rowStart) : rowStart; + this._rowEnd = this._rowEnd !== null ? Math.max(this._rowEnd, rowEnd) : rowEnd; if (this._animationFrame) { return; From bbc9a010b9584dedea98ec7cda515662146f30ec Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Thu, 11 Jan 2018 09:27:37 -0800 Subject: [PATCH 14/47] Listen to dpr change in a11y manager --- src/AccessibilityManager.ts | 4 ++++ src/Terminal.ts | 11 +++++++++++ src/renderer/Renderer.ts | 5 ----- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index ed1904000b..9b790abf55 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -66,6 +66,10 @@ export class AccessibilityManager implements IDisposable { this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions()); this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); this._addTerminalEventListener('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._addTerminalEventListener('dprchange', () => this._refreshRowsDimensions()); // TODO: Dispose of this listener when disposed // TODO: Listen instead to when devicePixelRatio changed (depends on PR #1172) window.addEventListener('resize', () => this._refreshRowsDimensions()); diff --git a/src/Terminal.ts b/src/Terminal.ts index 118b3fc143..9e338c6250 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -47,6 +47,7 @@ import { MouseZoneManager } from './input/MouseZoneManager'; import { initialize as initializeCharAtlas } from './renderer/CharAtlas'; import { IRenderer } from './renderer/Interfaces'; import { AccessibilityManager } from './AccessibilityManager'; +import { ScreenDprMonitor } from './utils/ScreenDprMonitor'; // Let it work inside Node.js for automated testing purposes. const document = (typeof window !== 'undefined') ? window.document : null; @@ -201,6 +202,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private _mouseZoneManager: IMouseZoneManager; public mouseHelper: MouseHelper; private _accessibilityManager: AccessibilityManager; + private _screenDprMonitor: ScreenDprMonitor; public cols: number; public rows: number; @@ -586,6 +588,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT initializeCharAtlas(this.document); + 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'); @@ -647,6 +652,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()); @@ -670,6 +679,8 @@ 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); } diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index e828fbb9c1..dda4b56828 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -14,14 +14,12 @@ import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions } from './Interfa import { LinkRenderLayer } from './LinkRenderLayer'; import { EventEmitter } from '../EventEmitter'; import { RenderDebouncer } from '../utils/RenderDebouncer'; -import { ScreenDprMonitor } from '../utils/ScreenDprMonitor'; export class Renderer extends EventEmitter implements IRenderer { private _renderDebouncer: RenderDebouncer; private _renderLayers: IRenderLayer[]; private _devicePixelRatio: number; - private _screenDprMonitor: ScreenDprMonitor; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; @@ -58,9 +56,6 @@ export class Renderer extends EventEmitter implements IRenderer { this._updateDimensions(); this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); - this._screenDprMonitor = new ScreenDprMonitor(); - this._screenDprMonitor.setListener(() => this.onWindowResize(window.devicePixelRatio)); - // Detect whether IntersectionObserver is detected and enable renderer pause // and resume based on terminal visibility if so if ('IntersectionObserver' in window) { From 2311d7d66bb9fab873657190081e3bae34ba04a1 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Thu, 11 Jan 2018 22:41:23 -0800 Subject: [PATCH 15/47] Add basic navigation mode support for current viewport --- src/AccessibilityManager.ts | 91 +++++++++++++++++++++++++++++++++++-- src/Terminal.ts | 7 +++ src/xterm.css | 8 +++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 9b790abf55..47839a7081 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -8,8 +8,13 @@ import { isMac } from './utils/Browser'; import { RenderDebouncer } from './utils/RenderDebouncer'; const MAX_ROWS_TO_READ = 20; +const ACTIVE_ITEM_ID_PREFIX = 'xterm-active-item-'; export class AccessibilityManager implements IDisposable { + private _activeItemId: string; + private _isNavigationModeActive: boolean = false; + private _navigationModeFocusedRow: number; + private _accessibilityTreeRoot: HTMLElement; private _rowContainer: HTMLElement; private _rowElements: HTMLElement[] = []; @@ -32,12 +37,13 @@ export class AccessibilityManager implements IDisposable { private _charsToConsume: string[] = []; constructor(private _terminal: ITerminal) { + this._activeItemId = ACTIVE_ITEM_ID_PREFIX + Math.floor((Math.random() * 100000)); this._accessibilityTreeRoot = document.createElement('div'); - this._accessibilityTreeRoot.classList.add('accessibility'); + this._accessibilityTreeRoot.classList.add('xterm-accessibility'); this._rowContainer = document.createElement('div'); - this._rowContainer.classList.add('accessibility-tree'); + this._rowContainer.classList.add('xterm-accessibility-tree'); for (let i = 0; i < this._terminal.rows; i++) { - this._rowElements[i] = document.createElement('div'); + this._rowElements[i] = this._createAccessibilityTreeNode(); this._rowContainer.appendChild(this._rowElements[i]); } this._refreshRowsDimensions(); @@ -73,6 +79,41 @@ export class AccessibilityManager implements IDisposable { // TODO: Dispose of this listener when disposed // TODO: Listen instead to when devicePixelRatio changed (depends on PR #1172) window.addEventListener('resize', () => this._refreshRowsDimensions()); + + this._rowContainer.addEventListener('keyup', e => { + switch (e.keyCode) { + case 27: // Escape + this.leaveNavigationMode(); + break; + // TODO: Jump up/down to next non-blank row + case 38: /*ArrowUp*/ + this._navigateToElement(this._navigationModeFocusedRow - 1); + break; + case 40: /*ArrowDown*/ + this._navigateToElement(this._navigationModeFocusedRow + 1); + break; + } + this._rowContainer.focus(); + console.log('keydown2', e); + e.preventDefault(); + e.stopPropagation(); + return true; + + // no handler + //return false; + }); + this._rowContainer.addEventListener('keydown', e => { + if (this._isNavigationModeActive) { + e.preventDefault(); + e.stopPropagation(); + return true; + } + return false; + }); + } + + public get isNavigationModeActive(): boolean { + return this._isNavigationModeActive; } private _addTerminalEventListener(type: string, listener: (...args: any[]) => any): void { @@ -99,7 +140,7 @@ export class AccessibilityManager implements IDisposable { private _onResize(cols: number, rows: number): void { // Grow rows as required for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) { - this._rowElements[i] = document.createElement('div'); + this._rowElements[i] = this._createAccessibilityTreeNode(); this._rowContainer.appendChild(this._rowElements[i]); } // Shrink rows as required @@ -110,6 +151,12 @@ export class AccessibilityManager implements IDisposable { this._refreshRowsDimensions(); } + private _createAccessibilityTreeNode(): HTMLElement { + const element = document.createElement('div'); + element.setAttribute('role', 'menuitem'); + return element; + } + private _onChar(char: string): void { if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) { // \n needs to be printed as a space, otherwise it will be collapsed to @@ -179,6 +226,7 @@ export class AccessibilityManager implements IDisposable { for (let i = start; i <= end; i++) { const lineData = buffer.translateBufferLineToString(buffer.ybase + i, true); this._rowElements[i].textContent = lineData; + this._rowElements[i].setAttribute('aria-label', lineData); } } @@ -189,4 +237,39 @@ export class AccessibilityManager implements IDisposable { this._rowElements[i].style.height = `${dimensions.actualCellHeight}px`; } } + + public enterNavigationMode(): void { + this._isNavigationModeActive = true; + this._clearLiveRegion(); + this._liveRegion.textContent += 'Entered line navigation mode'; + this._rowContainer.tabIndex = 0; + this._rowContainer.setAttribute('role', 'menu'); + this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); + this._rowContainer.focus(); + this._navigateToElement(this._terminal.buffer.y); + } + + public leaveNavigationMode(): void { + this._isNavigationModeActive = false; + this._liveRegion.textContent += 'Left line navigation mode'; + this._rowContainer.removeAttribute('tabindex'); + this._rowContainer.removeAttribute('aria-activedescendant'); + this._rowContainer.removeAttribute('role'); + const selected = document.querySelector('#' + this._activeItemId); + if (selected) { + selected.removeAttribute('id'); + } + this._terminal.textarea.focus(); + } + + private _navigateToElement(row: number): void { + // TODO: Store this state + const selected = document.querySelector('#' + this._activeItemId); + if (selected) { + selected.removeAttribute('id'); + } + this._navigationModeFocusedRow = row; + const selectedElement = this._rowElements[row]; + selectedElement.id = this._activeItemId; + } } diff --git a/src/Terminal.ts b/src/Terminal.ts index 9e338c6250..5fbd2b976d 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -454,6 +454,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT * Binds the desired focus behavior on a given terminal object. */ private _onTextAreaFocus(): void { + console.log('textarea.focus'); if (this.sendFocus) { this.send(C0.ESC + '[I'); } @@ -1374,6 +1375,12 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT * @param {KeyboardEvent} ev The keydown event to be handled. */ protected _keyDown(ev: KeyboardEvent): boolean { + console.log('a'); + if (this._accessibilityManager && this._accessibilityManager.isNavigationModeActive) { + console.log('b'); + return; + } + if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) { return false; } diff --git a/src/xterm.css b/src/xterm.css index 68d0fd7d5f..583aa90710 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -124,7 +124,7 @@ cursor: text; } -.xterm .accessibility { +.xterm .xterm-accessibility { position: absolute; left: 0; top: 0; @@ -133,10 +133,14 @@ z-index: 100; } -.xterm .accessibility-tree { +.xterm .xterm-accessibility-tree { color: transparent; } +.xterm .xterm-accessibility-tree:focus [id^="xterm-active-item-"] { + outline: 1px solid #F80; +} + .xterm .live-region { position: absolute; left: -9999px; From 2a187a26e02f7437d289426b407c16c40a783d3d Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Thu, 11 Jan 2018 23:15:02 -0800 Subject: [PATCH 16/47] Update a11y rows on scroll --- src/AccessibilityManager.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 47839a7081..87dbd3c0d8 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -60,6 +60,7 @@ export class AccessibilityManager implements IDisposable { this._addTerminalEventListener('resize', data => this._onResize(data.cols, data.rows)); this._addTerminalEventListener('refresh', data => this._refreshRows(data.start, data.end)); + this._addTerminalEventListener('scroll', data => this._refreshRows()); // Line feed is an issue as the prompt won't be read out after a command is run this._addTerminalEventListener('a11y.char', (char) => this._onChar(char)); this._addTerminalEventListener('linefeed', () => this._onChar('\n')); @@ -224,7 +225,7 @@ export class AccessibilityManager implements IDisposable { private _renderRows(start: number, end: number): void { const buffer: IBuffer = (this._terminal.buffer); for (let i = start; i <= end; i++) { - const lineData = buffer.translateBufferLineToString(buffer.ybase + i, true); + const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true); this._rowElements[i].textContent = lineData; this._rowElements[i].setAttribute('aria-label', lineData); } @@ -263,6 +264,15 @@ export class AccessibilityManager implements IDisposable { } private _navigateToElement(row: number): void { + if (row < 0) { + if (this._terminal.buffer.ydisp > 0) { + this._terminal.scrollLines(-1); + row = 0; + // TODO: This doesn't reliably read the element, maybe a new row needs to be inserted at the top? + // Exit early since the same element is focused + return; + } + } // TODO: Store this state const selected = document.querySelector('#' + this._activeItemId); if (selected) { From 1e41b113195ae7da84fc4a1d2a7f7305def98075 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 12 Jan 2018 21:15:53 -0800 Subject: [PATCH 17/47] Remove logs --- src/AccessibilityManager.ts | 1 - src/Terminal.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 87dbd3c0d8..5a27d34f57 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -95,7 +95,6 @@ export class AccessibilityManager implements IDisposable { break; } this._rowContainer.focus(); - console.log('keydown2', e); e.preventDefault(); e.stopPropagation(); return true; diff --git a/src/Terminal.ts b/src/Terminal.ts index 5fbd2b976d..619387e29f 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -454,7 +454,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT * Binds the desired focus behavior on a given terminal object. */ private _onTextAreaFocus(): void { - console.log('textarea.focus'); if (this.sendFocus) { this.send(C0.ESC + '[I'); } @@ -1375,9 +1374,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT * @param {KeyboardEvent} ev The keydown event to be handled. */ protected _keyDown(ev: KeyboardEvent): boolean { - console.log('a'); if (this._accessibilityManager && this._accessibilityManager.isNavigationModeActive) { - console.log('b'); return; } From 70489e503576c6f51471cd220968d89b4669c225 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sun, 14 Jan 2018 12:54:27 -0800 Subject: [PATCH 18/47] Move navigation mode stuff into its own class --- src/AccessibilityManager.ts | 118 +++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 36 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 5a27d34f57..c854cc2b1f 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -11,10 +11,6 @@ const MAX_ROWS_TO_READ = 20; const ACTIVE_ITEM_ID_PREFIX = 'xterm-active-item-'; export class AccessibilityManager implements IDisposable { - private _activeItemId: string; - private _isNavigationModeActive: boolean = false; - private _navigationModeFocusedRow: number; - private _accessibilityTreeRoot: HTMLElement; private _rowContainer: HTMLElement; private _rowElements: HTMLElement[] = []; @@ -22,6 +18,7 @@ export class AccessibilityManager implements IDisposable { private _liveRegionLineCount: number = 0; private _renderRowsDebouncer: RenderDebouncer; + private _navigationMode: NavigationMode; private _disposables: IDisposable[] = []; @@ -37,7 +34,6 @@ export class AccessibilityManager implements IDisposable { private _charsToConsume: string[] = []; constructor(private _terminal: ITerminal) { - this._activeItemId = ACTIVE_ITEM_ID_PREFIX + Math.floor((Math.random() * 100000)); this._accessibilityTreeRoot = document.createElement('div'); this._accessibilityTreeRoot.classList.add('xterm-accessibility'); this._rowContainer = document.createElement('div'); @@ -50,6 +46,7 @@ export class AccessibilityManager implements IDisposable { this._accessibilityTreeRoot.appendChild(this._rowContainer); this._renderRowsDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._navigationMode = new NavigationMode(this._terminal, this._rowContainer, this._rowElements, this); this._liveRegion = document.createElement('div'); this._liveRegion.classList.add('live-region'); @@ -82,40 +79,19 @@ export class AccessibilityManager implements IDisposable { window.addEventListener('resize', () => this._refreshRowsDimensions()); this._rowContainer.addEventListener('keyup', e => { - switch (e.keyCode) { - case 27: // Escape - this.leaveNavigationMode(); - break; - // TODO: Jump up/down to next non-blank row - case 38: /*ArrowUp*/ - this._navigateToElement(this._navigationModeFocusedRow - 1); - break; - case 40: /*ArrowDown*/ - this._navigateToElement(this._navigationModeFocusedRow + 1); - break; + if (this._navigationMode.isActive) { + return this._navigationMode.onKeyUp(e); } - this._rowContainer.focus(); - e.preventDefault(); - e.stopPropagation(); - return true; - - // no handler - //return false; + return false; }); this._rowContainer.addEventListener('keydown', e => { - if (this._isNavigationModeActive) { - e.preventDefault(); - e.stopPropagation(); - return true; + if (this._navigationMode.isActive) { + return this._navigationMode.onKeyDown(e); } return false; }); } - public get isNavigationModeActive(): boolean { - return this._isNavigationModeActive; - } - private _addTerminalEventListener(type: string, listener: (...args: any[]) => any): void { this._terminal.on(type, listener); this._disposables.push({ @@ -238,10 +214,29 @@ export class AccessibilityManager implements IDisposable { } } - public enterNavigationMode(): void { - this._isNavigationModeActive = true; + public announce(text: string): void { this._clearLiveRegion(); - this._liveRegion.textContent += 'Entered line navigation mode'; + this._liveRegion.textContent = text; + } +} + +class NavigationMode { + private _activeItemId: string; + private _isNavigationModeActive: boolean = false; + private _navigationModeFocusedRow: number; + + constructor( + private _terminal: ITerminal, + private _rowContainer: HTMLElement, + private _rowElements: HTMLElement[], + private _accessibilityManager: AccessibilityManager + ) { + this._activeItemId = ACTIVE_ITEM_ID_PREFIX + Math.floor((Math.random() * 100000)); + } + + public enter(): void { + this._isNavigationModeActive = true; + this._accessibilityManager.announce('Entered line navigation mode'); this._rowContainer.tabIndex = 0; this._rowContainer.setAttribute('role', 'menu'); this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); @@ -249,9 +244,9 @@ export class AccessibilityManager implements IDisposable { this._navigateToElement(this._terminal.buffer.y); } - public leaveNavigationMode(): void { + public leave(): void { this._isNavigationModeActive = false; - this._liveRegion.textContent += 'Left line navigation mode'; + this._accessibilityManager.announce('Left line navigation mode'); this._rowContainer.removeAttribute('tabindex'); this._rowContainer.removeAttribute('aria-activedescendant'); this._rowContainer.removeAttribute('role'); @@ -262,6 +257,57 @@ export class AccessibilityManager implements IDisposable { this._terminal.textarea.focus(); } + public get isActive(): boolean { + return this._isNavigationModeActive; + } + + public onKeyDown(e: KeyboardEvent): boolean { + return this._onKey(e, e => { + console.log('keydown', e); + if (this._isNavigationModeActive) { + return true; + } + return false; + }); + } + + public onKeyUp(e: KeyboardEvent): boolean { + return this._onKey(e, e => { + console.log('keyup', e); + switch (e.keyCode) { + case 27: return this._onEscape(e); + case 38: return this._onArrowUp(e); + case 40: return this._onArrowDown(e); + } + return false; + }); + } + + private _onKey(e: KeyboardEvent, handler: (e: KeyboardEvent) => boolean): boolean { + if (handler && handler(e)) { + return true; + } + return false; + } + + private _onEscape(e: KeyboardEvent): boolean { + this.leave(); + return true; + } + + private _onArrowUp(e: KeyboardEvent): boolean { + // TODO: Jump up/down to next non-blank row + this._navigateToElement(this._navigationModeFocusedRow - 1); + this._rowContainer.focus(); + return true; + } + + private _onArrowDown(e: KeyboardEvent): boolean { + this._navigateToElement(this._navigationModeFocusedRow + 1); + this._rowContainer.focus(); + return true; + } + private _navigateToElement(row: number): void { if (row < 0) { if (this._terminal.buffer.ydisp > 0) { From 85c02f10bffee6334907020779c39ee838611afb Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sun, 14 Jan 2018 13:18:00 -0800 Subject: [PATCH 19/47] Improve dispoable listener functions --- src/AccessibilityManager.ts | 44 +++++++++++++++---------------------- src/EventEmitter.ts | 21 +++++++++++++++++- src/Interfaces.ts | 1 + src/utils/Dom.ts | 26 ++++++++++++++++++++++ 4 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 src/utils/Dom.ts diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index c854cc2b1f..76595c8699 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -6,6 +6,7 @@ import { ITerminal, IBuffer, IDisposable } from './Interfaces'; import { isMac } from './utils/Browser'; import { RenderDebouncer } from './utils/RenderDebouncer'; +import { addDisposableListener } from './utils/Dom'; const MAX_ROWS_TO_READ = 20; const ACTIVE_ITEM_ID_PREFIX = 'xterm-active-item-'; @@ -55,28 +56,22 @@ export class AccessibilityManager implements IDisposable { 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)); - this._addTerminalEventListener('scroll', data => this._refreshRows()); + this._terminal.addDisposableListener('resize', data => this._onResize(data.cols, data.rows)); + this._terminal.addDisposableListener('refresh', data => this._refreshRows(data.start, data.end)); + 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._addTerminalEventListener('a11y.char', (char) => this._onChar(char)); - this._addTerminalEventListener('linefeed', () => this._onChar('\n')); - // Ensure \t is covered, if not 2 words separated by only a tab will be read as 1 word - this._addTerminalEventListener('a11y.tab', spaceCount => { - for (let i = 0; i < spaceCount; i++) { - this._onChar(' '); - } - }); - this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions()); - this._addTerminalEventListener('key', keyChar => this._onKey(keyChar)); - this._addTerminalEventListener('blur', () => this._clearLiveRegion()); + this._terminal.addDisposableListener('a11y.char', (char) => this._onChar(char)); + this._terminal.addDisposableListener('linefeed', () => this._onChar('\n')); + this._terminal.addDisposableListener('a11y.tab', spaceCount => this._onTab(spaceCount)); + this._terminal.addDisposableListener('charsizechanged', () => this._refreshRowsDimensions()); + this._terminal.addDisposableListener('key', keyChar => this._onKey(keyChar)); + 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._addTerminalEventListener('dprchange', () => this._refreshRowsDimensions()); + this._terminal.addDisposableListener('dprchange', () => this._refreshRowsDimensions()); // TODO: Dispose of this listener when disposed - // TODO: Listen instead to when devicePixelRatio changed (depends on PR #1172) - window.addEventListener('resize', () => this._refreshRowsDimensions()); + addDisposableListener(window, 'resize', () => this._refreshRowsDimensions()); this._rowContainer.addEventListener('keyup', e => { if (this._navigationMode.isActive) { @@ -92,15 +87,6 @@ export class AccessibilityManager implements IDisposable { }); } - 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._renderRowsDebouncer.dispose(); this._terminal.element.removeChild(this._accessibilityTreeRoot); @@ -133,6 +119,12 @@ export class AccessibilityManager implements IDisposable { 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) { // \n needs to be printed as a space, otherwise it will be collapsed to diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index 414eac8956..7f090ac1a7 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IEventEmitter, IListenerType } from './Interfaces'; +import { IEventEmitter, IListenerType, IDisposable } from './Interfaces'; export class EventEmitter implements IEventEmitter { private _events: {[type: string]: IListenerType[]}; @@ -19,6 +19,25 @@ export class EventEmitter implements IEventEmitter { this._events[type].push(listener); } + /** + * 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: IListenerType): IDisposable { + this.on(type, handler); + return { + dispose: () => { + if (!handler) { + // Already disposed + return; + } + this.off(type, handler); + handler = null; + } + }; + } + public off(type: string, listener: IListenerType): void { if (!this._events[type]) { return; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 423f8d1140..780a6b4e6a 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -244,6 +244,7 @@ export interface IEventEmitter { on(type: string, listener: IListenerType): void; off(type: string, listener: IListenerType): void; emit(type: string, data?: any): void; + addDisposableListener(type: string, handler: IListenerType): IDisposable; } export interface IListenerType { diff --git a/src/utils/Dom.ts b/src/utils/Dom.ts new file mode 100644 index 0000000000..d32f379069 --- /dev/null +++ b/src/utils/Dom.ts @@ -0,0 +1,26 @@ +import { IDisposable } from "../Interfaces"; + +/** + * 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; + } + }; +} From 23bd60a733ffc05e396b081d97816f8f85db6b73 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sun, 14 Jan 2018 13:28:43 -0800 Subject: [PATCH 20/47] Properly dispose of disposables --- src/AccessibilityManager.ts | 58 +++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 76595c8699..ac1cdd37e2 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -56,35 +56,23 @@ export class AccessibilityManager implements IDisposable { this._terminal.element.appendChild(this._accessibilityTreeRoot); - this._terminal.addDisposableListener('resize', data => this._onResize(data.cols, data.rows)); - this._terminal.addDisposableListener('refresh', data => this._refreshRows(data.start, data.end)); - this._terminal.addDisposableListener('scroll', data => this._refreshRows()); + 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._terminal.addDisposableListener('a11y.char', (char) => this._onChar(char)); - this._terminal.addDisposableListener('linefeed', () => this._onChar('\n')); - this._terminal.addDisposableListener('a11y.tab', spaceCount => this._onTab(spaceCount)); - this._terminal.addDisposableListener('charsizechanged', () => this._refreshRowsDimensions()); - this._terminal.addDisposableListener('key', keyChar => this._onKey(keyChar)); - this._terminal.addDisposableListener('blur', () => this._clearLiveRegion()); + 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('charsizechanged', () => this._refreshRowsDimensions())); + 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._terminal.addDisposableListener('dprchange', () => this._refreshRowsDimensions()); - // TODO: Dispose of this listener when disposed - addDisposableListener(window, 'resize', () => this._refreshRowsDimensions()); - - this._rowContainer.addEventListener('keyup', e => { - if (this._navigationMode.isActive) { - return this._navigationMode.onKeyUp(e); - } - return false; - }); - this._rowContainer.addEventListener('keydown', e => { - if (this._navigationMode.isActive) { - return this._navigationMode.onKeyDown(e); - } - return false; - }); + this._disposables.push(this._terminal.addDisposableListener('dprchange', () => 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 { @@ -217,6 +205,8 @@ class NavigationMode { private _isNavigationModeActive: boolean = false; private _navigationModeFocusedRow: number; + private _disposables: IDisposable[] = []; + constructor( private _terminal: ITerminal, private _rowContainer: HTMLElement, @@ -224,6 +214,24 @@ class NavigationMode { private _accessibilityManager: AccessibilityManager ) { this._activeItemId = ACTIVE_ITEM_ID_PREFIX + Math.floor((Math.random() * 100000)); + + this._disposables.push(addDisposableListener(this._rowContainer, 'keyup', e => { + if (this.isActive) { + return this.onKeyUp(e); + } + return false; + })); + this._disposables.push(addDisposableListener(this._rowContainer, 'keydown', e => { + if (this.isActive) { + return this.onKeyDown(e); + } + return false; + })); + } + + public dispose(): void { + this._disposables.forEach(d => d.dispose); + this._disposables = null; } public enter(): void { From 81b4d8fe9c17a713b76e76a66bce5119ca8913ba Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sun, 14 Jan 2018 13:42:36 -0800 Subject: [PATCH 21/47] Dispose of NavigationMode when AccessibilityManager is --- src/AccessibilityManager.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index ac1cdd37e2..bb6e08d418 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -56,6 +56,8 @@ export class AccessibilityManager implements IDisposable { this._terminal.element.appendChild(this._accessibilityTreeRoot); + this._disposables.push(this._renderRowsDebouncer); + this._disposables.push(this._navigationMode); 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())); @@ -76,15 +78,14 @@ export class AccessibilityManager implements IDisposable { } public dispose(): void { - this._renderRowsDebouncer.dispose(); 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; - this._disposables.forEach(d => d.dispose()); - this._disposables = null; } private _onResize(cols: number, rows: number): void { @@ -115,9 +116,6 @@ export class AccessibilityManager implements IDisposable { private _onChar(char: string): void { if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) { - // \n needs to be printed as a space, otherwise it will be collapsed to - // "" in the DOM and the last and first words of the rows will be read - // as a single word if (this._charsToConsume.length > 0) { // Have the screen reader ignore the char if it was just input const shiftedChar = this._charsToConsume.shift(); @@ -200,7 +198,7 @@ export class AccessibilityManager implements IDisposable { } } -class NavigationMode { +class NavigationMode implements IDisposable { private _activeItemId: string; private _isNavigationModeActive: boolean = false; private _navigationModeFocusedRow: number; @@ -230,7 +228,7 @@ class NavigationMode { } public dispose(): void { - this._disposables.forEach(d => d.dispose); + this._disposables.forEach(d => d.dispose()); this._disposables = null; } @@ -263,7 +261,6 @@ class NavigationMode { public onKeyDown(e: KeyboardEvent): boolean { return this._onKey(e, e => { - console.log('keydown', e); if (this._isNavigationModeActive) { return true; } @@ -273,7 +270,6 @@ class NavigationMode { public onKeyUp(e: KeyboardEvent): boolean { return this._onKey(e, e => { - console.log('keyup', e); switch (e.keyCode) { case 27: return this._onEscape(e); case 38: return this._onArrowUp(e); From e9b55930d89096e3173d67086ff6318e1d820e5c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 16 Jan 2018 10:12:37 -0800 Subject: [PATCH 22/47] Fix test mocks --- src/AccessibilityManager.ts | 4 ++++ src/utils/TestUtils.test.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index bb6e08d418..d2fe7e1a1f 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -88,6 +88,10 @@ export class AccessibilityManager implements IDisposable { this._rowElements = null; } + public get isNavigationModeActive(): boolean { + return this._navigationMode.isActive; + } + private _onResize(cols: number, rows: number): void { // Grow rows as required for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) { diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index ee28bd1122..89b03530ba 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper, ITheme, ILinkifier, IMouseHelper } from '../Interfaces'; +import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper, ITheme, ILinkifier, IMouseHelper, IDisposable } from '../Interfaces'; import { LineData } from '../Types'; import { Buffer } from '../Buffer'; import * as Browser from './Browser'; @@ -42,6 +42,9 @@ export class MockTerminal implements ITerminal { off(type: string, listener: IListenerType): void { throw new Error('Method not implemented.'); } + addDisposableListener(type: string, handler: IListenerType): IDisposable { + throw new Error('Method not implemented.'); + } scrollLines(disp: number, suppressScrollEvent: boolean): void { throw new Error('Method not implemented.'); } @@ -193,6 +196,9 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { emit(type: string, data?: any): void { throw new Error('Method not implemented.'); } + addDisposableListener(type: string, handler: IListenerType): IDisposable { + throw new Error('Method not implemented.'); + } } export class MockBuffer implements IBuffer { @@ -229,6 +235,9 @@ export class MockRenderer implements IRenderer { emit(type: string, data?: any): void { throw new Error('Method not implemented.'); } + addDisposableListener(type: string, handler: IListenerType): IDisposable { + throw new Error('Method not implemented.'); + } dimensions: IRenderDimensions; setTheme(theme: ITheme): IColorSet { return {}; } onResize(cols: number, rows: number, didCharSizeChange: boolean): void {} From 34eb08952c8e78b20ea7109403f244d82c63331c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 16 Jan 2018 10:26:09 -0800 Subject: [PATCH 23/47] Fix RenderDebouncer not refreshing whole viewport when asked --- src/utils/RenderDebouncer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/RenderDebouncer.ts b/src/utils/RenderDebouncer.ts index 5a9c93d6d4..0d07b1a1e8 100644 --- a/src/utils/RenderDebouncer.ts +++ b/src/utils/RenderDebouncer.ts @@ -24,8 +24,8 @@ export class RenderDebouncer implements IDisposable { public refresh(rowStart?: number, rowEnd?: number): void { rowStart = rowStart || 0; rowEnd = rowEnd || this._terminal.rows - 1; - this._rowStart = this._rowStart !== null ? Math.min(this._rowStart, rowStart) : rowStart; - this._rowEnd = this._rowEnd !== null ? Math.max(this._rowEnd, rowEnd) : rowEnd; + 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; From 222c42f7ea7e5155a3b55597a15b006235099ab4 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 16 Jan 2018 10:29:40 -0800 Subject: [PATCH 24/47] Expose screen reader and navigation mode on demo/API --- demo/index.html | 8 ++++++++ demo/main.js | 17 +++++++++++++++-- src/AccessibilityManager.ts | 6 ++++++ src/Terminal.ts | 6 ++++++ typings/xterm.d.ts | 6 ++++++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/demo/index.html b/demo/index.html index 93399f9cdd..e0c355d096 100644 --- a/demo/index.html +++ b/demo/index.html @@ -63,6 +63,14 @@

Size

+
+

Accessibility

+

+ +

+

+ +

Attention: The demo is a barebones implementation and is designed for xterm.js evaluation purposes 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 95b15a0dbc..df11a27fd3 100644 --- a/demo/main.js +++ b/demo/main.js @@ -29,8 +29,10 @@ var terminalContainer = document.getElementById('terminal-container'), cursorStyle: document.querySelector('#option-cursor-style'), 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') }, + navigationModeElement = document.querySelector('#screen-reader-navigation-mode'), colsElement = document.getElementById('cols'), rowsElement = document.getElementById('rows'); @@ -78,6 +80,16 @@ 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.value); +}); +navigationModeElement.addEventListener('click', function () { + if (term.getOption('screenReaderMode')) { + term.enterNavigationMode(); + } else { + console.warn('screenReaderMode must be true to enter navigation mode'); + } +}); createTerminal(); @@ -89,7 +101,8 @@ function createTerminal() { term = new Terminal({ 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 index d2fe7e1a1f..930ae9f0df 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -47,6 +47,8 @@ export class AccessibilityManager implements IDisposable { this._accessibilityTreeRoot.appendChild(this._rowContainer); this._renderRowsDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._refreshRows(); + this._navigationMode = new NavigationMode(this._terminal, this._rowContainer, this._rowElements, this); this._liveRegion = document.createElement('div'); @@ -92,6 +94,10 @@ export class AccessibilityManager implements IDisposable { return this._navigationMode.isActive; } + public enterNavigationMode(): void { + this._navigationMode.enter(); + } + private _onResize(cols: number, rows: number): void { // Grow rows as required for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) { diff --git a/src/Terminal.ts b/src/Terminal.ts index 619387e29f..62dcab8d90 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1334,6 +1334,12 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT } } + public enterNavigationMode(): void { + if (this._accessibilityManager) { + this._accessibilityManager.enterNavigationMode(); + } + } + /** * Gets whether the terminal has an active selection. */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 5ebe578e44..856637344a 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -332,6 +332,12 @@ declare module 'xterm' { */ deregisterLinkMatcher(matcherId: number): void; + /** + * Enters screen reader navigation mode. This will only work when + * the screenReaderMode option is true. + */ + enterNavigationMode(): void; + /** * Gets whether the terminal has an active selection. */ From 3fa4b4941fbbbda6178e65eddc0e944c49839b3e Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 16 Jan 2018 11:01:45 -0800 Subject: [PATCH 25/47] Support nav mode pgup/pgdown/end/home, check boundaries --- src/AccessibilityManager.ts | 63 +++++++++++++++++++++++-------------- src/Interfaces.ts | 1 + src/Terminal.ts | 22 +++++++++++++ src/utils/TestUtils.test.ts | 3 ++ 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 930ae9f0df..20da65e25d 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -211,7 +211,7 @@ export class AccessibilityManager implements IDisposable { class NavigationMode implements IDisposable { private _activeItemId: string; private _isNavigationModeActive: boolean = false; - private _navigationModeFocusedRow: number; + private _absoluteFocusedRow: number; private _disposables: IDisposable[] = []; @@ -243,13 +243,14 @@ class NavigationMode implements IDisposable { } public enter(): void { + // TODO: Should entering navigation mode send ydisp to ybase? this._isNavigationModeActive = true; this._accessibilityManager.announce('Entered line navigation mode'); this._rowContainer.tabIndex = 0; this._rowContainer.setAttribute('role', 'menu'); this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); this._rowContainer.focus(); - this._navigateToElement(this._terminal.buffer.y); + this._navigateToElement(this._terminal.buffer.ydisp + this._terminal.buffer.y); } public leave(): void { @@ -280,10 +281,16 @@ class NavigationMode implements IDisposable { public onKeyUp(e: KeyboardEvent): boolean { return this._onKey(e, e => { - switch (e.keyCode) { - case 27: return this._onEscape(e); - case 38: return this._onArrowUp(e); - case 40: return this._onArrowDown(e); + if (this._isNavigationModeActive) { + switch (e.keyCode) { + case 27: return this._onEscape(e); + case 33: return this._onPageUp(e); + case 34: return this._onPageDown(e); + case 35: return this._onEnd(e); + case 36: return this._onHome(e); + case 38: return this._onArrowUp(e); + case 40: return this._onArrowDown(e); + } } return false; }); @@ -302,35 +309,45 @@ class NavigationMode implements IDisposable { } private _onArrowUp(e: KeyboardEvent): boolean { - // TODO: Jump up/down to next non-blank row - this._navigateToElement(this._navigationModeFocusedRow - 1); - this._rowContainer.focus(); - return true; + return this._focusRow(this._absoluteFocusedRow - 1); } private _onArrowDown(e: KeyboardEvent): boolean { - this._navigateToElement(this._navigationModeFocusedRow + 1); + return this._focusRow(this._absoluteFocusedRow + 1); + } + + private _onPageUp(e: KeyboardEvent): boolean { + return this._focusRow(this._absoluteFocusedRow - this._terminal.rows); + } + + private _onPageDown(e: KeyboardEvent): boolean { + return this._focusRow(this._absoluteFocusedRow + this._terminal.rows); + } + + private _onHome(e: KeyboardEvent): boolean { + return this._focusRow(0); + } + + private _onEnd(e: KeyboardEvent): boolean { + return this._focusRow(this._terminal.buffer.lines.length - 1); + } + + private _focusRow(row: number): boolean { + this._navigateToElement(row); this._rowContainer.focus(); return true; } - private _navigateToElement(row: number): void { - if (row < 0) { - if (this._terminal.buffer.ydisp > 0) { - this._terminal.scrollLines(-1); - row = 0; - // TODO: This doesn't reliably read the element, maybe a new row needs to be inserted at the top? - // Exit early since the same element is focused - return; - } - } + private _navigateToElement(absoluteRow: number): void { + absoluteRow = this._terminal.scrollToRow(absoluteRow); + // TODO: Store this state const selected = document.querySelector('#' + this._activeItemId); if (selected) { selected.removeAttribute('id'); } - this._navigationModeFocusedRow = row; - const selectedElement = this._rowElements[row]; + this._absoluteFocusedRow = absoluteRow; + const selectedElement = this._rowElements[absoluteRow - this._terminal.buffer.ydisp]; selectedElement.id = this._activeItemId; } } diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 780a6b4e6a..4dc572d1e6 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -56,6 +56,7 @@ export interface ITerminal extends ILinkifierAccessor, IBufferAccessor, IElement */ handler(data: string): void; scrollLines(disp: number, suppressScrollEvent?: boolean): void; + scrollToRow(row: number): number; cancel(ev: Event, force?: boolean): boolean | void; log(text: string): void; reset(): void; diff --git a/src/Terminal.ts b/src/Terminal.ts index 62dcab8d90..92ae794ff1 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1169,6 +1169,28 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.refresh(0, this.rows - 1); } + /** + * Scroll the viewport to an absolute row in the buffer. + * @param absoluteRow The absolute row in the buffer to scroll to. + * @returns The actual absolute row that was scrolled to (including boundary checked). + */ + public scrollToRow(absoluteRow: number): number { + // Ensure value is valid + absoluteRow = Math.max(Math.min(absoluteRow, this.buffer.lines.length - 1), 0); + + // Move viewport as necessary + const relativeRow = absoluteRow - this.buffer.ydisp; + let scrollAmount = 0; + if (relativeRow < 0) { + scrollAmount = relativeRow; + } else if (relativeRow >= this.rows) { + scrollAmount = relativeRow - this.rows + 1; + } + this.scrollLines(scrollAmount); + + return absoluteRow; + } + /** * Scroll the display of the terminal by a number of pages. * @param {number} pageCount The number of pages to scroll (negative scrolls up). diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 89b03530ba..69adf3a20a 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -48,6 +48,9 @@ export class MockTerminal implements ITerminal { 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.'); } From f48da12c16c357913c47a93071da920977a07801 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 16 Jan 2018 11:24:19 -0800 Subject: [PATCH 26/47] Prevent event propagation when handled by nav mode --- src/AccessibilityManager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 20da65e25d..a2504d3bc4 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -298,6 +298,8 @@ class NavigationMode implements IDisposable { private _onKey(e: KeyboardEvent, handler: (e: KeyboardEvent) => boolean): boolean { if (handler && handler(e)) { + e.preventDefault(); + e.stopPropagation(); return true; } return false; From 78d734117b6b14fcfe719d4cfff09f3a56001b81 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 17 Jan 2018 09:56:57 -0800 Subject: [PATCH 27/47] Support aria posinset and setsize --- src/AccessibilityManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index a2504d3bc4..fc34f41e6d 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -190,7 +190,8 @@ export class AccessibilityManager implements IDisposable { for (let i = start; i <= end; i++) { const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true); this._rowElements[i].textContent = lineData; - this._rowElements[i].setAttribute('aria-label', lineData); + this._rowElements[i].setAttribute('aria-posinset', (buffer.ydisp + i + 1).toString()); + this._rowElements[i].setAttribute('aria-setsize', (buffer.lines.length).toString()); } } From abfa58fca946b315664862b69f395f59f0c0957d Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 17 Jan 2018 10:52:12 -0800 Subject: [PATCH 28/47] Keep track of nav mode focused element --- src/AccessibilityManager.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index fc34f41e6d..e36efbec93 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -213,6 +213,7 @@ class NavigationMode implements IDisposable { private _activeItemId: string; private _isNavigationModeActive: boolean = false; private _absoluteFocusedRow: number; + private _focusedElement: HTMLElement; private _disposables: IDisposable[] = []; @@ -260,9 +261,8 @@ class NavigationMode implements IDisposable { this._rowContainer.removeAttribute('tabindex'); this._rowContainer.removeAttribute('aria-activedescendant'); this._rowContainer.removeAttribute('role'); - const selected = document.querySelector('#' + this._activeItemId); - if (selected) { - selected.removeAttribute('id'); + if (this._focusedElement) { + this._focusedElement.removeAttribute('id'); } this._terminal.textarea.focus(); } @@ -344,13 +344,11 @@ class NavigationMode implements IDisposable { private _navigateToElement(absoluteRow: number): void { absoluteRow = this._terminal.scrollToRow(absoluteRow); - // TODO: Store this state - const selected = document.querySelector('#' + this._activeItemId); - if (selected) { - selected.removeAttribute('id'); + if (this._focusedElement) { + this._focusedElement.removeAttribute('id'); } this._absoluteFocusedRow = absoluteRow; - const selectedElement = this._rowElements[absoluteRow - this._terminal.buffer.ydisp]; - selectedElement.id = this._activeItemId; + this._focusedElement = this._rowElements[absoluteRow - this._terminal.buffer.ydisp]; + this._focusedElement.id = this._activeItemId; } } From 0f04f9fbb4951c1a81d210f61d0fd0323e32c1b7 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 17 Jan 2018 14:10:03 -0800 Subject: [PATCH 29/47] Rotate rows to ensure an item will be read in nav mode when scrolling --- src/AccessibilityManager.ts | 19 +++++++++++++++++-- src/Interfaces.ts | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index e36efbec93..a958d10bc5 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -187,14 +187,24 @@ export class AccessibilityManager implements IDisposable { 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); this._rowElements[i].textContent = lineData; - this._rowElements[i].setAttribute('aria-posinset', (buffer.ydisp + i + 1).toString()); - this._rowElements[i].setAttribute('aria-setsize', (buffer.lines.length).toString()); + const posInSet = (buffer.ydisp + i + 1).toString(); + this._rowElements[i].setAttribute('aria-posinset', posInSet); + this._rowElements[i].setAttribute('aria-setsize', setSize); } } + public rotateRows(): void { + this._rowContainer.removeChild(this._rowElements.shift()); + const newRowIndex = this._rowElements.length; + this._rowElements[newRowIndex] = this._createAccessibilityTreeNode(); + this._rowContainer.appendChild(this._rowElements[newRowIndex]); + this._refreshRowsDimensions(); + } + private _refreshRowsDimensions(): void { const buffer: IBuffer = (this._terminal.buffer); const dimensions = this._terminal.renderer.dimensions; @@ -342,6 +352,11 @@ class NavigationMode implements IDisposable { } private _navigateToElement(absoluteRow: number): void { + if (absoluteRow < this._terminal.buffer.ydisp || absoluteRow >= this._terminal.buffer.ydisp + this._terminal.rows) { + // Rotate rows to ensure the next focused item is read out correctly + this._accessibilityManager.rotateRows(); + } + absoluteRow = this._terminal.scrollToRow(absoluteRow); if (this._focusedElement) { diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 4dc572d1e6..a1623a67a4 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -251,7 +251,7 @@ export interface IEventEmitter { export interface IListenerType { (data?: any): void; listener?: (data?: any) => void; -}; +} export interface ILinkMatcherOptions { /** From 39fcbe61c0b368e3b53739ab98d3abddcc38fbb6 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 19 Jan 2018 10:52:03 -0800 Subject: [PATCH 30/47] Improve announcement on focus --- src/Terminal.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Terminal.ts b/src/Terminal.ts index 92ae794ff1..47fcbe7727 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -620,6 +620,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', 'Terminal input'); + this.textarea.setAttribute('aria-multiline', 'false'); this.textarea.setAttribute('autocorrect', 'off'); this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('spellcheck', 'false'); From c2d78e57d064e5d9a68973394b3b967f63ee6280 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 23 Jan 2018 09:49:43 -0800 Subject: [PATCH 31/47] Fix screenReadeMode disable in demo --- demo/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/main.js b/demo/main.js index 651dbd0f24..179c6c6481 100644 --- a/demo/main.js +++ b/demo/main.js @@ -85,7 +85,7 @@ optionElements.tabstopwidth.addEventListener('change', function () { term.setOption('tabStopWidth', parseInt(optionElements.tabstopwidth.value, 10)); }); optionElements.screenReaderMode.addEventListener('change', function () { - term.setOption('screenReaderMode', optionElements.screenReaderMode.value); + term.setOption('screenReaderMode', optionElements.screenReaderMode.checked); }); navigationModeElement.addEventListener('click', function () { if (term.getOption('screenReaderMode')) { From bce0cce74d3c2789ec79aa9023082b949ada2530 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 23 Jan 2018 09:50:47 -0800 Subject: [PATCH 32/47] Ensure a11y tree is located above the prompt --- src/AccessibilityManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index a958d10bc5..819541ae7f 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -56,7 +56,7 @@ export class AccessibilityManager implements IDisposable { this._liveRegion.setAttribute('aria-live', 'assertive'); this._accessibilityTreeRoot.appendChild(this._liveRegion); - this._terminal.element.appendChild(this._accessibilityTreeRoot); + this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot); this._disposables.push(this._renderRowsDebouncer); this._disposables.push(this._navigationMode); From fba4f5f261ae480e0d9f90f852e47a3c1971dfc9 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 23 Jan 2018 10:39:21 -0800 Subject: [PATCH 33/47] Ensure active item is removed before scroll --- src/AccessibilityManager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 819541ae7f..68681f4f1d 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -357,11 +357,13 @@ class NavigationMode implements IDisposable { this._accessibilityManager.rotateRows(); } - absoluteRow = this._terminal.scrollToRow(absoluteRow); - + // Make sure there is no active element when scroll happens if (this._focusedElement) { this._focusedElement.removeAttribute('id'); } + + absoluteRow = this._terminal.scrollToRow(absoluteRow); + this._absoluteFocusedRow = absoluteRow; this._focusedElement = this._rowElements[absoluteRow - this._terminal.buffer.ydisp]; this._focusedElement.id = this._activeItemId; From 45c6008b275d84a0e207768bbbc20120eb54f81f Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 23 Jan 2018 11:11:29 -0800 Subject: [PATCH 34/47] Rotate terminal rows after removing focus --- src/AccessibilityManager.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 68681f4f1d..988279abb1 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -261,8 +261,8 @@ class NavigationMode implements IDisposable { this._rowContainer.tabIndex = 0; this._rowContainer.setAttribute('role', 'menu'); this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); - this._rowContainer.focus(); this._navigateToElement(this._terminal.buffer.ydisp + this._terminal.buffer.y); + this._rowContainer.focus(); } public leave(): void { @@ -352,19 +352,23 @@ class NavigationMode implements IDisposable { } private _navigateToElement(absoluteRow: number): void { - if (absoluteRow < this._terminal.buffer.ydisp || absoluteRow >= this._terminal.buffer.ydisp + this._terminal.rows) { - // Rotate rows to ensure the next focused item is read out correctly - this._accessibilityManager.rotateRows(); - } - - // Make sure there is no active element when scroll happens + // Make sure there is no active element when scroll and rotate happens if (this._focusedElement) { + this._rowContainer.removeAttribute('aria-activedescendant'); this._focusedElement.removeAttribute('id'); } - absoluteRow = this._terminal.scrollToRow(absoluteRow); + // Rotate rows to ensure the next focused item is read out correctly + if (absoluteRow < this._terminal.buffer.ydisp || absoluteRow >= this._terminal.buffer.ydisp + this._terminal.rows) { + this._accessibilityManager.rotateRows(); + } + // Scroll to row if it's outside of the viewport + absoluteRow = this._terminal.scrollToRow(absoluteRow); this._absoluteFocusedRow = absoluteRow; + + // Focus the new active element + this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); this._focusedElement = this._rowElements[absoluteRow - this._terminal.buffer.ydisp]; this._focusedElement.id = this._activeItemId; } From c41b35ae9caf4824f8e4a8cb54722b115ba6d18c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 24 Jan 2018 11:04:41 -0800 Subject: [PATCH 35/47] Add message about entering navigation mode --- src/AccessibilityManager.ts | 7 +++++++ src/xterm.css | 3 --- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 988279abb1..281618f45b 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -16,6 +16,7 @@ export class AccessibilityManager implements IDisposable { private _rowContainer: HTMLElement; private _rowElements: HTMLElement[] = []; private _liveRegion: HTMLElement; + private _moreRowsElement: HTMLElement; private _liveRegionLineCount: number = 0; private _renderRowsDebouncer: RenderDebouncer; @@ -37,6 +38,12 @@ export class AccessibilityManager implements IDisposable { constructor(private _terminal: ITerminal) { this._accessibilityTreeRoot = document.createElement('div'); this._accessibilityTreeRoot.classList.add('xterm-accessibility'); + + this._moreRowsElement = document.createElement('div'); + this._moreRowsElement.style.clip = 'clip(0 0 0 0)'; + this._moreRowsElement.textContent = 'In order to properly navigation the terminal buffer you need to enter navigation mode'; + this._accessibilityTreeRoot.appendChild(this._moreRowsElement); + this._rowContainer = document.createElement('div'); this._rowContainer.classList.add('xterm-accessibility-tree'); for (let i = 0; i < this._terminal.rows; i++) { diff --git a/src/xterm.css b/src/xterm.css index 583aa90710..fffc6b8520 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -131,9 +131,6 @@ bottom: 0; right: 0; z-index: 100; -} - -.xterm .xterm-accessibility-tree { color: transparent; } From 3e734fab79b268537b26e25cde5e5ad4ef194c7f Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 24 Jan 2018 11:15:47 -0800 Subject: [PATCH 36/47] Support i18n --- src/AccessibilityManager.ts | 5 +++-- src/Strings.ts | 8 ++++++++ src/Terminal.ts | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/Strings.ts diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 281618f45b..e1bc841a7e 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -3,6 +3,7 @@ * @license MIT */ +import * as Strings from './Strings'; import { ITerminal, IBuffer, IDisposable } from './Interfaces'; import { isMac } from './utils/Browser'; import { RenderDebouncer } from './utils/RenderDebouncer'; @@ -41,7 +42,7 @@ export class AccessibilityManager implements IDisposable { this._moreRowsElement = document.createElement('div'); this._moreRowsElement.style.clip = 'clip(0 0 0 0)'; - this._moreRowsElement.textContent = 'In order to properly navigation the terminal buffer you need to enter navigation mode'; + this._moreRowsElement.textContent = Strings.navigationModeMoreRows; this._accessibilityTreeRoot.appendChild(this._moreRowsElement); this._rowContainer = document.createElement('div'); @@ -156,7 +157,7 @@ export class AccessibilityManager implements IDisposable { this._liveRegionLineCount++; if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) { // TODO: Enable localization - this._liveRegion.textContent += 'Too much output to announce, navigate to rows manually to read'; + this._liveRegion.textContent += Strings.tooMuchOutput; } } diff --git a/src/Strings.ts b/src/Strings.ts new file mode 100644 index 0000000000..17d8a45848 --- /dev/null +++ b/src/Strings.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export let promptLabel = 'Terminal input'; +export let navigationModeMoreRows = 'In order to properly navigation the terminal buffer you need to enter navigation mode'; +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 584565ebcf..d293c256a4 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -36,6 +36,7 @@ import { Linkifier } from './Linkifier'; import { SelectionManager } from './SelectionManager'; import { CharMeasure } from './utils/CharMeasure'; import * as Browser from './utils/Browser'; +import * as Strings from './Strings'; import { MouseHelper } from './utils/MouseHelper'; import { CHARSETS } from './Charsets'; import { CustomKeyEventHandler, LinkMatcherHandler, LinkMatcherValidationCallback, CharData, LineData } from './Types'; @@ -623,7 +624,7 @@ 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', 'Terminal input'); + this.textarea.setAttribute('aria-label', Strings.promptLabel); this.textarea.setAttribute('aria-multiline', 'false'); this.textarea.setAttribute('autocorrect', 'off'); this.textarea.setAttribute('autocapitalize', 'off'); From 1cceda605299c3dd82593f16a39a8d4b8c9a2932 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 24 Jan 2018 16:34:39 -0800 Subject: [PATCH 37/47] Change navigation mode to work using focus instead of activedescendant --- src/AccessibilityManager.ts | 275 ++++++++++++++---------------------- src/xterm.css | 3 +- 2 files changed, 108 insertions(+), 170 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index e1bc841a7e..fa3a094d36 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -12,6 +12,11 @@ import { addDisposableListener } from './utils/Dom'; 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; @@ -21,7 +26,10 @@ export class AccessibilityManager implements IDisposable { private _liveRegionLineCount: number = 0; private _renderRowsDebouncer: RenderDebouncer; - private _navigationMode: NavigationMode; + // private _navigationMode: NavigationMode; + + private _topBoundaryFocusListener: (e: FocusEvent) => void; + private _bottomBoundaryFocusListener: (e: FocusEvent) => void; private _disposables: IDisposable[] = []; @@ -41,6 +49,7 @@ export class AccessibilityManager implements IDisposable { this._accessibilityTreeRoot.classList.add('xterm-accessibility'); this._moreRowsElement = document.createElement('div'); + this._moreRowsElement.classList.add('xterm-message'); this._moreRowsElement.style.clip = 'clip(0 0 0 0)'; this._moreRowsElement.textContent = Strings.navigationModeMoreRows; this._accessibilityTreeRoot.appendChild(this._moreRowsElement); @@ -51,13 +60,19 @@ export class AccessibilityManager implements IDisposable { 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._navigationMode = new NavigationMode(this._terminal, this._rowContainer, this._rowElements, this); + // this._navigationMode = new NavigationMode(this._terminal, this._rowContainer, this._rowElements, this); this._liveRegion = document.createElement('div'); this._liveRegion.classList.add('live-region'); @@ -67,7 +82,7 @@ export class AccessibilityManager implements IDisposable { this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot); this._disposables.push(this._renderRowsDebouncer); - this._disposables.push(this._navigationMode); + // this._disposables.push(this._navigationMode); 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())); @@ -98,12 +113,86 @@ export class AccessibilityManager implements IDisposable { 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 been reached + const posInSet = this._rowElements[0].getAttribute('aria-posinset'); + if (posInSet === '1') { + return; + } + console.log('posInSet', posInSet); + + // Don't scroll when the last focused item was not the second row (focus is going the other + // direction) + console.log('related', e.relatedTarget); + if (e.relatedTarget !== beforeBoundaryElement) { + console.log('cancel'); + return; + } + + boundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); + let oldLastElement: HTMLElement; + // TODO: oldLastElement.removeEventListener(...) + + if (position === BoundaryPosition.Top) { + oldLastElement = this._rowElements.pop(); + this._rowElements.unshift(this._createAccessibilityTreeNode()); + this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); + this._rowContainer.insertAdjacentElement('afterbegin', this._rowElements[0]); + } else { + oldLastElement = this._rowElements.shift(); + this._rowElements.push(this._createAccessibilityTreeNode()); + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._topBoundaryFocusListener); + this._rowContainer.appendChild(this._rowElements[this._rowElements.length - 1]); + } + this._rowContainer.removeChild(oldLastElement); + + + + + // TODO: Add bottom boundary listeners and remove in both cases + + + + + // Scroll up + this._terminal.scrollLines(position === BoundaryPosition.Top ? -1 : 1); + + // TODO: Only refresh single + this._refreshRowsDimensions(); + + // Focus the new active element + // this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); + // this._focusedElement = this._rowElements[1]; + // this._focusedElement.id = this._activeItemId; + + // Focus new boundary before element + this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2].focus(); + + // Prevent the standard behavior + e.preventDefault(); + e.stopImmediatePropagation(); + } + public get isNavigationModeActive(): boolean { - return this._navigationMode.isActive; + // TODO: Remove this function + return true; + // return this._navigationMode.isActive; } public enterNavigationMode(): void { - this._navigationMode.enter(); + // this._navigationMode.enter(); + + // this._isNavigationModeActive = true; + this.announce('Entered line navigation mode'); + // this._rowContainer.tabIndex = 0; + // this._rowContainer.setAttribute('role', 'list'); + // this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); + // this._navigateToElement(this._terminal.buffer.ydisp + this._terminal.buffer.y); + // this._rowContainer.focus(); + this._rowElements[this._rowElements.length - 1].focus(); } private _onResize(cols: number, rows: number): void { @@ -117,12 +206,15 @@ export class AccessibilityManager implements IDisposable { this._rowContainer.removeChild(this._rowElements.pop()); } + // TODO: Fix up boundary listeners + this._refreshRowsDimensions(); } - private _createAccessibilityTreeNode(): HTMLElement { + public _createAccessibilityTreeNode(): HTMLElement { const element = document.createElement('div'); - element.setAttribute('role', 'menuitem'); + element.setAttribute('role', 'listitem'); + element.tabIndex = -1; return element; } @@ -156,7 +248,6 @@ export class AccessibilityManager implements IDisposable { if (char === '\n') { this._liveRegionLineCount++; if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) { - // TODO: Enable localization this._liveRegion.textContent += Strings.tooMuchOutput; } } @@ -198,19 +289,20 @@ export class AccessibilityManager implements IDisposable { const setSize = (buffer.lines.length).toString(); for (let i = start; i <= end; i++) { const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true); - this._rowElements[i].textContent = lineData; + this._rowElements[i].textContent = lineData.length === 0 ? 'Blank line' : lineData; const posInSet = (buffer.ydisp + i + 1).toString(); this._rowElements[i].setAttribute('aria-posinset', posInSet); this._rowElements[i].setAttribute('aria-setsize', setSize); } + // TODO: Clean up } public rotateRows(): void { - this._rowContainer.removeChild(this._rowElements.shift()); - const newRowIndex = this._rowElements.length; - this._rowElements[newRowIndex] = this._createAccessibilityTreeNode(); - this._rowContainer.appendChild(this._rowElements[newRowIndex]); - this._refreshRowsDimensions(); + // this._rowContainer.removeChild(this._rowElements.shift()); + // const newRowIndex = this._rowElements.length; + // this._rowElements[newRowIndex] = this._createAccessibilityTreeNode(); + // this._rowContainer.appendChild(this._rowElements[newRowIndex]); + // this._refreshRowsDimensions(); } private _refreshRowsDimensions(): void { @@ -226,158 +318,3 @@ export class AccessibilityManager implements IDisposable { this._liveRegion.textContent = text; } } - -class NavigationMode implements IDisposable { - private _activeItemId: string; - private _isNavigationModeActive: boolean = false; - private _absoluteFocusedRow: number; - private _focusedElement: HTMLElement; - - private _disposables: IDisposable[] = []; - - constructor( - private _terminal: ITerminal, - private _rowContainer: HTMLElement, - private _rowElements: HTMLElement[], - private _accessibilityManager: AccessibilityManager - ) { - this._activeItemId = ACTIVE_ITEM_ID_PREFIX + Math.floor((Math.random() * 100000)); - - this._disposables.push(addDisposableListener(this._rowContainer, 'keyup', e => { - if (this.isActive) { - return this.onKeyUp(e); - } - return false; - })); - this._disposables.push(addDisposableListener(this._rowContainer, 'keydown', e => { - if (this.isActive) { - return this.onKeyDown(e); - } - return false; - })); - } - - public dispose(): void { - this._disposables.forEach(d => d.dispose()); - this._disposables = null; - } - - public enter(): void { - // TODO: Should entering navigation mode send ydisp to ybase? - this._isNavigationModeActive = true; - this._accessibilityManager.announce('Entered line navigation mode'); - this._rowContainer.tabIndex = 0; - this._rowContainer.setAttribute('role', 'menu'); - this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); - this._navigateToElement(this._terminal.buffer.ydisp + this._terminal.buffer.y); - this._rowContainer.focus(); - } - - public leave(): void { - this._isNavigationModeActive = false; - this._accessibilityManager.announce('Left line navigation mode'); - this._rowContainer.removeAttribute('tabindex'); - this._rowContainer.removeAttribute('aria-activedescendant'); - this._rowContainer.removeAttribute('role'); - if (this._focusedElement) { - this._focusedElement.removeAttribute('id'); - } - this._terminal.textarea.focus(); - } - - public get isActive(): boolean { - return this._isNavigationModeActive; - } - - public onKeyDown(e: KeyboardEvent): boolean { - return this._onKey(e, e => { - if (this._isNavigationModeActive) { - return true; - } - return false; - }); - } - - public onKeyUp(e: KeyboardEvent): boolean { - return this._onKey(e, e => { - if (this._isNavigationModeActive) { - switch (e.keyCode) { - case 27: return this._onEscape(e); - case 33: return this._onPageUp(e); - case 34: return this._onPageDown(e); - case 35: return this._onEnd(e); - case 36: return this._onHome(e); - case 38: return this._onArrowUp(e); - case 40: return this._onArrowDown(e); - } - } - return false; - }); - } - - private _onKey(e: KeyboardEvent, handler: (e: KeyboardEvent) => boolean): boolean { - if (handler && handler(e)) { - e.preventDefault(); - e.stopPropagation(); - return true; - } - return false; - } - - private _onEscape(e: KeyboardEvent): boolean { - this.leave(); - return true; - } - - private _onArrowUp(e: KeyboardEvent): boolean { - return this._focusRow(this._absoluteFocusedRow - 1); - } - - private _onArrowDown(e: KeyboardEvent): boolean { - return this._focusRow(this._absoluteFocusedRow + 1); - } - - private _onPageUp(e: KeyboardEvent): boolean { - return this._focusRow(this._absoluteFocusedRow - this._terminal.rows); - } - - private _onPageDown(e: KeyboardEvent): boolean { - return this._focusRow(this._absoluteFocusedRow + this._terminal.rows); - } - - private _onHome(e: KeyboardEvent): boolean { - return this._focusRow(0); - } - - private _onEnd(e: KeyboardEvent): boolean { - return this._focusRow(this._terminal.buffer.lines.length - 1); - } - - private _focusRow(row: number): boolean { - this._navigateToElement(row); - this._rowContainer.focus(); - return true; - } - - private _navigateToElement(absoluteRow: number): void { - // Make sure there is no active element when scroll and rotate happens - if (this._focusedElement) { - this._rowContainer.removeAttribute('aria-activedescendant'); - this._focusedElement.removeAttribute('id'); - } - - // Rotate rows to ensure the next focused item is read out correctly - if (absoluteRow < this._terminal.buffer.ydisp || absoluteRow >= this._terminal.buffer.ydisp + this._terminal.rows) { - this._accessibilityManager.rotateRows(); - } - - // Scroll to row if it's outside of the viewport - absoluteRow = this._terminal.scrollToRow(absoluteRow); - this._absoluteFocusedRow = absoluteRow; - - // Focus the new active element - this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); - this._focusedElement = this._rowElements[absoluteRow - this._terminal.buffer.ydisp]; - this._focusedElement.id = this._activeItemId; - } -} diff --git a/src/xterm.css b/src/xterm.css index fffc6b8520..1057dadc84 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -124,7 +124,8 @@ cursor: text; } -.xterm .xterm-accessibility { +.xterm .xterm-accessibility, +.xterm .xterm-message { position: absolute; left: 0; top: 0; From 5c061195c0bfb8cd6c413c0871752a31f4fd720a Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 14:45:22 -0800 Subject: [PATCH 38/47] Get navigation right/down working with nav mode --- src/AccessibilityManager.ts | 61 +++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 676a39ed61..4749529588 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -73,8 +73,6 @@ export class AccessibilityManager implements IDisposable { this._renderRowsDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); this._refreshRows(); - // this._navigationMode = new NavigationMode(this._terminal, this._rowContainer, this._rowElements, this); - this._liveRegion = document.createElement('div'); this._liveRegion.classList.add('live-region'); this._liveRegion.setAttribute('aria-live', 'assertive'); @@ -83,7 +81,6 @@ export class AccessibilityManager implements IDisposable { this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot); this._disposables.push(this._renderRowsDebouncer); - // this._disposables.push(this._navigationMode); 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())); @@ -118,45 +115,54 @@ export class AccessibilityManager implements IDisposable { const boundaryElement = e.target; const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2]; - // Don't scroll if the buffer top has been reached - const posInSet = this._rowElements[0].getAttribute('aria-posinset'); - if (posInSet === '1') { + // 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; } - console.log('posInSet', posInSet); // Don't scroll when the last focused item was not the second row (focus is going the other // direction) - console.log('related', e.relatedTarget); if (e.relatedTarget !== beforeBoundaryElement) { console.log('cancel'); return; } - boundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); - let oldLastElement: HTMLElement; - // TODO: oldLastElement.removeEventListener(...) - + // TODO: Refactor to reduce duplication, define top and bottom boundary elements + let otherBoundaryElement: HTMLElement; if (position === BoundaryPosition.Top) { - oldLastElement = this._rowElements.pop(); - this._rowElements.unshift(this._createAccessibilityTreeNode()); - this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); - this._rowContainer.insertAdjacentElement('afterbegin', this._rowElements[0]); - } else { - oldLastElement = this._rowElements.shift(); - this._rowElements.push(this._createAccessibilityTreeNode()); - this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._topBoundaryFocusListener); - this._rowContainer.appendChild(this._rowElements[this._rowElements.length - 1]); - } - this._rowContainer.removeChild(oldLastElement); - + // Remove old other boundary element from array + otherBoundaryElement = this._rowElements.pop(); + // Remove listeners from old boundary elements + boundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); + otherBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); + // Add new element to array/DOM + this._rowElements.unshift(this._createAccessibilityTreeNode()); + this._rowContainer.insertAdjacentElement('afterbegin', this._rowElements[0]); - // TODO: Add bottom boundary listeners and remove in both cases + // Add listeners to new boundary elements + this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + } else { + // Remove old other boundary element from array + otherBoundaryElement = this._rowElements.shift(); + // Remove listeners from old boundary elements + otherBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); + boundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); + // Add new element to array/DOM + this._rowElements.push(this._createAccessibilityTreeNode()); + this._rowContainer.appendChild(this._rowElements[this._rowElements.length - 1]); + // Add listeners to new boundary elements + this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + } + this._rowContainer.removeChild(otherBoundaryElement); // Scroll up this._terminal.scrollLines(position === BoundaryPosition.Top ? -1 : 1); @@ -164,11 +170,6 @@ export class AccessibilityManager implements IDisposable { // TODO: Only refresh single this._refreshRowsDimensions(); - // Focus the new active element - // this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); - // this._focusedElement = this._rowElements[1]; - // this._focusedElement.id = this._activeItemId; - // Focus new boundary before element this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2].focus(); From 963db4d2fd46850818238dc470beb182607667bc Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 14:48:45 -0800 Subject: [PATCH 39/47] Remove navigation mode/more rows element --- demo/index.html | 3 --- demo/main.js | 8 -------- src/AccessibilityManager.ts | 27 --------------------------- src/Strings.ts | 1 - src/Terminal.ts | 10 ---------- src/utils/TestUtils.test.ts | 3 --- typings/xterm.d.ts | 6 ------ 7 files changed, 58 deletions(-) diff --git a/demo/index.html b/demo/index.html index 9c95d06c3d..b051f4ddf1 100644 --- a/demo/index.html +++ b/demo/index.html @@ -71,9 +71,6 @@

Accessibility

-

- -

Attention: The demo is a barebones implementation and is designed for xterm.js evaluation purposes 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 179c6c6481..e31c93b216 100644 --- a/demo/main.js +++ b/demo/main.js @@ -33,7 +33,6 @@ var terminalContainer = document.getElementById('terminal-container'), bellStyle: document.querySelector('#option-bell-style'), screenReaderMode: document.querySelector('#option-screen-reader-mode') }, - navigationModeElement = document.querySelector('#screen-reader-navigation-mode'), colsElement = document.getElementById('cols'), rowsElement = document.getElementById('rows'); @@ -87,13 +86,6 @@ optionElements.tabstopwidth.addEventListener('change', function () { optionElements.screenReaderMode.addEventListener('change', function () { term.setOption('screenReaderMode', optionElements.screenReaderMode.checked); }); -navigationModeElement.addEventListener('click', function () { - if (term.getOption('screenReaderMode')) { - term.enterNavigationMode(); - } else { - console.warn('screenReaderMode must be true to enter navigation mode'); - } -}); createTerminal(); diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 4749529588..a5fe336721 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -23,11 +23,9 @@ export class AccessibilityManager implements IDisposable { private _rowContainer: HTMLElement; private _rowElements: HTMLElement[] = []; private _liveRegion: HTMLElement; - private _moreRowsElement: HTMLElement; private _liveRegionLineCount: number = 0; private _renderRowsDebouncer: RenderDebouncer; - // private _navigationMode: NavigationMode; private _topBoundaryFocusListener: (e: FocusEvent) => void; private _bottomBoundaryFocusListener: (e: FocusEvent) => void; @@ -49,12 +47,6 @@ export class AccessibilityManager implements IDisposable { this._accessibilityTreeRoot = document.createElement('div'); this._accessibilityTreeRoot.classList.add('xterm-accessibility'); - this._moreRowsElement = document.createElement('div'); - this._moreRowsElement.classList.add('xterm-message'); - this._moreRowsElement.style.clip = 'clip(0 0 0 0)'; - this._moreRowsElement.textContent = Strings.navigationModeMoreRows; - this._accessibilityTreeRoot.appendChild(this._moreRowsElement); - this._rowContainer = document.createElement('div'); this._rowContainer.classList.add('xterm-accessibility-tree'); for (let i = 0; i < this._terminal.rows; i++) { @@ -178,25 +170,6 @@ export class AccessibilityManager implements IDisposable { e.stopImmediatePropagation(); } - public get isNavigationModeActive(): boolean { - // TODO: Remove this function - return true; - // return this._navigationMode.isActive; - } - - public enterNavigationMode(): void { - // this._navigationMode.enter(); - - // this._isNavigationModeActive = true; - this.announce('Entered line navigation mode'); - // this._rowContainer.tabIndex = 0; - // this._rowContainer.setAttribute('role', 'list'); - // this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId); - // this._navigateToElement(this._terminal.buffer.ydisp + this._terminal.buffer.y); - // this._rowContainer.focus(); - this._rowElements[this._rowElements.length - 1].focus(); - } - private _onResize(cols: number, rows: number): void { // Grow rows as required for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) { diff --git a/src/Strings.ts b/src/Strings.ts index 17d8a45848..8e78233339 100644 --- a/src/Strings.ts +++ b/src/Strings.ts @@ -4,5 +4,4 @@ */ export let promptLabel = 'Terminal input'; -export let navigationModeMoreRows = 'In order to properly navigation the terminal buffer you need to enter navigation mode'; 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 6d3f066a31..e9116a2214 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1374,12 +1374,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT } } - public enterNavigationMode(): void { - if (this._accessibilityManager) { - this._accessibilityManager.enterNavigationMode(); - } - } - /** * Gets whether the terminal has an active selection. */ @@ -1420,10 +1414,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT * @param {KeyboardEvent} ev The keydown event to be handled. */ protected _keyDown(ev: KeyboardEvent): boolean { - if (this._accessibilityManager && this._accessibilityManager.isNavigationModeActive) { - return; - } - if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) { return false; } diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 91dabc26dd..dd91267606 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -10,9 +10,6 @@ import * as Browser from '../shared/utils/Browser'; import { ITheme, IDisposable } from 'xterm'; export class MockTerminal implements ITerminal { - enterNavigationMode(): void { - throw new Error('Method not implemented.'); - } getOption(key: any): any { throw new Error('Method not implemented.'); } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index ce4684e514..c48c814bb6 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -370,12 +370,6 @@ declare module 'xterm' { */ deregisterLinkMatcher(matcherId: number): void; - /** - * Enters screen reader navigation mode. This will only work when - * the screenReaderMode option is true. - */ - enterNavigationMode(): void; - /** * Gets whether the terminal has an active selection. */ From 65850c1c209d0ad6b6b1e015087db8d381ac7e27 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 14:53:46 -0800 Subject: [PATCH 40/47] Reduce duplication --- src/AccessibilityManager.ts | 52 ++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index a5fe336721..969a983700 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -105,7 +105,7 @@ export class AccessibilityManager implements IDisposable { private _onBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void { const boundaryElement = e.target; - const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2]; + 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'); @@ -116,50 +116,44 @@ export class AccessibilityManager implements IDisposable { // Don't scroll when the last focused item was not the second row (focus is going the other // direction) - if (e.relatedTarget !== beforeBoundaryElement) { - console.log('cancel'); + if (e.relatedTarget !== beforeBoundaryElement) { return; } - // TODO: Refactor to reduce duplication, define top and bottom boundary elements - let otherBoundaryElement: HTMLElement; + // Remove old boundary element from array + let topBoundaryElement: HTMLElement; + let bottomBoundaryElement: HTMLElement; if (position === BoundaryPosition.Top) { - // Remove old other boundary element from array - otherBoundaryElement = this._rowElements.pop(); + 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 - boundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); - otherBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); + // Remove listeners from old boundary elements + topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); + bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); - // Add new element to array/DOM + // Add new element to array/DOM + if (position === BoundaryPosition.Top) { this._rowElements.unshift(this._createAccessibilityTreeNode()); this._rowContainer.insertAdjacentElement('afterbegin', this._rowElements[0]); - - // Add listeners to new boundary elements - this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); - this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); } else { - // Remove old other boundary element from array - otherBoundaryElement = this._rowElements.shift(); - - // Remove listeners from old boundary elements - otherBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); - boundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); - - // Add new element to array/DOM this._rowElements.push(this._createAccessibilityTreeNode()); this._rowContainer.appendChild(this._rowElements[this._rowElements.length - 1]); - - // Add listeners to new boundary elements - this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); - this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); } - this._rowContainer.removeChild(otherBoundaryElement); + + // 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); - // TODO: Only refresh single + // TODO: Only refresh only a single row this._refreshRowsDimensions(); // Focus new boundary before element From 6439aca1d847113f56aeb2ace8b83eee14d27513 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 15:00:19 -0800 Subject: [PATCH 41/47] Refresh dimensions of only a single row --- src/AccessibilityManager.ts | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 969a983700..fdcf488763 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -139,11 +139,15 @@ export class AccessibilityManager implements IDisposable { // Add new element to array/DOM if (position === BoundaryPosition.Top) { - this._rowElements.unshift(this._createAccessibilityTreeNode()); - this._rowContainer.insertAdjacentElement('afterbegin', this._rowElements[0]); + const newElement = this._createAccessibilityTreeNode(); + this._rowElements.unshift(newElement); + this._refreshRowDimensions(newElement); + this._rowContainer.insertAdjacentElement('afterbegin', newElement); } else { - this._rowElements.push(this._createAccessibilityTreeNode()); - this._rowContainer.appendChild(this._rowElements[this._rowElements.length - 1]); + const newElement = this._createAccessibilityTreeNode(); + this._rowElements.push(newElement); + this._refreshRowDimensions(newElement); + this._rowContainer.appendChild(newElement); } // Add listeners to new boundary elements @@ -153,9 +157,6 @@ export class AccessibilityManager implements IDisposable { // Scroll up this._terminal.scrollLines(position === BoundaryPosition.Top ? -1 : 1); - // TODO: Only refresh only a single row - this._refreshRowsDimensions(); - // Focus new boundary before element this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2].focus(); @@ -266,22 +267,17 @@ export class AccessibilityManager implements IDisposable { // TODO: Clean up } - public rotateRows(): void { - // this._rowContainer.removeChild(this._rowElements.shift()); - // const newRowIndex = this._rowElements.length; - // this._rowElements[newRowIndex] = this._createAccessibilityTreeNode(); - // this._rowContainer.appendChild(this._rowElements[newRowIndex]); - // this._refreshRowsDimensions(); - } - 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`; + this._refreshRowDimensions(this._rowElements[i]); } } + private _refreshRowDimensions(element: HTMLElement): void { + element.style.height = `${this._terminal.renderer.dimensions.actualCellHeight}px`; + } + public announce(text: string): void { this._clearLiveRegion(); this._liveRegion.textContent = text; From 5506af53224f0814080011a4b5056b4f6b839f81 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 16:47:23 -0800 Subject: [PATCH 42/47] Clean up a11y manager --- src/AccessibilityManager.ts | 47 ++++++++++++++++++------------------- src/Strings.ts | 1 + 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index fdcf488763..286c1f987a 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -141,12 +141,10 @@ export class AccessibilityManager implements IDisposable { if (position === BoundaryPosition.Top) { const newElement = this._createAccessibilityTreeNode(); this._rowElements.unshift(newElement); - this._refreshRowDimensions(newElement); this._rowContainer.insertAdjacentElement('afterbegin', newElement); } else { const newElement = this._createAccessibilityTreeNode(); this._rowElements.push(newElement); - this._refreshRowDimensions(newElement); this._rowContainer.appendChild(newElement); } @@ -166,6 +164,9 @@ export class AccessibilityManager implements IDisposable { } 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(); @@ -176,7 +177,8 @@ export class AccessibilityManager implements IDisposable { this._rowContainer.removeChild(this._rowElements.pop()); } - // TODO: Fix up boundary listeners + // Add bottom boundary listener + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); this._refreshRowsDimensions(); } @@ -185,6 +187,7 @@ export class AccessibilityManager implements IDisposable { const element = document.createElement('div'); element.setAttribute('role', 'listitem'); element.tabIndex = -1; + this._refreshRowDimensions(element); return element; } @@ -200,19 +203,10 @@ export class AccessibilityManager implements IDisposable { // Have the screen reader ignore the char if it was just input const shiftedChar = this._charsToConsume.shift(); if (shiftedChar !== char) { - 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; - } + this._announceCharacter(char); } } else { - if (char === ' ') { - this._liveRegion.innerHTML += ' '; - } else - this._liveRegion.textContent += char; + this._announceCharacter(char); } if (char === '\n') { @@ -255,20 +249,20 @@ export class AccessibilityManager implements IDisposable { } private _renderRows(start: number, end: number): void { - const buffer: IBuffer = (this._terminal.buffer); - const setSize = (buffer.lines.length).toString(); + 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); - this._rowElements[i].textContent = lineData.length === 0 ? 'Blank line' : lineData; const posInSet = (buffer.ydisp + i + 1).toString(); - this._rowElements[i].setAttribute('aria-posinset', posInSet); - this._rowElements[i].setAttribute('aria-setsize', setSize); + const element = this._rowElements[i]; + element.textContent = lineData.length === 0 ? Strings.blankLine : lineData; + element.setAttribute('aria-posinset', posInSet); + element.setAttribute('aria-setsize', setSize); } - // TODO: Clean up } private _refreshRowsDimensions(): void { - const buffer: IBuffer = (this._terminal.buffer); + const buffer: IBuffer = this._terminal.buffer; for (let i = 0; i < this._terminal.rows; i++) { this._refreshRowDimensions(this._rowElements[i]); } @@ -278,8 +272,13 @@ export class AccessibilityManager implements IDisposable { element.style.height = `${this._terminal.renderer.dimensions.actualCellHeight}px`; } - public announce(text: string): void { - this._clearLiveRegion(); - this._liveRegion.textContent = text; + 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/Strings.ts b/src/Strings.ts index 8e78233339..86f0ebe131 100644 --- a/src/Strings.ts +++ b/src/Strings.ts @@ -3,5 +3,6 @@ * @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'; From 0bdf1e82656c4ec508f5072d7536d5db7152ef8e Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 17:51:43 -0800 Subject: [PATCH 43/47] Remove unused function --- src/Terminal.ts | 22 ---------------------- src/Types.ts | 1 - 2 files changed, 23 deletions(-) diff --git a/src/Terminal.ts b/src/Terminal.ts index e9116a2214..4eafc7a043 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1187,28 +1187,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.refresh(0, this.rows - 1); } - /** - * Scroll the viewport to an absolute row in the buffer. - * @param absoluteRow The absolute row in the buffer to scroll to. - * @returns The actual absolute row that was scrolled to (including boundary checked). - */ - public scrollToRow(absoluteRow: number): number { - // Ensure value is valid - absoluteRow = Math.max(Math.min(absoluteRow, this.buffer.lines.length - 1), 0); - - // Move viewport as necessary - const relativeRow = absoluteRow - this.buffer.ydisp; - let scrollAmount = 0; - if (relativeRow < 0) { - scrollAmount = relativeRow; - } else if (relativeRow >= this.rows) { - scrollAmount = relativeRow - this.rows + 1; - } - this.scrollLines(scrollAmount); - - return absoluteRow; - } - /** * Scroll the display of the terminal by a number of pages. * @param {number} pageCount The number of pages to scroll (negative scrolls up). diff --git a/src/Types.ts b/src/Types.ts index 0b877930a6..2500d73cb1 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -197,7 +197,6 @@ export interface ITerminal extends PublicTerminal, IElementAccessor, IBufferAcce */ handler(data: string): void; scrollLines(disp: number, suppressScrollEvent?: boolean): void; - scrollToRow(row: number): number; cancel(ev: Event, force?: boolean): boolean | void; log(text: string): void; showCursor(): void; From b13826441cdeb4cb539a022808d1d8932b2e8283 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 18:14:52 -0800 Subject: [PATCH 44/47] Hook up scroll APIs to scroll and focus when navigating --- src/AccessibilityManager.ts | 74 ++++++++++++++++++++++++++++++++++--- src/Terminal.ts | 20 ++++++++-- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 286c1f987a..338f5a80e4 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -103,10 +103,20 @@ export class AccessibilityManager implements IDisposable { this._rowElements = null; } + public get isNavigatingRows(): boolean { + return this._rowElements.indexOf(document.activeElement) >= 0; + } + 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 when the last focused item was not the second row (focus is going the other + // direction) + if (e.relatedTarget !== beforeBoundaryElement) { + return; + } + // 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}`; @@ -114,12 +124,6 @@ export class AccessibilityManager implements IDisposable { 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; @@ -163,6 +167,64 @@ export class AccessibilityManager implements IDisposable { e.stopImmediatePropagation(); } + /** + * Moves the focus of the terminal relatively by a number of rows. + * @param amount The amount of rows to scroll + */ + public moveRowFocus(amount: number): void { + console.log('moveRowFocus', amount); + // Do nothing if not navigating rows or the amount to navigate is 0 + if (!this.isNavigatingRows || amount === 0) { + return; + } + + // Find and validate the new absolute row position + const buffer = this._terminal.buffer; + const oldRelativeRow = this._rowElements.indexOf(document.activeElement); + const oldAbsoluteRow = buffer.ydisp + oldRelativeRow; + const newAbsoluteRow = Math.max(Math.min(oldAbsoluteRow + amount, buffer.lines.length - 1), 0); + if (oldAbsoluteRow === newAbsoluteRow) { + return; + } + + // Find the new relative row, this cannot be on a boundary element unless the focused row is the + // top-most or bottom-most row in the buffer. + let newRelativeRow: number; + if (newAbsoluteRow === 0) { + newRelativeRow = 0; + } else if (newAbsoluteRow === buffer.lines.length - 1) { + newRelativeRow = this._terminal.rows - 1; + } else { + newRelativeRow = Math.max(Math.min(oldRelativeRow + amount, this._terminal.rows - 2), 1); + } + + // Find the new viewport position + let newYDisp: number; + if (newAbsoluteRow === 0) { + // Start of buffer + newYDisp = 0; + } else if (newAbsoluteRow === buffer.lines.length - 1) { + // End of buffer + newYDisp = buffer.lines.length - 1 - this._terminal.rows; + } else if (newAbsoluteRow > buffer.ydisp && newAbsoluteRow < buffer.ydisp + this._terminal.rows - 1) { + // No scrolling necessary + newYDisp = buffer.ydisp; + } else if (newAbsoluteRow < oldAbsoluteRow) { + // Scrolling up needed + newYDisp = newAbsoluteRow - 1; + } else { // if (newAbsoluteRow > oldAbsoluteRow) + // Scrolling down needed + newYDisp = newAbsoluteRow + this._terminal.rows - 2; + } + + // Perform scroll + const scrollAmount = newYDisp - buffer.ydisp; + this._terminal.scrollLines(scrollAmount); + + // Focus new row + this._rowElements[newRelativeRow].focus(); + } + private _onResize(cols: number, rows: number): void { // Remove bottom boundary listener this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener); diff --git a/src/Terminal.ts b/src/Terminal.ts index 4eafc7a043..d0389048c1 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1187,26 +1187,40 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.refresh(0, this.rows - 1); } + /** + * Scrolls the viewport by a number of rows when not navigating rows. When + * navigating rows, move focus relatively by a number of rows. + * @param amount The number of lines to scroll. + */ + private _accessibilityAwareScrollLines(amount: number): void { + console.log('_accessibilityAwareScrollLines'); + if (this.options.screenReaderMode && this._accessibilityManager.isNavigatingRows) { + this._accessibilityManager.moveRowFocus(amount); + } else { + this.scrollLines(amount); + } + } + /** * Scroll the display of the terminal by a number of pages. * @param {number} pageCount The number of pages to scroll (negative scrolls up). */ public scrollPages(pageCount: number): void { - this.scrollLines(pageCount * (this.rows - 1)); + this._accessibilityAwareScrollLines(pageCount * (this.rows - 1)); } /** * Scrolls the display of the terminal to the top. */ public scrollToTop(): void { - this.scrollLines(-this.buffer.ydisp); + this._accessibilityAwareScrollLines(-this.buffer.ydisp); } /** * Scrolls the display of the terminal to the bottom. */ public scrollToBottom(): void { - this.scrollLines(this.buffer.ybase - this.buffer.ydisp); + this._accessibilityAwareScrollLines(this.buffer.ybase - this.buffer.ydisp); } /** From 7c8b64aaf12c52ab6f2c67a0d518dc52e5226e66 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 18:14:58 -0800 Subject: [PATCH 45/47] Revert "Hook up scroll APIs to scroll and focus when navigating" This reverts commit b13826441cdeb4cb539a022808d1d8932b2e8283. --- src/AccessibilityManager.ts | 74 +++---------------------------------- src/Terminal.ts | 20 ++-------- 2 files changed, 9 insertions(+), 85 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 338f5a80e4..286c1f987a 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -103,20 +103,10 @@ export class AccessibilityManager implements IDisposable { this._rowElements = null; } - public get isNavigatingRows(): boolean { - return this._rowElements.indexOf(document.activeElement) >= 0; - } - 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 when the last focused item was not the second row (focus is going the other - // direction) - if (e.relatedTarget !== beforeBoundaryElement) { - return; - } - // 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}`; @@ -124,6 +114,12 @@ export class AccessibilityManager implements IDisposable { 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; @@ -167,64 +163,6 @@ export class AccessibilityManager implements IDisposable { e.stopImmediatePropagation(); } - /** - * Moves the focus of the terminal relatively by a number of rows. - * @param amount The amount of rows to scroll - */ - public moveRowFocus(amount: number): void { - console.log('moveRowFocus', amount); - // Do nothing if not navigating rows or the amount to navigate is 0 - if (!this.isNavigatingRows || amount === 0) { - return; - } - - // Find and validate the new absolute row position - const buffer = this._terminal.buffer; - const oldRelativeRow = this._rowElements.indexOf(document.activeElement); - const oldAbsoluteRow = buffer.ydisp + oldRelativeRow; - const newAbsoluteRow = Math.max(Math.min(oldAbsoluteRow + amount, buffer.lines.length - 1), 0); - if (oldAbsoluteRow === newAbsoluteRow) { - return; - } - - // Find the new relative row, this cannot be on a boundary element unless the focused row is the - // top-most or bottom-most row in the buffer. - let newRelativeRow: number; - if (newAbsoluteRow === 0) { - newRelativeRow = 0; - } else if (newAbsoluteRow === buffer.lines.length - 1) { - newRelativeRow = this._terminal.rows - 1; - } else { - newRelativeRow = Math.max(Math.min(oldRelativeRow + amount, this._terminal.rows - 2), 1); - } - - // Find the new viewport position - let newYDisp: number; - if (newAbsoluteRow === 0) { - // Start of buffer - newYDisp = 0; - } else if (newAbsoluteRow === buffer.lines.length - 1) { - // End of buffer - newYDisp = buffer.lines.length - 1 - this._terminal.rows; - } else if (newAbsoluteRow > buffer.ydisp && newAbsoluteRow < buffer.ydisp + this._terminal.rows - 1) { - // No scrolling necessary - newYDisp = buffer.ydisp; - } else if (newAbsoluteRow < oldAbsoluteRow) { - // Scrolling up needed - newYDisp = newAbsoluteRow - 1; - } else { // if (newAbsoluteRow > oldAbsoluteRow) - // Scrolling down needed - newYDisp = newAbsoluteRow + this._terminal.rows - 2; - } - - // Perform scroll - const scrollAmount = newYDisp - buffer.ydisp; - this._terminal.scrollLines(scrollAmount); - - // Focus new row - this._rowElements[newRelativeRow].focus(); - } - private _onResize(cols: number, rows: number): void { // Remove bottom boundary listener this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener); diff --git a/src/Terminal.ts b/src/Terminal.ts index d0389048c1..4eafc7a043 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1187,40 +1187,26 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.refresh(0, this.rows - 1); } - /** - * Scrolls the viewport by a number of rows when not navigating rows. When - * navigating rows, move focus relatively by a number of rows. - * @param amount The number of lines to scroll. - */ - private _accessibilityAwareScrollLines(amount: number): void { - console.log('_accessibilityAwareScrollLines'); - if (this.options.screenReaderMode && this._accessibilityManager.isNavigatingRows) { - this._accessibilityManager.moveRowFocus(amount); - } else { - this.scrollLines(amount); - } - } - /** * Scroll the display of the terminal by a number of pages. * @param {number} pageCount The number of pages to scroll (negative scrolls up). */ public scrollPages(pageCount: number): void { - this._accessibilityAwareScrollLines(pageCount * (this.rows - 1)); + this.scrollLines(pageCount * (this.rows - 1)); } /** * Scrolls the display of the terminal to the top. */ public scrollToTop(): void { - this._accessibilityAwareScrollLines(-this.buffer.ydisp); + this.scrollLines(-this.buffer.ydisp); } /** * Scrolls the display of the terminal to the bottom. */ public scrollToBottom(): void { - this._accessibilityAwareScrollLines(this.buffer.ybase - this.buffer.ydisp); + this.scrollLines(this.buffer.ybase - this.buffer.ydisp); } /** From ec156f62e1cda51682ab0ffbd253f420435410ea Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sat, 27 Jan 2018 18:38:28 -0800 Subject: [PATCH 46/47] Only update a11y manager dimensions after a renderer resize --- src/AccessibilityManager.ts | 5 ++++- src/renderer/Renderer.ts | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 286c1f987a..cb9155165d 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -80,13 +80,13 @@ export class AccessibilityManager implements IDisposable { 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('charsizechanged', () => this._refreshRowsDimensions())); 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())); @@ -262,6 +262,9 @@ export class AccessibilityManager implements IDisposable { } 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]); diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 80bc9f44a0..f281f9a9f0 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -242,7 +242,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; - } - } From a0f390ef5429a7d10029c0c3f2216a1a128e7644 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sun, 28 Jan 2018 13:10:11 -0800 Subject: [PATCH 47/47] Expose strings through the API --- src/Terminal.ts | 6 +++++- src/utils/TestUtils.test.ts | 1 + typings/xterm.d.ts | 13 ++++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Terminal.ts b/src/Terminal.ts index 4eafc7a043..48d00b2b5f 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -47,7 +47,7 @@ import { DEFAULT_ANSI_COLORS } from './renderer/ColorManager'; import { MouseZoneManager } from './input/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ScreenDprMonitor } from './utils/ScreenDprMonitor'; -import { ITheme } from 'xterm'; +import { ITheme, ILocalizableStrings } from 'xterm'; // Let it work inside Node.js for automated testing purposes. const document = (typeof window !== 'undefined') ? window.document : null; @@ -313,6 +313,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. */ diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index dd91267606..0c79e98f8d 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -10,6 +10,7 @@ import * as Browser from '../shared/utils/Browser'; import { ITheme, IDisposable } from 'xterm'; export class MockTerminal implements ITerminal { + static string: any; getOption(key: any): any { throw new Error('Method not implemented.'); } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index c48c814bb6..ea39ffcdd8 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -207,12 +207,18 @@ declare module 'xterm' { } /** - * An object that can be disposed via a dipose function. + * An object that can be disposed via a dispose function. */ export interface IDisposable { dispose(): void; } + export interface ILocalizableStrings { + blankLine: string; + promptLabel: string; + tooMuchOutput: string; + } + /** * The class that represents an xterm.js terminal. */ @@ -237,6 +243,11 @@ declare module 'xterm' { */ cols: number; + /** + * Natural language strings that can be localized. + */ + static strings: ILocalizableStrings; + /** * Creates a new `Terminal` object. *