Skip to content

Commit 1b940de

Browse files
author
Jason Lin
committed
Allow selection in the a11y tree and sync the selection to terminal
1 parent 34017f3 commit 1b940de

File tree

5 files changed

+239
-53
lines changed

5 files changed

+239
-53
lines changed

Diff for: css/xterm.css

+10-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
cursor: crosshair;
141141
}
142142

143-
.xterm .xterm-accessibility,
143+
.xterm .xterm-accessibility:not(.debug),
144144
.xterm .xterm-message {
145145
position: absolute;
146146
left: 0;
@@ -152,6 +152,15 @@
152152
pointer-events: none;
153153
}
154154

155+
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
156+
color: transparent;
157+
}
158+
159+
.xterm .xterm-accessibility-tree {
160+
user-select: text;
161+
white-space: pre;
162+
}
163+
155164
.xterm .live-region {
156165
position: absolute;
157166
left: -9999px;

Diff for: src/browser/AccessibilityManager.ts

+131-3
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,17 @@ const enum BoundaryPosition {
1919
BOTTOM
2020
}
2121

22+
// Turn this on to unhide the accessibility tree and display it under
23+
// (instead of overlapping with) the terminal.
24+
const DEBUG = false;
25+
2226
export class AccessibilityManager extends Disposable {
27+
private _debugRootContainer: HTMLElement | undefined;
2328
private _accessibilityContainer: HTMLElement;
2429

2530
private _rowContainer: HTMLElement;
2631
private _rowElements: HTMLElement[];
32+
private _rowColumns: WeakMap<HTMLElement, number[]> = new WeakMap();
2733

2834
private _liveRegion: HTMLElement;
2935
private _liveRegionLineCount: number = 0;
@@ -81,7 +87,23 @@ export class AccessibilityManager extends Disposable {
8187
if (!this._terminal.element) {
8288
throw new Error('Cannot enable accessibility before Terminal.open');
8389
}
84-
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer);
90+
91+
if (DEBUG) {
92+
this._accessibilityContainer.classList.add('debug');
93+
this._rowContainer.classList.add('debug');
94+
95+
// Use a `<div class="xterm">` container so that the css will still apply.
96+
this._debugRootContainer = document.createElement('div');
97+
this._debugRootContainer.classList.add('xterm');
98+
99+
this._debugRootContainer.appendChild(document.createTextNode('------start a11y------'));
100+
this._debugRootContainer.appendChild(this._accessibilityContainer);
101+
this._debugRootContainer.appendChild(document.createTextNode('------end a11y------'));
102+
103+
this._terminal.element.insertAdjacentElement('afterend', this._debugRootContainer);
104+
} else {
105+
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer);
106+
}
85107

86108
this.register(this._terminal.onResize(e => this._handleResize(e.rows)));
87109
this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end)));
@@ -93,6 +115,7 @@ export class AccessibilityManager extends Disposable {
93115
this.register(this._terminal.onKey(e => this._handleKey(e.key)));
94116
this.register(this._terminal.onBlur(() => this._clearLiveRegion()));
95117
this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions()));
118+
this.register(addDisposableDomListener(document, 'selectionchange', () => this._handleSelectionChange()));
96119

