Skip to content
This repository has been archived by the owner on Sep 1, 2023. It is now read-only.
/ hterm Public archive

Commit

Permalink
hterm/nassh: Add basic support for announcing command output to AT in…
Browse files Browse the repository at this point in the history
… hterm. (Reland)

This adds basic support for announcing command output for Assistive
Technology. A live region is created and <p> elements added to it to
indicate that the screen reader should announce the output. The live
region is separate from the <x-screen> element is used because not all
output will be rendered to the <x-screen>. It also allows the output to
be structured in a way that is more suitable for AT.

For large amounts of output, delays are inserted before rendering text
to the live region to avoid performance issues which cause the DOM to
hang.

nassh and crosh are updated to use this support only when a screen
reader is detected to be present. When that's not the case we don't
add nodes to the live region since there is an associated performance
cost.

This is a reland of https://chromium-review.googlesource.com/c/1013822/
which broke because crosh didn't have accessibilityFeatures permission.
A runtime check has been added for now to avoid needing to wait for the
Chrome-side change to land.

Bug: 822490, 646690
Change-Id: Ie588d5f962a6e61bbc994cf956f0622e40c690b4
Reviewed-on: https://chromium-review.googlesource.com/1063047
Tested-by: Raymes Khoury <[email protected]>
Reviewed-by: Mike Frysinger <[email protected]>
  • Loading branch information
Raymes Khoury committed May 17, 2018
1 parent 60279de commit 6ccb7d0
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 6 deletions.
1 change: 1 addition & 0 deletions concat/hterm.concat
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# Keep this list in sync with ../html/hterm_test.html!

hterm/js/hterm.js
hterm/js/hterm_accessibility_reader.js
hterm/js/hterm_frame.js
hterm/js/hterm_keyboard.js
hterm/js/hterm_keyboard_bindings.js
Expand Down
4 changes: 4 additions & 0 deletions doc/hack.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ The vast majority of the code here lives under [js/].
keyboard key names and actions).

* Supplementary modules
* [hterm_accessibility_reader.js]: Code related to rendering terminal output
for a screen reader.
* [hterm_options.js]: Internal runtime settings for the `Terminal` object.
* [hterm_preference_manager.js]: Manager for user preferences.
* [hterm_pubsub.js]: Helper for managing custom events.
Expand Down Expand Up @@ -213,6 +215,8 @@ loads rows on the fly from `hterm.Terminal` (as a "RowProvider").
[hterm_test.html]: ../html/hterm_test.html

[hterm.js]: ../js/hterm.js
[hterm_accessibility_reader.js]: ../js/hterm_accessibility_reader.js
[hterm_accessibility_reader_tests.js]: ../js/hterm_accessibility_reader_tests.js
[hterm_frame.js]: ../js/hterm_frame.js
[hterm_keyboard.js]: ../js/hterm_keyboard.js
[hterm_keyboard_bindings.js]: ../js/hterm_keyboard_bindings.js
Expand Down
2 changes: 2 additions & 0 deletions html/hterm_test.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<!-- Keep this list in sync with ../concat/hterm.concat! -->
<script src='../js/hterm.js'></script>
<script src='../js/hterm_accessibility_reader.js'></script>
<script src='../js/hterm_frame.js'></script>
<script src='../js/hterm_keyboard.js'></script>
<script src='../js/hterm_keyboard_bindings.js'></script>
Expand All @@ -34,6 +35,7 @@
<!-- TODO: Add some tests.
<script src='../js/hterm_keyboard_bindings_tests.js'></script>
-->
<script src='../js/hterm_accessibility_reader_tests.js'></script>
<script src='../js/hterm_parser_tests.js'></script>
<script src='../js/hterm_preference_manager_tests.js'></script>
<script src='../js/hterm_pubsub_tests.js'></script>
Expand Down
6 changes: 3 additions & 3 deletions js/hterm.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ hterm.desktopNotificationTitle = '\u266A %(title) \u266A';
*
* A test harness should ensure that they all exist before running.
*/
hterm.testDeps = ['hterm.ScrollPort.Tests', 'hterm.Screen.Tests',
'hterm.Terminal.Tests', 'hterm.VT.Tests',
'hterm.VT.CannedTests'];
hterm.testDeps = ['hterm.AccessibilityReader.Tests', 'hterm.ScrollPort.Tests',
'hterm.Screen.Tests', 'hterm.Terminal.Tests',
'hterm.VT.Tests', 'hterm.VT.CannedTests'];

