Skip to content

Commit 2d08400

Browse files
meganroggealexdima
andauthored
wrapped lines w screen reader (microsoft#163229)
* wip * sync widths * clean up * remove line * get it to work * add wrapping strategy Co-authored-by: Alex Dima <[email protected]> * fix issue * always use the width * Reduce diffs * Fix JSON schema for the WrappingStrategy option * Only turn on advanced wrapping strategy when we know that a screen reader is attached * Make the textarea's width match the wrapping width when wrapping is enabled and a screen reader might be attached * Force wrappingIndent to be none when we know a screen reader is attached to get that the textarea's wrapping points match the editor's wrapping points * remove part of notification message * adjust z-indices, use content left when wrapped * use view model rendering data as content when wrapping is enabled * selection is not working correctly * mostly fix selection problem * broke normal wrapping handling * Revert to using the text model instead of the view model and to using the paged screen reader strategy * Record also the start position for the text in the textarea * Expose EndOfLinePreference to `getValueLengthInRange` and fix its implementation in cases where the text EOL wouldn't match the requested EOL * Fix `getValueLengthInRange` implementation to convert its range from the view coordinate system to the model coordinate system * Record the visible line count for the text in `value` before `selectionStart` in the text area state * Fix up line count such that the VoiceOver thick black box lines up correctly with the rendered text * Make tab characters inside the textarea match the editor's tab width * Turn off wrapping when doing IME and be sure to measure IME text using the same styles as the `<textarea>` Co-authored-by: Alex Dima <[email protected]>
1 parent a7b1c3e commit 2d08400

File tree

6 files changed

+136
-64
lines changed

6 files changed

+136
-64
lines changed

src/vs/editor/browser/controller/textAreaHandler.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
overflow: hidden;
1616
color: transparent;
1717
background-color: transparent;
18+
z-index: -10;
1819
}
1920
/*.monaco-editor .inputarea {
2021
position: fixed !important;
@@ -28,6 +29,7 @@
2829
background: white !important;
2930
line-height: 15px !important;
3031
font-size: 14px !important;
32+
z-index: 10 !important;
3133
}*/
3234
.monaco-editor .inputarea.ime-input {
3335
z-index: 10;

src/vs/editor/browser/controller/textAreaHandler.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { PartFingerprint, PartFingerprints, ViewPart } from 'vs/editor/browser/v
1818
import { LineNumbersOverlay } from 'vs/editor/browser/viewParts/lineNumbers/lineNumbers';
1919
import { Margin } from 'vs/editor/browser/viewParts/margin/margin';
2020
import { RenderLineNumbersType, EditorOption, IComputedEditorOptions, EditorOptions } from 'vs/editor/common/config/editorOptions';
21-
import { BareFontInfo } from 'vs/editor/common/config/fontInfo';
21+
import { FontInfo } from 'vs/editor/common/config/fontInfo';
2222
import { WordCharacterClass, getMapForWordSeparators } from 'vs/editor/common/core/wordCharacterClassifier';
2323
import { Position } from 'vs/editor/common/core/position';
2424
import { Range } from 'vs/editor/common/core/range';
@@ -114,10 +114,12 @@ export class TextAreaHandler extends ViewPart {
114114
private _accessibilitySupport!: AccessibilitySupport;
115115
private _accessibilityPageSize!: number;
116116
private _accessibilityWriteTimer: TimeoutTimer;
117+
private _textAreaWrapping!: boolean;
118+
private _textAreaWidth!: number;
117119
private _contentLeft: number;
118120
private _contentWidth: number;
119121
private _contentHeight: number;
120-
private _fontInfo: BareFontInfo;
122+
private _fontInfo: FontInfo;
121123
private _lineHeight: number;
122124
private _emptySelectionClipboard: boolean;
123125
private _copyWithSyntaxHighlighting: boolean;
@@ -169,7 +171,9 @@ export class TextAreaHandler extends ViewPart {
169171
this.textArea = createFastDomNode(document.createElement('textarea'));
170172
PartFingerprints.write(this.textArea, PartFingerprint.TextArea);
171173
this.textArea.setClassName(`inputarea ${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`);
172-
this.textArea.setAttribute('wrap', 'off');
174+
this.textArea.setAttribute('wrap', this._textAreaWrapping && !this._visibleTextArea ? 'on' : 'off');
175+
const { tabSize } = this._context.viewModel.model.getOptions();
176+
this.textArea.domNode.style.tabSize = `${tabSize * this._fontInfo.spaceWidth}px`;
173177
this.textArea.setAttribute('autocorrect', 'off');
174178
this.textArea.setAttribute('autocapitalize', 'off');
175179
this.textArea.setAttribute('autocomplete', 'off');
@@ -379,7 +383,8 @@ export class TextAreaHandler extends ViewPart {
379383
const visibleBeforeCharCount = Math.min(startModelPosition.column - 1, desiredVisibleBeforeCharCount);
380384
const distanceToModelLineStart = startModelPosition.column - 1 - visibleBeforeCharCount;
381385
const hiddenLineTextBefore = lineTextBeforeSelection.substring(0, lineTextBeforeSelection.length - visibleBeforeCharCount);
382-
const widthOfHiddenTextBefore = measureText(hiddenLineTextBefore, this._fontInfo);
386+
const { tabSize } = this._context.viewModel.model.getOptions();
387+
const widthOfHiddenTextBefore = measureText(hiddenLineTextBefore, this._fontInfo, tabSize);
383388

384389
return { distanceToModelLineStart, widthOfHiddenTextBefore };
385390
})();
@@ -416,6 +421,9 @@ export class TextAreaHandler extends ViewPart {
416421
distanceToModelLineEnd,
417422
);
418423

424+
// We turn off wrapping if the <textarea> becomes visible for composition
425+
this.textArea.setAttribute('wrap', this._textAreaWrapping && !this._visibleTextArea ? 'on' : 'off');
426+
419427
this._visibleTextArea.prepareRender(this._visibleRangeProvider);
420428
this._render();
421429

@@ -438,6 +446,10 @@ export class TextAreaHandler extends ViewPart {
438446
this._register(this._textAreaInput.onCompositionEnd(() => {
439447

440448
this._visibleTextArea = null;
449+
450+
// We turn on wrapping as necessary if the <textarea> hides after composition
451+
this.textArea.setAttribute('wrap', this._textAreaWrapping && !this._visibleTextArea ? 'on' : 'off');
452+
441453
this._render();
442454

443455
this.textArea.setClassName(`inputarea ${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`);
@@ -545,6 +557,21 @@ export class TextAreaHandler extends ViewPart {
545557
} else {
546558
this._accessibilityPageSize = accessibilityPageSize;
547559
}
560+
561+
// When wrapping is enabled and a screen reader might be attached,
562+
// we will size the textarea to match the width used for wrapping points computation (see `domLineBreaksComputer.ts`).
563+
// This is because screen readers will read the text in the textarea and we'd like that the
564+
// wrapping points in the textarea match the wrapping points in the editor.
565+
const layoutInfo = options.get(EditorOption.layoutInfo);
566+
const wrappingColumn = layoutInfo.wrappingColumn;
567+
if (wrappingColumn !== -1 && this._accessibilitySupport !== AccessibilitySupport.Disabled) {
568+
const fontInfo = options.get(EditorOption.fontInfo);
569+
this._textAreaWrapping = true;
570+
this._textAreaWidth = Math.round(wrappingColumn * fontInfo.typicalHalfwidthCharacterWidth);
571+
} else {
572+
this._textAreaWrapping = false;
573+
this._textAreaWidth = (canUseZeroSizeTextarea ? 0 : 1);
574+
}
548575
}
549576

550577
// --- begin event handlers
@@ -561,6 +588,9 @@ export class TextAreaHandler extends ViewPart {
561588
this._lineHeight = options.get(EditorOption.lineHeight);
562589
this._emptySelectionClipboard = options.get(EditorOption.emptySelectionClipboard);
563590
this._copyWithSyntaxHighlighting = options.get(EditorOption.copyWithSyntaxHighlighting);
591+
this.textArea.setAttribute('wrap', this._textAreaWrapping && !this._visibleTextArea ? 'on' : 'off');
592+
const { tabSize } = this._context.viewModel.model.getOptions();
593+
this.textArea.domNode.style.tabSize = `${tabSize * this._fontInfo.spaceWidth}px`;
564594
this.textArea.setAttribute('aria-label', this._getAriaLabel(options));
565595
this.textArea.setAttribute('tabindex', String(options.get(EditorOption.tabIndex)));
566596

@@ -757,25 +787,25 @@ export class TextAreaHandler extends ViewPart {
757787
// We will also make the fontSize and lineHeight the correct dimensions to help with the placement of these pickers
758788
this._doRender({
759789
lastRenderPosition: this._primaryCursorPosition,
760-
top: top,
761-
left: left,
762-
width: (canUseZeroSizeTextarea ? 0 : 1),
790+
top,
791+
left: this._textAreaWrapping ? this._contentLeft : left,
792+
width: this._textAreaWidth,
763793
height: this._lineHeight,
764794
useCover: false
765795
});
766796
// In case the textarea contains a word, we're going to try to align the textarea's cursor
767797
// with our cursor by scrolling the textarea as much as possible
768798
this.textArea.domNode.scrollLeft = this._primaryCursorVisibleRange.left;
769-
const lineCount = this._newlinecount(this.textArea.domNode.value.substr(0, this.textArea.domNode.selectionStart));
799+
const lineCount = this._textAreaInput.textAreaState.newlineCountBeforeSelection ?? this._newlinecount(this.textArea.domNode.value.substr(0, this.textArea.domNode.selectionStart));
770800
this.textArea.domNode.scrollTop = lineCount * this._lineHeight;
771801
return;
772802
}
773803

774804
this._doRender({
775805
lastRenderPosition: this._primaryCursorPosition,
776806
top: top,
777-
left: left,
778-
width: (canUseZeroSizeTextarea ? 0 : 1),
807+
left: this._textAreaWrapping ? this._contentLeft : left,
808+
width: this._textAreaWidth,
779809
height: (canUseZeroSizeTextarea ? 0 : 1),
780810
useCover: false
781811
});
@@ -801,7 +831,7 @@ export class TextAreaHandler extends ViewPart {
801831
lastRenderPosition: null,
802832
top: 0,
803833
left: 0,
804-
width: (canUseZeroSizeTextarea ? 0 : 1),
834+
width: this._textAreaWidth,
805835
height: (canUseZeroSizeTextarea ? 0 : 1),
806836
useCover: true
807837
});
@@ -861,7 +891,7 @@ interface IRenderData {
861891
strikethrough?: boolean;
862892
}
863893

864-
function measureText(text: string, fontInfo: BareFontInfo): number {
894+
function measureText(text: string, fontInfo: FontInfo, tabSize: number): number {
865895
if (text.length === 0) {
866896
return 0;
867897
}
@@ -874,6 +904,7 @@ function measureText(text: string, fontInfo: BareFontInfo): number {
874904
const regularDomNode = document.createElement('span');
875905
applyFontInfo(regularDomNode, fontInfo);
876906
regularDomNode.style.whiteSpace = 'pre'; // just like the textarea
907+
regularDomNode.style.tabSize = `${tabSize * fontInfo.spaceWidth}px`; // just like the textarea
877908
regularDomNode.append(text);
878909
container.appendChild(regularDomNode);
879910

src/vs/editor/browser/controller/textAreaInput.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@ export class TextAreaInput extends Disposable {
193193
private readonly _asyncFocusGainWriteScreenReaderContent: RunOnceScheduler;
194194

195195
private _textAreaState: TextAreaState;
196+
197+
public get textAreaState(): TextAreaState {
198+
return this._textAreaState;
199+
}
200+
196201
private _selectionChangeListener: IDisposable | null;
197202

198203
private _hasFocus: boolean;

src/vs/editor/common/config/editorOptions.ts

Lines changed: 83 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2326,7 +2326,6 @@ export class EditorLayoutInfoComputer extends ComputedEditorOption<EditorOption.
23262326
const wordWrap = (wordWrapOverride1 === 'inherit' ? options.get(EditorOption.wordWrap) : wordWrapOverride1);
23272327

23282328
const wordWrapColumn = options.get(EditorOption.wordWrapColumn);
2329-
const accessibilitySupport = options.get(EditorOption.accessibilitySupport);
23302329
const isDominatedByLongLines = env.isDominatedByLongLines;
23312330

23322331
const showGlyphMargin = options.get(EditorOption.glyphMargin);
@@ -2378,20 +2377,14 @@ export class EditorLayoutInfoComputer extends ComputedEditorOption<EditorOption.
23782377
let isViewportWrapping = false;
23792378
let wrappingColumn = -1;
23802379

2381-
if (accessibilitySupport !== AccessibilitySupport.Enabled) {
2382-
// See https://github.com/microsoft/vscode/issues/27766
2383-
// Never enable wrapping when a screen reader is attached
2384-
// because arrow down etc. will not move the cursor in the way
2385-
// a screen reader expects.
2386-
if (wordWrapOverride1 === 'inherit' && isDominatedByLongLines) {
2387-
// Force viewport width wrapping if model is dominated by long lines
2388-
isWordWrapMinified = true;
2389-
isViewportWrapping = true;
2390-
} else if (wordWrap === 'on' || wordWrap === 'bounded') {
2391-
isViewportWrapping = true;
2392-
} else if (wordWrap === 'wordWrapColumn') {
2393-
wrappingColumn = wordWrapColumn;
2394-
}
2380+
if (wordWrapOverride1 === 'inherit' && isDominatedByLongLines) {
2381+
// Force viewport width wrapping if model is dominated by long lines
2382+
isWordWrapMinified = true;
2383+
isViewportWrapping = true;
2384+
} else if (wordWrap === 'on' || wordWrap === 'bounded') {
2385+
isViewportWrapping = true;
2386+
} else if (wordWrap === 'wordWrapColumn') {
2387+
wrappingColumn = wordWrapColumn;
23952388
}
23962389

23972390
const minimapLayout = EditorLayoutInfoComputer._computeMinimapLayout({
@@ -2469,6 +2462,42 @@ export class EditorLayoutInfoComputer extends ComputedEditorOption<EditorOption.
24692462

24702463
//#endregion
24712464

2465+
//#region WrappingStrategy
2466+
class WrappingStrategy extends BaseEditorOption<EditorOption.wrappingStrategy, 'simple' | 'advanced', 'simple' | 'advanced'> {
2467+
2468+
constructor() {
2469+
super(EditorOption.wrappingStrategy, 'wrappingStrategy', 'simple',
2470+
{
2471+
'editor.wrappingStrategy': {
2472+
enumDescriptions: [
2473+
nls.localize('wrappingStrategy.simple', "Assumes that all characters are of the same width. This is a fast algorithm that works correctly for monospace fonts and certain scripts (like Latin characters) where glyphs are of equal width."),
2474+
nls.localize('wrappingStrategy.advanced', "Delegates wrapping points computation to the browser. This is a slow algorithm, that might cause freezes for large files, but it works correctly in all cases.")
2475+
],
2476+
type: 'string',
2477+
enum: ['simple', 'advanced'],
2478+
default: 'simple',
2479+
description: nls.localize('wrappingStrategy', "Controls the algorithm that computes wrapping points. Note that when in accessibility mode, advanced will be used for the best experience.")
2480+
}
2481+
}
2482+
);
2483+
}
2484+
2485+
public validate(input: any): 'simple' | 'advanced' {
2486+
return stringSet<'simple' | 'advanced'>(input, 'simple', ['simple', 'advanced']);
2487+
}
2488+
2489+
public override compute(env: IEnvironmentalOptions, options: IComputedEditorOptions, value: 'simple' | 'advanced'): 'simple' | 'advanced' {
2490+
const accessibilitySupport = options.get(EditorOption.accessibilitySupport);
2491+
if (accessibilitySupport === AccessibilitySupport.Enabled) {
2492+
// if we know for a fact that a screen reader is attached, we switch our strategy to advanced to
2493+
// help that the editor's wrapping points match the textarea's wrapping points
2494+
return 'advanced';
2495+
}
2496+
return value;
2497+
}
2498+
}
2499+
//#endregion
2500+
24722501
//#region lightbulb
24732502

24742503
/**
@@ -4426,12 +4455,42 @@ export const enum WrappingIndent {
44264455
DeepIndent = 3
44274456
}
44284457

4429-
function _wrappingIndentFromString(wrappingIndent: 'none' | 'same' | 'indent' | 'deepIndent'): WrappingIndent {
4430-
switch (wrappingIndent) {
4431-
case 'none': return WrappingIndent.None;
4432-
case 'same': return WrappingIndent.Same;
4433-
case 'indent': return WrappingIndent.Indent;
4434-
case 'deepIndent': return WrappingIndent.DeepIndent;
4458+
class WrappingIndentOption extends BaseEditorOption<EditorOption.wrappingIndent, 'none' | 'same' | 'indent' | 'deepIndent', WrappingIndent> {
4459+
4460+
constructor() {
4461+
super(EditorOption.wrappingIndent, 'wrappingIndent', WrappingIndent.Same,
4462+
{
4463+
'editor.wrappingIndent': {
4464+
enumDescriptions: [
4465+
nls.localize('wrappingIndent.none', "No indentation. Wrapped lines begin at column 1."),
4466+
nls.localize('wrappingIndent.same', "Wrapped lines get the same indentation as the parent."),
4467+
nls.localize('wrappingIndent.indent', "Wrapped lines get +1 indentation toward the parent."),
4468+
nls.localize('wrappingIndent.deepIndent', "Wrapped lines get +2 indentation toward the parent."),
4469+
],
4470+
description: nls.localize('wrappingIndent', "Controls the indentation of wrapped lines."),
4471+
}
4472+
}
4473+
);
4474+
}
4475+
4476+
public validate(input: any): WrappingIndent {
4477+
switch (input) {
4478+
case 'none': return WrappingIndent.None;
4479+
case 'same': return WrappingIndent.Same;
4480+
case 'indent': return WrappingIndent.Indent;
4481+
case 'deepIndent': return WrappingIndent.DeepIndent;
4482+
}
4483+
return WrappingIndent.Same;
4484+
}
4485+
4486+
public override compute(env: IEnvironmentalOptions, options: IComputedEditorOptions, value: WrappingIndent): WrappingIndent {
4487+
const accessibilitySupport = options.get(EditorOption.accessibilitySupport);
4488+
if (accessibilitySupport === AccessibilitySupport.Enabled) {
4489+
// if we know for a fact that a screen reader is attached, we use no indent wrapping to
4490+
// help that the editor's wrapping points match the textarea's wrapping points
4491+
return WrappingIndent.None;
4492+
}
4493+
return value;
44354494
}
44364495
}
44374496

@@ -5345,40 +5404,15 @@ export const EditorOptions = {
53455404
'inherit' as 'off' | 'on' | 'inherit',
53465405
['off', 'on', 'inherit'] as const
53475406
)),
5348-
wrappingIndent: register(new EditorEnumOption(
5349-
EditorOption.wrappingIndent, 'wrappingIndent',
5350-
WrappingIndent.Same, 'same',
5351-
['none', 'same', 'indent', 'deepIndent'],
5352-
_wrappingIndentFromString,
5353-
{
5354-
enumDescriptions: [
5355-
nls.localize('wrappingIndent.none', "No indentation. Wrapped lines begin at column 1."),
5356-
nls.localize('wrappingIndent.same', "Wrapped lines get the same indentation as the parent."),
5357-
nls.localize('wrappingIndent.indent', "Wrapped lines get +1 indentation toward the parent."),
5358-
nls.localize('wrappingIndent.deepIndent', "Wrapped lines get +2 indentation toward the parent."),
5359-
],
5360-
description: nls.localize('wrappingIndent', "Controls the indentation of wrapped lines."),
5361-
}
5362-
)),
5363-
wrappingStrategy: register(new EditorStringEnumOption(
5364-
EditorOption.wrappingStrategy, 'wrappingStrategy',
5365-
'simple' as 'simple' | 'advanced',
5366-
['simple', 'advanced'] as const,
5367-
{
5368-
enumDescriptions: [
5369-
nls.localize('wrappingStrategy.simple', "Assumes that all characters are of the same width. This is a fast algorithm that works correctly for monospace fonts and certain scripts (like Latin characters) where glyphs are of equal width."),
5370-
nls.localize('wrappingStrategy.advanced', "Delegates wrapping points computation to the browser. This is a slow algorithm, that might cause freezes for large files, but it works correctly in all cases.")
5371-
],
5372-
description: nls.localize('wrappingStrategy', "Controls the algorithm that computes wrapping points.")
5373-
}
5374-
)),
53755407

53765408
// Leave these at the end (because they have dependencies!)
53775409
editorClassName: register(new EditorClassName()),
53785410
pixelRatio: register(new EditorPixelRatio()),
53795411
tabFocusMode: register(new EditorTabFocusMode()),
53805412
layoutInfo: register(new EditorLayoutInfoComputer()),
5381-
wrappingInfo: register(new EditorWrappingInfoComputer())
5413+
wrappingInfo: register(new EditorWrappingInfoComputer()),
5414+
wrappingIndent: register(new WrappingIndentOption()),
5415+
wrappingStrategy: register(new WrappingStrategy())
53825416
};
53835417

53845418
type EditorOptionsType = typeof EditorOptions;

src/vs/monaco.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4683,13 +4683,13 @@ declare namespace monaco.editor {
46834683
wordWrapColumn: IEditorOption<EditorOption.wordWrapColumn, number>;
46844684
wordWrapOverride1: IEditorOption<EditorOption.wordWrapOverride1, 'on' | 'off' | 'inherit'>;
46854685
wordWrapOverride2: IEditorOption<EditorOption.wordWrapOverride2, 'on' | 'off' | 'inherit'>;
4686-
wrappingIndent: IEditorOption<EditorOption.wrappingIndent, WrappingIndent>;
4687-
wrappingStrategy: IEditorOption<EditorOption.wrappingStrategy, 'simple' | 'advanced'>;
46884686
editorClassName: IEditorOption<EditorOption.editorClassName, string>;
46894687
pixelRatio: IEditorOption<EditorOption.pixelRatio, number>;
46904688
tabFocusMode: IEditorOption<EditorOption.tabFocusMode, boolean>;
46914689
layoutInfo: IEditorOption<EditorOption.layoutInfo, EditorLayoutInfo>;
46924690
wrappingInfo: IEditorOption<EditorOption.wrappingInfo, EditorWrappingInfo>;
4691+
wrappingIndent: IEditorOption<EditorOption.wrappingIndent, WrappingIndent>;
4692+
wrappingStrategy: IEditorOption<EditorOption.wrappingStrategy, 'simple' | 'advanced'>;
46934693
};
46944694

46954695
type EditorOptionsType = typeof EditorOptions;

src/vs/workbench/browser/parts/editor/editorStatus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution {
349349
if (!this.screenReaderNotification) {
350350
this.screenReaderNotification = this.notificationService.prompt(
351351
Severity.Info,
352-
localize('screenReaderDetectedExplanation.question', "Are you using a screen reader to operate VS Code? (word wrap is disabled when using a screen reader)"),
352+
localize('screenReaderDetectedExplanation.question', "Are you using a screen reader to operate VS Code?"),
353353
[{
354354
label: localize('screenReaderDetectedExplanation.answerYes', "Yes"),
355355
run: () => {

0 commit comments

Comments
 (0)