Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

update the a11y terminal buffer dynamically #176413

Merged
merged 26 commits into from
Mar 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/media/xterm.css
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,7 @@
z-index: 2;
position: relative;
}

.xterm.terminal.hide {
visibility: hidden;
}
4 changes: 4 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Dimension } from 'vs/base/browser/dom';
import { Orientation } from 'vs/base/browser/ui/splitview/splitview';
import { AutoOpenBarrier } from 'vs/base/common/async';
import { Color } from 'vs/base/common/color';
import { Event } from 'vs/base/common/event';
import { IDisposable } from 'vs/base/common/lifecycle';
Expand Down Expand Up @@ -601,6 +602,9 @@ export interface ITerminalInstance {
/** A promise that resolves when the terminal's pty/process have been created. */
readonly processReady: Promise<void>;

/** A barrier that opens when the terminal's container is ready */
readonly containerReadyBarrier: AutoOpenBarrier;

/** Whether the terminal's process has child processes (ie. is dirty/busy). */
readonly hasChildProcesses: boolean;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
private _areLinksReady: boolean = false;
private _initialDataEvents: string[] | undefined = [];
private _containerReadyBarrier: AutoOpenBarrier;
get containerReadyBarrier(): AutoOpenBarrier { return this._containerReadyBarrier; }
private _attachBarrier: AutoOpenBarrier;
private _icon: TerminalIcon | undefined;
private _messageTitleDisposable: IDisposable | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ class AccessibleBufferContribution extends DisposableStore implements ITerminalC
}