/**
* The hterm init function, registered with lib.registerInit().
Expand Down
159 changes: 159 additions & 0 deletions js/hterm_accessibility_reader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2018 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

'use strict';

/**
* AccessibilityReader responsible for rendering command output for AT.
*
* Renders command output for Assistive Technology using a live region. We don't
* use the visible rows of the terminal for rendering command output to the
* screen reader because the rendered content may be different from what we want
* read out by a screen reader. For example, we may not actually render every
* row of a large piece of output to the screen as it wouldn't be performant.
* But we want the screen reader to read it all out in order.
*
* @param {HTMLDivElement} div The div element where the live region should be
* added.
*/
hterm.AccessibilityReader = function(div) {
this.document_ = div.ownerDocument;

// The live region element to add text to.
this.liveRegion_ = this.document_.createElement('div');
this.liveRegion_.id = 'hterm:accessibility-live-region';
this.liveRegion_.setAttribute('aria-live', 'polite');
this.liveRegion_.style.cssText = `position: absolute;
width: 0; height: 0;
overflow: hidden;
left: 0; top: 0;`;
div.appendChild(this.liveRegion_);

// A queue of updates to announce.
this.queue_ = [];

// A timer which tracks when next to add items to the live region. null when
// not running. This is used to combine updates that occur in a small window,
// as well as to avoid too much output being added to the live region in one
// go which can cause the renderer to hang.
this.nextReadTimer_ = null;
};

/**
* Initial delay in ms to use for merging strings to output.
*
* Only used if no output has been spoken recently. We want this to be
* relatively short so there's not a big delay between typing/executing commands
* and hearing output.
*
* @constant
* @type {integer}
*/
hterm.AccessibilityReader.INITIAL_DELAY = 50;

/**
* Delay for bufferring subsequent strings of output in ms.
* This can be longer because text is already being spoken. Having too small a
* delay interferes with performance for large amounts of output. A larger delay
* may cause interruptions to speech.
*
* @constant
* @type {integer}
*/
hterm.AccessibilityReader.SUBSEQUENT_DELAY = 100;

/**
* The maximum number of strings to add to the live region in a single pass.
*
* If this is too large performance will suffer. If it is too small, it will
* take too long to add text to the live region and may cause interruptions
* to speech.
*
* @constant
* @type {integer}
*/
hterm.AccessibilityReader.MAX_ITEMS_TO_ADD = 100;

/**
* Announce the command output.
*
* @param {string} str The string to announce using a live region.
*/
hterm.AccessibilityReader.prototype.announce = function(str) {
// TODO(raymes): If the string being added is on the same line as previous
// strings in the queue, merge them so that the reading of the text doesn't
// stutter.
this.queue_.push(str);

// If we've already scheduled text being added to the live region, wait for it
// to happen.
if (this.nextReadTimer_) {
return;
}

// If there's only one item in the queue, we may get other text being added
// very soon after. In that case, wait a small delay so we can merge the
// related strings.
if (this.queue_.length == 1) {
this.nextReadTimer_ = setTimeout(this.onNextReadTimer_.bind(this),
hterm.AccessibilityReader.INITIAL_DELAY);
} else {
throw new Error(
'Expected only one item in queue_ or nextReadTimer_ to be running.');
}
};

/**
* Clear the live region.
*/
hterm.AccessibilityReader.prototype.clear = function() {
while (this.liveRegion_.firstChild) {
this.liveRegion_.firstChild.remove();
}
};

/**
* Add text from queue_ to the live region.
*
* This limits the amount of text that will be added in one go and schedules
* another call to addLiveRegion_ afterwards to continue adding text until
* queue_ is empty.
*/
hterm.AccessibilityReader.prototype.addToLiveRegion_ = function() {
if (this.nextReadTimer_) {
throw new Error('Expected nextReadTimer_ not to be running.');
}

// Clear the live region so it doesn't grow indefinitely. As soon as elements
// are added to the DOM, the screen reader will be informed so we don't need
// to keep elements around after that.
this.clear();

for (let i = 0; i < hterm.AccessibilityReader.MAX_ITEMS_TO_ADD; ++i) {
const str = this.queue_.shift();
const liveElement = this.document_.createElement('p');
liveElement.innerText = str;
this.liveRegion_.appendChild(liveElement);
if (this.queue_.length == 0) {
break;
}
}

if (this.queue_.length > 0) {
this.nextReadTimer_ = setTimeout(
this.onNextReadTimer_.bind(this),
hterm.AccessibilityReader.SUBSEQUENT_DELAY);
}
};

/**
* Fired when nextReadTimer_ finishes.
*
* This clears the timer and calls addToLiveRegion_.
*/
hterm.AccessibilityReader.prototype.onNextReadTimer_ = function() {
this.nextReadTimer_ = null;
this.addToLiveRegion_();
};
174 changes: 174 additions & 0 deletions js/hterm_accessibility_reader_tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright 2018 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

'use strict';

/**
* @fileoverview hterm.AccessibilityReader unit tests.
*/
hterm.AccessibilityReader.Tests = new lib.TestManager.Suite(
'hterm.AccessibilityReader.Tests');

