-
Notifications
You must be signed in to change notification settings - Fork 33
/
Copy pathhterm_accessibility_reader.js
423 lines (375 loc) · 15.1 KB
/
hterm_accessibility_reader.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
// Copyright 2018 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {lib} from '../../libdot/index.js';
import {hterm} from '../index.js';
/**
* 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 {!Element} div The div element where the live region should be
* added.
* @constructor
*/
hterm.AccessibilityReader = function(div) {
this.document_ = div.ownerDocument;
// The live region element to add text to.
const liveRegion = this.document_.createElement('div');
liveRegion.id = 'hterm:accessibility-live-region';
liveRegion.style.cssText = `position: absolute;
width: 0; height: 0;
overflow: hidden;
left: -1000px; top: -1000px;`;
div.appendChild(liveRegion);
// Whether command output should be rendered for Assistive Technology.
// This isn't always enabled because it has an impact on performance.
this.accessibilityEnabled = false;
// This live element is used for command output.
this.liveElement_ = this.document_.createElement('p');
this.liveElement_.setAttribute('aria-live', 'polite');
liveRegion.appendChild(this.liveElement_);
// This live element is used for speaking out the current screen when
// navigating through the scrollback buffer. It will interrupt existing
// announcements.
this.assertiveLiveElement_ = this.document_.createElement('p');
this.assertiveLiveElement_.setAttribute('aria-live', 'assertive');
liveRegion.appendChild(this.assertiveLiveElement_);
// 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;
// This is set to true if the cursor is about to update position on the
// screen. i.e. beforeCursorChange has been called but not afterCursorChange.
this.cursorIsChanging_ = false;
// This tracks changes that would be added to queue_ while the cursor is
// changing. This is done so that we can decide to discard these changes if
// we announce something as a result of the cursor change.
this.cursorChangeQueue_ = [];
// The string of text on the row that the cursor was last on. Only valid while
// cursorIsChanging_ is true.
this.lastCursorRowString_ = null;
// The row that the cursor was last on. Only valid while cursorIsChanging_ is
// true.
this.lastCursorRow_ = null;
// The column that the cursor was last on. Only valid while cursorIsChanging_
// is true.
this.lastCursorColumn_ = null;
// True if a keypress has been performed since the last cursor change.
this.hasUserGesture = false;
};
/**
* Delay in ms to use for merging strings to output.
*
* We merge strings together to avoid hanging the terminal and to ensure that
* aria updates make it to the screen reader. We want this to be short so
* there's not a big delay between typing/executing commands and hearing output.
*
* @const
* @type {number}
*/
hterm.AccessibilityReader.DELAY = 50;
/**
* Enable accessibility-friendly features that have a performance impact.
*
* @param {boolean} enabled Whether to enable accessibility-friendly features.
*/
hterm.AccessibilityReader.prototype.setAccessibilityEnabled =
function(enabled) {
if (!enabled) {
this.clear();
}
this.accessibilityEnabled = enabled;
};
/**
* Decorate the document where the terminal <x-screen> resides. This is needed
* for listening to keystrokes on the screen.
*
* @param {!Document} doc The document where the <x-screen> resides.
*/
hterm.AccessibilityReader.prototype.decorate = function(doc) {
const handlers = ['keydown', 'keypress', 'keyup', 'textInput'];
handlers.forEach((handler) => {
doc.addEventListener(handler, () => { this.hasUserGesture = true; });
});
};
/**
* This should be called before the cursor on the screen is about to get
* updated. This allows cursor changes to be tracked and related notifications
* to be announced.
*
* @param {string} cursorRowString The text in the row that the cursor is
* currently on.
* @param {number} cursorRow The index of the row that the cursor is currently
* on, including rows in the scrollback buffer.
* @param {number} cursorColumn The index of the column that the cursor is
* currently on.
*/
hterm.AccessibilityReader.prototype.beforeCursorChange =
function(cursorRowString, cursorRow, cursorColumn) {
// If accessibility is enabled we don't announce selection changes as these
// can have a performance impact.
if (!this.accessibilityEnabled) {
return;
}
// If there is no user gesture that can be tied to the cursor change, we
// don't want to announce anything.
if (!this.hasUserGesture || this.cursorIsChanging_) {
return;
}
this.cursorIsChanging_ = true;
this.lastCursorRowString_ = cursorRowString;
this.lastCursorRow_ = cursorRow;
this.lastCursorColumn_ = cursorColumn;
};
/**
* This should be called after the cursor on the screen has been updated. Note
* that several updates to the cursor may have happened between
* beforeCursorChange and afterCursorChange.
*
* This allows cursor changes to be tracked and related notifications to be
* announced.
*
* @param {string} cursorRowString The text in the row that the cursor is
* currently on.
* @param {number} cursorRow The index of the row that the cursor is currently
* on, including rows in the scrollback buffer.
* @param {number} cursorColumn The index of the column that the cursor is
* currently on.
*/
hterm.AccessibilityReader.prototype.afterCursorChange =
function(cursorRowString, cursorRow, cursorColumn) {
// This can happen if clear() is called midway through a cursor change.
if (!this.cursorIsChanging_) {
return;
}
this.cursorIsChanging_ = false;
if (!this.announceAction_(cursorRowString, cursorRow, cursorColumn)) {
// If we don't announce a special action, we re-queue all the output that
// was queued during the selection change.
for (let i = 0; i < this.cursorChangeQueue_.length; ++i) {
this.announce(this.cursorChangeQueue_[i]);
}
}
this.cursorChangeQueue_ = [];
this.lastCursorRowString_ = null;
this.lastCursorRow_ = null;
this.lastCursorColumn_ = null;
this.hasUserGesture = false;
};
/**
* Announce the command output.
*
* @param {string} str The string to announce using a live region.
*/
hterm.AccessibilityReader.prototype.announce = function(str) {
if (!this.accessibilityEnabled) {
return;
}
// If the cursor is in the middle of changing, we queue up the output
// separately as we may not want it to be announced if it's part of a cursor
// change announcement.
if (this.cursorIsChanging_) {
this.cursorChangeQueue_.push(str);
return;
}
// Don't append newlines to the queue if the queue is empty. It won't have any
// impact.
if (str == '\n' && this.queue_.length > 0) {
this.queue_.push('');
// We don't need to trigger an announcement on newlines because they won't
// change the existing content that's output.
return;
}
if (this.queue_.length == 0) {
this.queue_.push(str);
} else {
// We put a space between strings that appear on the same line.
// TODO(raymes): We should check the location on the row and not add a space
// if the strings are joined together.
let padding = '';
if (this.queue_[this.queue_.length - 1].length != 0) {
padding = ' ';
}
this.queue_[this.queue_.length - 1] += padding + 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.addToLiveRegion_.bind(this),
hterm.AccessibilityReader.DELAY);
} else {
throw new Error(
'Expected only one item in queue_ or nextReadTimer_ to be running.');
}
};
/**
* Voice an announcement that will interrupt other announcements.
*
* @param {string} str The string to announce using a live region.
*/
hterm.AccessibilityReader.prototype.assertiveAnnounce = function(str) {
if (this.hasUserGesture && str == ' ') {
str = hterm.msg('SPACE_CHARACTER', [], 'Space');
}
// If the same string is announced twice, an attribute change won't be
// registered and the screen reader won't know that the string has changed.
// So we slightly change the string to ensure that the attribute change gets
// registered.
str = str.trim();
if (str == this.assertiveLiveElement_.innerText) {
str = '\n' + str;
}
this.clear();
this.assertiveLiveElement_.innerText = str;
};
/**
* Add a newline to the text that will be announced to the live region.
*/
hterm.AccessibilityReader.prototype.newLine = function() {
this.announce('\n');
};
/**
* Clear the live region and any in-flight announcements.
*/
hterm.AccessibilityReader.prototype.clear = function() {
this.liveElement_.innerText = '';
this.assertiveLiveElement_.innerText = '';
clearTimeout(this.nextReadTimer_);
this.nextReadTimer_ = null;
this.queue_ = [];
this.cursorIsChanging_ = false;
this.cursorChangeQueue_ = [];
this.lastCursorRowString_ = null;
this.lastCursorRow_ = null;
this.lastCursorColumn_ = null;
this.hasUserGesture = false;
};
/**
* This will announce an action that is related to a cursor change, for example
* when the user deletes a character we want the character deleted to be
* announced. Similarly, when the user moves the cursor along the line, we want
* the characters selected to be announced.
*
* Note that this function is a heuristic. Because of the nature of terminal
* emulators, we can't distinguish input and output, which means we don't really
* know what output is the result of a keypress and what isn't. Also in some
* terminal applications certain announcements may make sense whereas others may
* not. This function should try to account for the most common cases.
*
* @param {string} cursorRowString The text in the row that the cursor is
* currently on.
* @param {number} cursorRow The index of the row that the cursor is currently
* on, including rows in the scrollback buffer.
* @param {number} cursorColumn The index of the column that the cursor is
* currently on.
* @return {boolean} Whether anything was announced.
*/
hterm.AccessibilityReader.prototype.announceAction_ =
function(cursorRowString, cursorRow, cursorColumn) {
// If the cursor changes rows, we don't announce anything at present.
if (this.lastCursorRow_ != cursorRow) {
return false;
}
// The case when the row of text hasn't changed at all.
if (lib.notNull(this.lastCursorRowString_) === cursorRowString) {
// Moving the cursor along the line. We check that no significant changes
// have been queued. If they have, it may not just be a cursor movement and
// it may be better to read those out.
if (lib.notNull(this.lastCursorColumn_) !== cursorColumn &&
this.cursorChangeQueue_.join('').trim() == '') {
// Announce the text between the old cursor position and the new one.
const start = Math.min(this.lastCursorColumn_, cursorColumn);
const len = Math.abs(cursorColumn - this.lastCursorColumn_);
this.assertiveAnnounce(
hterm.wc.substr(this.lastCursorRowString_, start, len));
return true;
}
return false;
}
// The case when the row of text has changed.
if (this.lastCursorRowString_ != cursorRowString) {
// Spacebar. We manually announce this character since the screen reader may
// not announce the whitespace in a live region.
if (this.lastCursorColumn_ + 1 == cursorColumn) {
if (hterm.wc.substr(cursorRowString, cursorColumn - 1, 1) == ' ' &&
this.cursorChangeQueue_.length > 0 &&
this.cursorChangeQueue_[0] == ' ') {
this.assertiveAnnounce(' ');
return true;
}
}
// Backspace and deletion.
// The position of the characters deleted is right after the current
// position of the cursor in the case of backspace and delete.
const cursorDeleted = cursorColumn;
// Check that the current row string is shorter than the previous. Also
// check that the start of the strings (up to the cursor) match.
if (hterm.wc.strWidth(cursorRowString) <=
hterm.wc.strWidth(this.lastCursorRowString_) &&
hterm.wc.substr(this.lastCursorRowString_, 0, cursorDeleted) ==
hterm.wc.substr(cursorRowString, 0, cursorDeleted)) {
// Find the length of the current row string ignoring space characters.
// These may be inserted at the end of the string when deleting characters
// so they should be ignored.
let lengthOfCurrentRow = hterm.wc.strWidth(cursorRowString);
for (; lengthOfCurrentRow > 0; --lengthOfCurrentRow) {
if (lengthOfCurrentRow == cursorDeleted ||
hterm.wc.substr(cursorRowString, lengthOfCurrentRow - 1, 1)
!= ' ') {
break;
}
}
const numCharsDeleted =
hterm.wc.strWidth(this.lastCursorRowString_) - lengthOfCurrentRow;
// Check that the end of the strings match.
const lengthOfEndOfString = lengthOfCurrentRow - cursorDeleted;
const endOfLastRowString = hterm.wc.substr(
this.lastCursorRowString_, cursorDeleted + numCharsDeleted,
lengthOfEndOfString);
const endOfCurrentRowString =
hterm.wc.substr(cursorRowString, cursorDeleted, lengthOfEndOfString);
if (endOfLastRowString == endOfCurrentRowString) {
const deleted = hterm.wc.substr(
this.lastCursorRowString_, cursorDeleted, numCharsDeleted);
if (deleted != '') {
this.assertiveAnnounce(deleted);
return true;
}
}
}
return false;
}
return false;
};
/**
* Add text from queue_ to the live region.
*
*/
hterm.AccessibilityReader.prototype.addToLiveRegion_ = function() {
this.nextReadTimer_ = null;
let str = this.queue_.join('\n').trim();
// If the same string is announced twice, an attribute change won't be
// registered and the screen reader won't know that the string has changed.
// So we slightly change the string to ensure that the attribute change gets
// registered.
if (str == this.liveElement_.innerText) {
str = '\n' + str;
}
this.liveElement_.innerText = str;
this.queue_ = [];
};