xtermReady(xterm: IXtermTerminal & { raw: Terminal }): void {
this._accessibleBufferWidget = this._instantiationService.createInstance(AccessibleBufferWidget, xterm, this._instance.capabilities);
this._instance.containerReadyBarrier.wait().then(() => {
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
this._accessibleBufferWidget = this._instantiationService.createInstance(AccessibleBufferWidget, this._instance.instanceId, xterm);
});
}
show(): void {
this._accessibleBufferWidget?.show();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,41 @@
*--------------------------------------------------------------------------------------------*/

import { KeyCode } from 'vs/base/common/keyCodes';
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration';
import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget';
import { StringBuilder } from 'vs/editor/common/core/stringBuilder';
import { ITextModel } from 'vs/editor/common/model';
import { IModelService } from 'vs/editor/common/services/model';
import { LinkDetector } from 'vs/editor/contrib/links/browser/links';
import { localize } from 'vs/nls';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ITerminalCapabilityStore, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
import { TerminalSettingId } from 'vs/platform/terminal/common/terminal';
import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard';
import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions';
import { ITerminalFont } from 'vs/workbench/contrib/terminal/common/terminal';
import { IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal';
import type { Terminal } from 'xterm';

const enum AccessibleBufferConstants {
Scheme = 'terminal-accessible-buffer'
const enum Constants {
Scheme = 'terminal-accessible-buffer',
Active = 'active',
Hide = 'hide'
}

export class AccessibleBufferWidget extends DisposableStore {
public static ID: string = AccessibleBufferConstants.Scheme;
public static ID: string = Constants.Scheme;
private _accessibleBuffer: HTMLElement;
private _bufferEditor: CodeEditorWidget;
private _editorContainer: HTMLElement;
private _commandFinishedDisposable: IDisposable | undefined;
private _refreshSelection: boolean = true;
private _registered: boolean = false;
private _lastContentLength: number = 0;
private _font: ITerminalFont;
private _xtermElement: HTMLElement;

constructor(
private readonly _instanceId: number,
private readonly _xterm: IXtermTerminal & { raw: Terminal },
private readonly _capabilities: ITerminalCapabilityStore,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IModelService private readonly _modelService: IModelService,
@IConfigurationService private readonly _configurationService: IConfigurationService
Expand All @@ -53,6 +49,7 @@ export class AccessibleBufferWidget extends DisposableStore {
contributions: EditorExtensionsRegistry.getSomeEditorContributions([LinkDetector.ID, SelectionClipboardContributionID])
};
this._font = _xterm.getFont();
// this will be defined because we await the container opening
this._xtermElement = _xterm.raw.element!;
const editorOptions: IEditorConstructionOptions = {
...getSimpleEditorOptions(),
Expand All @@ -76,66 +73,79 @@ export class AccessibleBufferWidget extends DisposableStore {
this._accessibleBuffer.setAttribute('role', 'document');
this._accessibleBuffer.ariaRoleDescription = localize('terminal.integrated.accessibleBuffer', 'Terminal buffer');
this._accessibleBuffer.classList.add('accessible-buffer');
const elt = _xterm.raw.element;
if (elt) {
elt.insertAdjacentElement('beforebegin', this._accessibleBuffer);
}
this._editorContainer = document.createElement('div');
this._bufferEditor = this._instantiationService.createInstance(CodeEditorWidget, this._editorContainer, editorOptions, codeEditorWidgetOptions);
this._accessibleBuffer.replaceChildren(this._editorContainer);
this._xtermElement.insertAdjacentElement('beforebegin', this._accessibleBuffer);
this._bufferEditor.layout({ width: this._xtermElement.clientWidth, height: this._xtermElement.clientHeight });
this.add(this._bufferEditor);
this._bufferEditor.onKeyDown((e) => {
// tab moves focus mode will prematurely move focus to the next element before
// xterm can be focused
if (e.keyCode === KeyCode.Escape || e.keyCode === KeyCode.Tab) {
e.stopPropagation();
e.preventDefault();
this._hide();
}
});
this.add(this._configurationService.onDidChangeConfiguration(e => {
if (e.affectedKeys.has(TerminalSettingId.FontFamily)) {
this._font = _xterm.getFont();
}
}));
this.add(this._xterm.raw.onWriteParsed(async () => {
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
if (this._accessibleBuffer.classList.contains(Constants.Active)) {
await this._updateEditor(true);
}
}));
this.add(this._bufferEditor.onDidFocusEditorText(() => this._accessibleBuffer.classList.add('active')));
}

private _hide(): void {
this._accessibleBuffer.classList.remove('active');
this._accessibleBuffer.classList.remove(Constants.Active);
this._xtermElement.classList.remove(Constants.Hide);
this._xterm.raw.focus();
}

async show(): Promise<void> {
const commandDetection = this._capabilities.get(TerminalCapability.CommandDetection);
const fragment = !!commandDetection ? this._getShellIntegrationContent() : this._getAllContent();
const model = await this._getTextModel(URI.from({ scheme: AccessibleBufferConstants.Scheme, fragment }));
if (model) {
this._bufferEditor.setModel(model);
private async _updateModel(insertion?: boolean): Promise<void> {
let model = this._bufferEditor.getModel();
const lineCount = model?.getLineCount() ?? 0;
if (insertion && model && lineCount > this._xterm.raw.rows) {
const lineNumber = lineCount + 1;
model.pushEditOperations(null, [{ range: { startLineNumber: lineNumber, endLineNumber: lineNumber, startColumn: 1, endColumn: 1 }, text: await this._getContent(lineNumber - 1) }], () => []);
} else {
model = await this._getTextModel(URI.from({ scheme: `${Constants.Scheme}-${this._instanceId}`, fragment: await this._getContent() }));
}
this._bufferEditor.onDidFocusEditorText(() => this._accessibleBuffer.classList.add('active'));
if (!this._registered) {
this._bufferEditor.layout({ width: this._xtermElement.clientWidth, height: this._xtermElement.clientHeight });
this._bufferEditor.onKeyDown((e) => {
// tab moves focus mode will prematurely move focus to the next element before
// xterm can be focused
if (e.keyCode === KeyCode.Escape || e.keyCode === KeyCode.Tab) {
e.stopPropagation();
e.preventDefault();
this._hide();
}
});
if (commandDetection) {
this._commandFinishedDisposable = commandDetection.onCommandFinished(() => this._refreshSelection = true);
this.add(this._commandFinishedDisposable);
}
this._registered = true;
if (!model) {
throw new Error('Could not create accessible buffer editor model');
}
this._accessibleBuffer.tabIndex = -1;
this._accessibleBuffer.classList.add('active');
if (this._lastContentLength !== fragment.length || this._refreshSelection) {
let lineNumber = 1;
const lineCount = model?.getLineCount();
if (lineCount && model) {
lineNumber = commandDetection ? lineCount - 1 : lineCount > 2 ? lineCount - 2 : 1;
}
this._bufferEditor.setModel(model);
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
}

private async _updateEditor(insertion?: boolean): Promise<void> {
await this._updateModel(insertion);
const model = this._bufferEditor.getModel();
if (!model) {
return;
}
const lineNumber = model.getLineCount() - 1;
const selection = this._bufferEditor.getSelection();
// If the selection is at the top of the buffer, IE the default when not set, move it to the bottom
if (selection?.startColumn === 1 && selection.endColumn === 1 && selection.startLineNumber === 1 && selection.endLineNumber === 1) {
this._bufferEditor.setSelection({ startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: 1 });
this._bufferEditor.setScrollTop(this._bufferEditor.getScrollHeight());
this._refreshSelection = false;
this._lastContentLength = fragment.length;
}
this._accessibleBuffer.replaceChildren(this._editorContainer);
this._bufferEditor.setScrollTop(this._bufferEditor.getScrollHeight());
this._bufferEditor.focus();
}

async show(): Promise<void> {
await this._updateEditor();
this._accessibleBuffer.tabIndex = -1;
this._bufferEditor.layout({ width: this._xtermElement.clientWidth, height: this._xtermElement.clientHeight });
this._accessibleBuffer.classList.add(Constants.Active);
this._xtermElement.classList.add(Constants.Hide);
}

private async _getTextModel(resource: URI): Promise<ITextModel | null> {
const existing = this._modelService.getModel(resource);
if (existing && !existing.isDisposed()) {
Expand All @@ -145,32 +155,15 @@ export class AccessibleBufferWidget extends DisposableStore {
return this._modelService.createModel(resource.fragment, null, resource, false);
}

private _getShellIntegrationContent(): string {
const commands = this._capabilities.get(TerminalCapability.CommandDetection)?.commands;
const sb = new StringBuilder(10000);
if (!commands?.length) {
return this._getAllContent();
}
for (const command of commands) {
sb.appendString(command.command.replace(new RegExp(' ', 'g'), '\xA0'));
if (command.exitCode !== 0) {
sb.appendString(` exited with code ${command.exitCode}`);
}
sb.appendString('\n');
sb.appendString(command.getOutput()?.replace(new RegExp(' ', 'g'), '\xA0') || '');
}
return sb.build();
}

private _getAllContent(): string {
private _getContent(startLine?: number): string {
const lines: string[] = [];
let currentLine: string = '';
const buffer = this._xterm?.raw.buffer.active;
if (!buffer) {
return '';
}
const end = buffer.length;
for (let i = 0; i < end; i++) {
for (let i = startLine ?? 0; i <= end; i++) {
const line = buffer.getLine(i);
if (!line) {
continue;
Expand Down