/**
* Clear out the current document and create a new hterm.AccessibilityReader
* object for testing.
*
* Called before each test case in this suite.
*/
hterm.AccessibilityReader.Tests.prototype.preamble = function(result, cx) {
const document = cx.window.document;

document.body.innerHTML = '';

const div = this.div = document.createElement('div');
div.style.position = 'absolute';
div.style.height = '100%';
div.style.width = '100%';

this.accessibilityReader = new hterm.AccessibilityReader(div);
this.liveRegion = div.firstChild;

document.body.appendChild(div);
};

/**
* Test that printing text to the terminal will cause nodes to be added to the
* live region for accessibility purposes. This shouldn't happen until after a
* small delay has passed.
*/
hterm.AccessibilityReader.Tests.addTest(
'a11y-live-region-single-delay', function(result, cx) {
this.accessibilityReader.announce('Some test output');
this.accessibilityReader.announce('Some other test output');

result.assertEQ(0, this.liveRegion.children.length);

const observer = new MutationObserver(() => {
if (this.liveRegion.children.length < 2) {
return;
}

result.assertEQ('Some test output',
this.liveRegion.children[0].innerHTML);
result.assertEQ('Some other test output',
this.liveRegion.children[1].innerHTML);

observer.disconnect();
result.pass();
});

observer.observe(this.liveRegion, {childList: true});
// This should only need to be 1x the initial delay but we wait longer to
// avoid flakiness.
result.requestTime(500);
});

/**
* Test that after text has been added to the live region, there is again a
* delay before adding more text.
*/
hterm.AccessibilityReader.Tests.addTest(
'a11y-live-region-double-delay', function(result, cx) {
this.accessibilityReader.announce('Some test output');
this.accessibilityReader.announce('Some other test output');

result.assertEQ(0, this.liveRegion.children.length);

const checkFirstAnnounce = () => {
if (this.liveRegion.children.length < 2) {
return false;
}

result.assertEQ('Some test output',
this.liveRegion.children[0].innerHTML);
result.assertEQ('Some other test output',
this.liveRegion.children[1].innerHTML);

this.accessibilityReader.announce('more text');
this.accessibilityReader.announce('...and more');
return true;
};

const checkSecondAnnounce = () => {
if (this.liveRegion.children.length < 2) {
return false;
}

result.assertEQ('more text', this.liveRegion.children[0].innerHTML);
result.assertEQ('...and more', this.liveRegion.children[1].innerHTML);
return true;
};

const checksToComplete = [checkFirstAnnounce, checkSecondAnnounce];

const observer = new MutationObserver(() => {
if (checksToComplete[0]()) {
checksToComplete.shift();
}

if (checksToComplete.length == 0) {
observer.disconnect();
result.pass();
}
});

observer.observe(this.liveRegion, {childList: true});
// This should only need to be 2x the initial delay but we wait longer to
// avoid flakiness.
result.requestTime(500);
});

/**
* Test that when adding a large amount of text, it will get buffered into the
* live region.
*/
hterm.AccessibilityReader.Tests.addTest(
'a11y-live-region-large-text', function(result, cx) {
for (let i = 0; i < hterm.AccessibilityReader.MAX_ITEMS_TO_ADD; ++i) {
this.accessibilityReader.announce('First pass');
}
this.accessibilityReader.announce('Second pass');

result.assertEQ(0, this.liveRegion.children.length);

const checkFirstAnnounce = () => {
if (this.liveRegion.children.length <
hterm.AccessibilityReader.MAX_ITEMS_TO_ADD) {
return false;
}

for (let i = 0; i < hterm.AccessibilityReader.MAX_ITEMS_TO_ADD; ++i) {
result.assertEQ('First pass', this.liveRegion.children[i].innerHTML);
}

return true;
};

const checkSecondAnnounce = () => {
if (this.liveRegion.children.length < 1) {
return false;
}

result.assertEQ('Second pass', this.liveRegion.children[0].innerHTML);
return true;
};

const checksToComplete = [checkFirstAnnounce, checkSecondAnnounce];

const observer = new MutationObserver(() => {
if (checksToComplete[0]()) {
checksToComplete.shift();
}

if (checksToComplete.length == 0) {
observer.disconnect();
result.pass();
}
});

observer.observe(this.liveRegion, {childList: true});
// This should only need to be the initial delay plus the subsequent delay
// but we use a longer delay to avoid flakiness.
result.requestTime(500);
});
1 change: 1 addition & 0 deletions js/hterm_scrollport.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ hterm.ScrollPort.prototype.decorate = function(div) {
this.screen_.setAttribute('autocorrect', 'off');
this.screen_.setAttribute('autocapitalize', 'none');
this.screen_.setAttribute('role', 'textbox');
this.screen_.setAttribute('aria-live', 'off');

// Set aria-readonly to indicate to the screen reader that the text on the
// screen is not modifiable by the html cursor. It may be modifiable by
Expand Down
Loading

0 comments on commit 6ccb7d0

Please sign in to comment.