97120
this._screenDprMonitor = new ScreenDprMonitor(window);
98121
this.register(this._screenDprMonitor);
@@ -103,7 +126,11 @@ export class AccessibilityManager extends Disposable {
103126

104127
this._refreshRows();
105128
this.register(toDisposable(() => {
106-
this._accessibilityContainer.remove();
129+
if (DEBUG) {
130+
this._debugRootContainer!.remove();
131+
} else {
132+
this._accessibilityContainer.remove();
133+
}
107134
this._rowElements.length = 0;
108135
}));
109136
}
@@ -156,14 +183,18 @@ export class AccessibilityManager extends Disposable {
156183
const buffer: IBuffer = this._terminal.buffer;
157184
const setSize = buffer.lines.length.toString();
158185
for (let i = start; i <= end; i++) {
159-
const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true);
186+
const line = buffer.lines.get(buffer.ydisp + i);
187+
const columns: number[] = [];
188+
const lineData = line?.translateToString(true, undefined, undefined, columns) || '';
160189
const posInSet = (buffer.ydisp + i + 1).toString();
161190
const element = this._rowElements[i];
162191
if (element) {
163192
if (lineData.length === 0) {
164193
element.innerText = '\u00a0';
194+
this._rowColumns.set(element, [0]);
165195
} else {
166196
element.textContent = lineData;
197+
this._rowColumns.set(element, columns);
167198
}
168199
element.setAttribute('aria-posinset', posInSet);
169200
element.setAttribute('aria-setsize', setSize);
@@ -240,6 +271,103 @@ export class AccessibilityManager extends Disposable {
240271
e.stopImmediatePropagation();
241272
}
242273

274+
private _handleSelectionChange(): void {
275+
if (this._rowElements.length === 0) {
276+
return;
277+
}
278+
279+
const selection = document.getSelection();
280+
if (!selection) {
281+
return;
282+
}
283+
284+
if (selection.isCollapsed) {
285+
// Only do something when the anchorNode is inside the row container. This
286+
// behavior mirrors what we do with mouse --- if the mouse clicks
287+
// somewhere outside of the terminal, we don't clear the selection.
288+
if (this._rowContainer.contains(selection.anchorNode)) {
289+
this._terminal.clearSelection();
290+
}
291+
return;
292+
}
293+
294+
if (!selection.anchorNode || !selection.focusNode) {
295+
console.error('anchorNode and/or focusNode are null');
296+
return;
297+
}
298+
299+
// Sort the two selection points in document order.
300+
let begin = { node: selection.anchorNode, offset: selection.anchorOffset };
301+
let end = { node: selection.focusNode, offset: selection.focusOffset };
302+
if ((begin.node.compareDocumentPosition(end.node) & Node.DOCUMENT_POSITION_PRECEDING) || (begin.node === end.node && begin.offset > end.offset) ) {
303+
[begin, end] = [end, begin];
304+
}
305+
306+
// Clamp begin/end to the inside of the row container.
307+
if (begin.node.compareDocumentPosition(this._rowElements[0]) & (Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_FOLLOWING)) {
308+
begin = { node: this._rowElements[0].childNodes[0], offset: 0 };
309+
}
310+
if (!this._rowContainer.contains(begin.node)) {
311+
// This happens when `begin` is below the last row.
312+
return;
313+
}
314+
const lastRowElement = this._rowElements.slice(-1)[0];
315+
if (end.node.compareDocumentPosition(lastRowElement) & (Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_PRECEDING)) {
316+
end = {
317+
node: lastRowElement,
318+
offset: lastRowElement.textContent?.length ?? 0
319+
};
320+
}
321+
if (!this._rowContainer.contains(end.node)) {
322+
// This happens when `end` is above the first row.
323+
return;
324+
}
325+
326+
const toRowColumn = ({ node, offset }: typeof begin): {row: number, column: number} | null => {
327+
// `node` is either the row element or the Text node inside it.
328+
const rowElement: any = node instanceof Text ? node.parentNode : node;
329+
let row = parseInt(rowElement?.getAttribute('aria-posinset'), 10) - 1;
330+
if (isNaN(row)) {
331+
console.warn('row is invalid. Race condition?');
332+
return null;
333+
}
334+
335+
const columns = this._rowColumns.get(rowElement);
336+
if (!columns) {
337+
console.warn('columns is null. Race condition?');
338+
return null;
339+
}
340+
341+
let column = offset < columns.length ? columns[offset] : columns.slice(-1)[0] + 1;
342+
if (column >= this._terminal.cols) {
343+
++row;
344+
column = 0;
345+
}
346+
return {
347+
row,
348+
column
349+
};
350+
};
351+
352+
const beginRowColumn = toRowColumn(begin);
353+
const endRowColumn = toRowColumn(end);
354+
355+
if (!beginRowColumn || !endRowColumn) {
356+
return;
357+
}
358+
359+
if (beginRowColumn.row > endRowColumn.row || (beginRowColumn.row === endRowColumn.row && beginRowColumn.column >= endRowColumn.column)) {
360+
// This should not happen unless we have some bugs.
361+
throw new Error('invalid range');
362+
}
363+
364+
this._terminal.select(
365+
beginRowColumn.column,
366+
beginRowColumn.row,
367+
(endRowColumn.row - beginRowColumn.row) * this._terminal.cols - beginRowColumn.column + endRowColumn.column
368+
);
369+
}
370+
243371
private _handleResize(rows: number): void {
244372
// Remove bottom boundary listener
245373
this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);

Diff for: src/common/Types.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export interface IBufferLine {
245245
clone(): IBufferLine;
246246
getTrimmedLength(): number;
247247
getNoBgTrimmedLength(): number;
248-
translateToString(trimRight?: boolean, startCol?: number, endCol?: number): string;
248+
translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string;
249249

250250
/* direct access to cell attrs */
251251
getWidth(index: number): number;

Diff for: src/common/buffer/BufferLine.test.ts

+76-45
Original file line numberDiff line numberDiff line change
@@ -331,56 +331,75 @@ describe('BufferLine', function(): void {
331331
describe('translateToString with and w\'o trimming', function(): void {
332332
it('empty line', function(): void {
333333
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
334-
assert.equal(line.translateToString(false), ' ');
335-
assert.equal(line.translateToString(true), '');
334+
const columns: number[] = [];
335+
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
336+
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
337+
assert.equal(line.translateToString(true, undefined, undefined, columns), '');
338+
assert.deepEqual(columns, []);
336339
});
337340
it('ASCII', function(): void {
341+
const columns: number[] = [];
338342
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
339343
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
340344
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
341345
line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
342346
line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
343-
assert.equal(line.translateToString(false), 'a a aa ');
344-
assert.equal(line.translateToString(true), 'a a aa');
345-
assert.equal(line.translateToString(false, 0, 5), 'a a a');
346-
assert.equal(line.translateToString(false, 0, 4), 'a a ');
347-
assert.equal(line.translateToString(false, 0, 3), 'a a');
348-
assert.equal(line.translateToString(true, 0, 5), 'a a a');
349-
assert.equal(line.translateToString(true, 0, 4), 'a a ');
350-
assert.equal(line.translateToString(true, 0, 3), 'a a');
347+
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
348+
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
349+
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa');
350+
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5]);
351+
for (const trimRight of [true, false]) {
352+
assert.equal(line.translateToString(trimRight, 0, 5, columns), 'a a a');
353+
assert.deepEqual(columns, [0, 1, 2, 3, 4]);
354+
assert.equal(line.translateToString(trimRight, 0, 4, columns), 'a a ');
355+
assert.deepEqual(columns, [0, 1, 2, 3]);
356+
assert.equal(line.translateToString(trimRight, 0, 3, columns), 'a a');
357+
assert.deepEqual(columns, [0, 1, 2]);
358+
}
351359

352360
});
353361
it('surrogate', function(): void {
362+
const columns: number[] = [];
354363
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
355364
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
356365
line.setCell(2, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
357366
line.setCell(4, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
358367
line.setCell(5, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
359-
assert.equal(line.translateToString(false), 'a 𝄞 𝄞𝄞 ');
360-
assert.equal(line.translateToString(true), 'a 𝄞 𝄞𝄞');
361-
assert.equal(line.translateToString(false, 0, 5), 'a 𝄞 𝄞');
362-
assert.equal(line.translateToString(false, 0, 4), 'a 𝄞 ');
363-
assert.equal(line.translateToString(false, 0, 3), 'a 𝄞');
364-
assert.equal(line.translateToString(true, 0, 5), 'a 𝄞 𝄞');
365-
assert.equal(line.translateToString(true, 0, 4), 'a 𝄞 ');
366-
assert.equal(line.translateToString(true, 0, 3), 'a 𝄞');
368+
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 𝄞 𝄞𝄞 ');
369+
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9]);
370+
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 𝄞 𝄞𝄞');
371+
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5]);
372+
for (const trimRight of [true, false]) {
373+
assert.equal(line.translateToString(trimRight, 0, 5, columns), 'a 𝄞 𝄞');
374+
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4]);
375+
assert.equal(line.translateToString(trimRight, 0, 4, columns), 'a 𝄞 ');
376+
assert.deepEqual(columns, [0, 1, 2, 2, 3]);
377+
assert.equal(line.translateToString(trimRight, 0, 3, columns), 'a 𝄞');
378+
assert.deepEqual(columns, [0, 1, 2, 2]);
379+
}
367380
});
368381
it('combining', function(): void {
382+
const columns: number[] = [];
369383
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
370384
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
371385
line.setCell(2, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
372386
line.setCell(4, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
373387
line.setCell(5, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
374-
assert.equal(line.translateToString(false), 'a e\u0301 e\u0301e\u0301 ');
375-
assert.equal(line.translateToString(true), 'a e\u0301 e\u0301e\u0301');
376-
assert.equal(line.translateToString(false, 0, 5), 'a e\u0301 e\u0301');
377-
assert.equal(line.translateToString(false, 0, 4), 'a e\u0301 ');
378-
assert.equal(line.translateToString(false, 0, 3), 'a e\u0301');
379-
assert.equal(line.translateToString(true, 0, 5), 'a e\u0301 e\u0301');
380-
assert.equal(line.translateToString(true, 0, 4), 'a e\u0301 ');
381-
assert.equal(line.translateToString(true, 0, 3), 'a e\u0301');
388+
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301 ');
389+
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9]);
390+
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301');
391+
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5]);
392+
for (const trimRight of [true, false]) {
393+
assert.equal(line.translateToString(trimRight, 0, 5, columns), 'a e\u0301 e\u0301');
394+
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4]);
395+
assert.equal(line.translateToString(trimRight, 0, 4, columns), 'a e\u0301 ');
396+
assert.deepEqual(columns, [0, 1, 2, 2, 3]);
397+
assert.equal(line.translateToString(trimRight, 0, 3, columns), 'a e\u0301');
398+
assert.deepEqual(columns, [0, 1, 2, 2]);
399+
}
382400
});
383401
it('fullwidth', function(): void {
402+
const columns: number[] = [];
384403
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
385404
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
386405
line.setCell(2, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
@@ -389,43 +408,55 @@ describe('BufferLine', function(): void {
389408
line.setCell(6, CellData.fromCharData([0, '', 0, 0]));
390409
line.setCell(7, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
391410
line.setCell(8, CellData.fromCharData([0, '', 0, 0]));
392-
assert.equal(line.translateToString(false), 'a 1 11 ');
393-
assert.equal(line.translateToString(true), 'a 1 11');
394-
assert.equal(line.translateToString(false, 0, 7), 'a 1 1');
395-
assert.equal(line.translateToString(false, 0, 6), 'a 1 1');
396-
assert.equal(line.translateToString(false, 0, 5), 'a 1 ');
397-
assert.equal(line.translateToString(false, 0, 4), 'a 1');
398-
assert.equal(line.translateToString(false, 0, 3), 'a 1');
399-
assert.equal(line.translateToString(false, 0, 2), 'a ');
400-
assert.equal(line.translateToString(true, 0, 7), 'a 1 1');
401-
assert.equal(line.translateToString(true, 0, 6), 'a 1 1');
402-
assert.equal(line.translateToString(true, 0, 5), 'a 1 ');
403-
assert.equal(line.translateToString(true, 0, 4), 'a 1');
404-
assert.equal(line.translateToString(true, 0, 3), 'a 1');
405-
assert.equal(line.translateToString(true, 0, 2), 'a ');
411+
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 1 11 ');
412+
assert.deepEqual(columns, [0, 1, 2, 4, 5, 7, 9]);
413+
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 1 11');
414+
assert.deepEqual(columns, [0, 1, 2, 4, 5, 7]);
415+
for (const trimRight of [true, false]) {
416+
assert.equal(line.translateToString(trimRight, 0, 7, columns), 'a 1 1');
417+
assert.deepEqual(columns, [0, 1, 2, 4, 5]);
418+
assert.equal(line.translateToString(trimRight, 0, 6, columns), 'a 1 1');
419+
assert.deepEqual(columns, [0, 1, 2, 4, 5]);
420+
assert.equal(line.translateToString(trimRight, 0, 5, columns), 'a 1 ');
421+
assert.deepEqual(columns, [0, 1, 2, 4]);
422+
assert.equal(line.translateToString(trimRight, 0, 4, columns), 'a 1');
423+
assert.deepEqual(columns, [0, 1, 2]);
424+
assert.equal(line.translateToString(trimRight, 0, 3, columns), 'a 1');
425+
assert.deepEqual(columns, [0, 1, 2]);
426+
assert.equal(line.translateToString(trimRight, 0, 2, columns), 'a ');
427+
assert.deepEqual(columns, [0, 1]);
428+
}
406429
});
407430
it('space at end', function(): void {
431+
const columns: number[] = [];
408432
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
409433
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
410434
line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
411435
line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
412436
line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
413437
line.setCell(6, CellData.fromCharData([1, ' ', 1, ' '.charCodeAt(0)]));
414-
assert.equal(line.translateToString(false), 'a a aa ');
415-
assert.equal(line.translateToString(true), 'a a aa ');
438+
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
439+
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
440+
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa ');
441+
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6]);
416442
});
417443
it('should always return some sane value', function(): void {
444+
const columns: number[] = [];
418445
// sanity check - broken line with invalid out of bound null width cells
419446
// this can atm happen with deleting/inserting chars in inputhandler by "breaking"
420447
// fullwidth pairs --> needs to be fixed after settling BufferLine impl
421448
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
422-
assert.equal(line.translateToString(false), ' ');
423-
assert.equal(line.translateToString(true), '');
449+
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
450+
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
451+
assert.equal(line.translateToString(true, undefined, undefined, columns), '');
452+
assert.deepEqual(columns, []);
424453
});
425454
it('should work with endCol=0', () => {
455+
const columns: number[] = [];
426456
const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
427457
line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
428-
assert.equal(line.translateToString(true, 0, 0), '');
458+
assert.equal(line.translateToString(true, 0, 0, columns), '');
459+
assert.deepEqual(columns, []);
429460
});
430461
});
431462
describe('addCharToCell', () => {

0 commit comments

Comments
 (0)