Skip to content

Commit 7b6f602

Browse files
committed
Add accessible screen reader mode
This adds the `EnableScreenReaderMode` command-line switch which defaults to true if an active screen reader is detected. In screen reader mode, the existing `ForceRender()` function is replaced by `RenderForScreenReader()` which uses a differential rendering approach to minimize extraneous output to the terminal, allowing the use of screen readers better than ever before. The differential rendering relies on calculating the common prefix of the `buffer` and `previousRender` strings. Nearly all necessary changes are consolidated in the new rendering function. Features known not to be supported: * Colors: as this necessitates redrawing to insert color sequences after the input is received and the AST parsed. * Inline predictions: as this by definition changes the suffix and thus requires endless redrawing. * List view predictions: since the render implementation never calls into the prediction engine, this is not available either. * Menu completion: well, it "works" since it's not disabled and does its own rendering, but no effort has been made to improve `DrawMenu()`, so it's not accessible (and I'm not sure it could be given our current constraints). Features known to be partially supported: * Text selection: mark and select commands work as intended, but provide no visual indication. * Multiple lines: as in newlines work fine, but there is no continuation prompt. * Visually wrapped lines: editing above a wrapped line redraws all subsequent lines and hence is noisy. * Status prompt based commands: what-is-key, digit-argument, and most notably, forward/backward incremental history search all render a "status prompt" on the line below the user's input buffer. This _is_ supported; however, it can be noisy since it necessarily has to render the whole buffer when the input buffer changes, including the status prompt (and search text). But what it's reading is almost always going to be relevant. Everything else should generally work, even Vi mode, and the tests pass. That said, this isn't perfect, and moreover the approach specifically doesn't attempt to enable things from the ground up. There may be features that are available but turn out not to be accessible (like `MenuComplete`) and I believe they should be left as-is. Specifically tested with NVDA on Windows and VoiceOver on macOS within VS Code's integrated terminal, with shell integration loaded, and Code's screen reader optimizations enabled.
1 parent 7a5826b commit 7b6f602

File tree

4 files changed

+222
-42
lines changed

4 files changed

+222
-42
lines changed

PSReadLine/BasicEditing.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public static void CancelLine(ConsoleKeyInfo? key = null, object arg = null)
8686
_singleton._current = _singleton._buffer.Length;
8787

8888
using var _ = _singleton._prediction.DisableScoped();
89-
_singleton.ForceRender();
89+
_singleton.Render(force: true);
9090

9191
_singleton._console.Write("\x1b[91m^C\x1b[0m");
9292

@@ -335,7 +335,7 @@ private bool AcceptLineImpl(bool validate)
335335

336336
if (renderNeeded)
337337
{
338-
ForceRender();
338+
Render(force: true);
339339
}
340340

341341
// Only run validation if we haven't before. If we have and status line shows an error,

PSReadLine/Cmdlets.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using System.Runtime.InteropServices;
1616
using System.Threading;
1717
using Microsoft.PowerShell.PSReadLine;
18+
using Microsoft.PowerShell.Internal;
1819
using AllowNull = System.Management.Automation.AllowNullAttribute;
1920

2021
namespace Microsoft.PowerShell
@@ -150,11 +151,6 @@ public class PSConsoleReadLineOptions
150151

151152
public const HistorySaveStyle DefaultHistorySaveStyle = HistorySaveStyle.SaveIncrementally;
152153

153-
/// <summary>
154-
/// The predictive suggestion feature is disabled by default.
155-
/// </summary>
156-
public const PredictionSource DefaultPredictionSource = PredictionSource.None;
157-
158154
public const PredictionViewStyle DefaultPredictionViewStyle = PredictionViewStyle.InlineView;
159155

160156
/// <summary>
@@ -201,6 +197,7 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole)
201197
{
202198
ResetColors();
203199
EditMode = DefaultEditMode;
200+
ScreenReaderModeEnabled = Accessibility.IsScreenReaderActive();
204201
ContinuationPrompt = DefaultContinuationPrompt;
205202
ContinuationPromptColor = Console.ForegroundColor;
206203
ExtraPromptLineCount = DefaultExtraPromptLineCount;
@@ -533,6 +530,8 @@ public object ListPredictionTooltipColor
533530

534531
public bool TerminateOrphanedConsoleApps { get; set; }
535532

533+
public bool ScreenReaderModeEnabled { get; set; }
534+
536535
internal string _defaultTokenColor;
537536
internal string _commentColor;
538537
internal string _keywordColor;
@@ -847,6 +846,14 @@ public SwitchParameter TerminateOrphanedConsoleApps
847846
}
848847
internal SwitchParameter? _terminateOrphanedConsoleApps;
849848

849+
[Parameter]
850+
public SwitchParameter EnableScreenReaderMode
851+
{
852+
get => _enableScreenReaderMode.GetValueOrDefault();
853+
set => _enableScreenReaderMode = value;
854+
}
855+
internal SwitchParameter? _enableScreenReaderMode;
856+
850857
[ExcludeFromCodeCoverage]
851858
protected override void EndProcessing()
852859
{

PSReadLine/Options.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ private void SetOptionsInternal(SetPSReadLineOption options)
185185
nameof(Options.TerminateOrphanedConsoleApps)));
186186
}
187187
}
188+
if (options._enableScreenReaderMode.HasValue)
189+
{
190+
Options.ScreenReaderModeEnabled = options.EnableScreenReaderMode;
191+
}
188192
}
189193

190194
private void SetKeyHandlerInternal(string[] keys, Action<ConsoleKeyInfo?, object> handler, string briefDescription, string longDescription, ScriptBlock scriptBlock)

PSReadLine/Render.cs

Lines changed: 204 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -218,36 +218,197 @@ private void RenderWithPredictionQueryPaused()
218218
Render();
219219
}
220220

221-
private void Render()
221+
private void Render(bool force = false)
222222
{
223-
// If there are a bunch of keys queued up, skip rendering if we've rendered very recently.
224-
long elapsedMs = _lastRenderTime.ElapsedMilliseconds;
225-
if (_queuedKeys.Count > 10 && elapsedMs < 50)
226-
{
227-
// We won't render, but most likely the tokens will be different, so make
228-
// sure we don't use old tokens, also allow garbage to get collected.
229-
_tokens = null;
230-
_ast = null;
231-
_parseErrors = null;
232-
_waitingToRender = true;
233-
return;
223+
if (!force)
224+
{
225+
// If there are a bunch of keys queued up, skip rendering if we've rendered very recently.
226+
long elapsedMs = _lastRenderTime.ElapsedMilliseconds;
227+
if (_queuedKeys.Count > 10 && elapsedMs < 50)
228+
{
229+
// We won't render, but most likely the tokens will be different, so make
230+
// sure we don't use old tokens, also allow garbage to get collected.
231+
_tokens = null;
232+
_ast = null;
233+
_parseErrors = null;
234+
_waitingToRender = true;
235+
return;
236+
}
237+
238+
// If we've rendered very recently, skip the terminal window resizing check as it's unlikely
239+
// to happen in such a short time interval.
240+
// We try to avoid unnecessary resizing check because it requires getting the cursor position
241+
// which would force a network round trip in an environment where front-end xtermjs talking to
242+
// a server-side PTY via websocket. Without querying for cursor position, content written on
243+
// the server side could be buffered, which is much more performant.
244+
// See the following 2 GitHub issues for more context:
245+
// - https://github.com/PowerShell/PSReadLine/issues/3879#issuecomment-2573996070
246+
// - https://github.com/PowerShell/PowerShell/issues/24696
247+
if (elapsedMs < 50)
248+
{
249+
_handlePotentialResizing = false;
250+
}
251+
}
252+
253+
// Use simplified rendering for screen readers
254+
if (Options.ScreenReaderModeEnabled)
255+
{
256+
RenderForScreenReader();
257+
}
258+
else
259+
{
260+
ForceRender();
261+
}
262+
}
263+
264+
private void RenderForScreenReader()
265+
{
266+
int bufferWidth = _console.BufferWidth;
267+
int bufferHeight = _console.BufferHeight;
268+
269+
static int FindCommonPrefixLength(string leftStr, string rightStr)
270+
{
271+
if (string.IsNullOrEmpty(leftStr) || string.IsNullOrEmpty(rightStr))
272+
{
273+
return 0;
274+
}
275+
276+
int i = 0;
277+
int minLength = Math.Min(leftStr.Length, rightStr.Length);
278+
279+
while (i < minLength && leftStr[i] == rightStr[i])
280+
{
281+
i++;
282+
}
283+
284+
return i;
285+
}
286+
287+
// For screen readers, we are just comparing the previous and current buffer text
288+
// (without colors) and only writing the differences.
289+
//
290+
// Note that we don't call QueryForSuggestion() which is the only
291+
// entry into the prediction logic, so while it could be enabled, it
292+
// won't do anything in this rendering implementation.
293+
string parsedInput = ParseInput();
294+
StringBuilder buffer = new(parsedInput);
295+
296+
// Really simple handling of a status line: append it!
297+
if (!string.IsNullOrEmpty(_statusLinePrompt))
298+
{
299+
buffer.Append("\n");
300+
buffer.Append(_statusLinePrompt);
301+
buffer.Append(_statusBuffer);
302+
}
303+
304+
string currentBuffer = buffer.ToString();
305+
string previousBuffer = _previousRender.lines[0].Line;
306+
307+
// In case the buffer was resized.
308+
RecomputeInitialCoords(isTextBufferUnchanged: false);
309+
310+
// Make cursor invisible while we're rendering.
311+
_console.CursorVisible = false;
312+
313+
if (currentBuffer == previousBuffer)
314+
{
315+
// No-op, such as when selecting text or otherwise re-entering.
316+
}
317+
else if (previousBuffer.Length == 0)
318+
{
319+
// Previous buffer was empty so we just render the current buffer,
320+
// and we don't need to move the cursor.
321+
_console.Write(currentBuffer);
234322
}
323+
else
324+
{
325+
// Calculate what to render and where to start the rendering.
326+
int commonPrefixLength = FindCommonPrefixLength(previousBuffer, currentBuffer);
327+
328+
// If we're scrolling through history we always want to re-render.
329+
// Writing only the diff in this scenario is a weird UX.
330+
if (commonPrefixLength > 0 && _anyHistoryCommandCount == 0)
331+
{
332+
// We need to differentially render, possibly with a partial rewrite.
333+
if (commonPrefixLength != previousBuffer.Length)
334+
{
335+
// The buffers share a common prefix but the previous buffer has additional content.
336+
// Move cursor to where the difference starts and clear so we can rewrite.
337+
var diffPoint = ConvertOffsetToPoint(commonPrefixLength, buffer);
338+
_console.SetCursorPosition(diffPoint.X, diffPoint.Y);
339+
_console.Write("\x1b[0J");
340+
} // Otherwise the previous buffer is a complete prefix and we just write.
341+
342+
// TODO: There is a rare edge case where the common prefix can be incorrectly
343+
// calculated because the incoming replacement text matches the text at the current
344+
// cursor position. Unfortunately we don't have a solution yet. For example:
345+
//
346+
// 1. Previous line is "abcdef" and cursor is at (before) the letter "d"
347+
// 2. Paste "defghi" so currentBuffer is "abcdefghidef"
348+
// 3. The diff is "ghidef" and because commonPrefixLength == previousBuffer.Length
349+
// 4. The terminal will incorrectly display "abcghidef" instead of "abcdefghidef"
350+
351+
// Finally, write the diff.
352+
var diffData = currentBuffer.Substring(commonPrefixLength);
353+
_console.Write(diffData);
354+
}
355+
else
356+
{
357+
// The buffers are completely different so we need to rewrite from the start.
358+
_console.SetCursorPosition(_initialX, _initialY);
359+
_console.Write("\x1b[0J");
360+
_console.Write(currentBuffer);
361+
}
362+
}
363+
364+
// If we had to wrap to render everything, update _initialY
365+
var endPoint = ConvertOffsetToPoint(currentBuffer.Length, buffer);
366+
if (endPoint.Y >= bufferHeight)
367+
{
368+
// We had to scroll to render everything, update _initialY.
369+
int offset = 1; // Base case to handle zero-indexing.
370+
if (endPoint.X == 0 && !currentBuffer.EndsWith("\n"))
371+
{
372+
// The line hasn't actually wrapped yet because we have exactly filled the line.
373+
offset -= 1;
374+
}
375+
int scrolledLines = endPoint.Y - bufferHeight + offset;
376+
_initialY -= scrolledLines;
377+
}
378+
379+
// Calculate the coord to place the cursor for the next input.
380+
var point = ConvertOffsetToPoint(_current, buffer);
235381

236-
// If we've rendered very recently, skip the terminal window resizing check as it's unlikely
237-
// to happen in such a short time interval.
238-
// We try to avoid unnecessary resizing check because it requires getting the cursor position
239-
// which would force a network round trip in an environment where front-end xtermjs talking to
240-
// a server-side PTY via websocket. Without querying for cursor position, content written on
241-
// the server side could be buffered, which is much more performant.
242-
// See the following 2 GitHub issues for more context:
243-
// - https://github.com/PowerShell/PSReadLine/issues/3879#issuecomment-2573996070
244-
// - https://github.com/PowerShell/PowerShell/issues/24696
245-
if (elapsedMs < 50)
382+
if (point.Y == bufferHeight)
246383
{
247-
_handlePotentialResizing = false;
384+
// The cursor top exceeds the buffer height and it hasn't already wrapped,
385+
// (because we have exactly filled the line) so we need to scroll up the buffer by 1 line.
386+
if (point.X == 0)
387+
{
388+
_console.Write("\n");
389+
}
390+
391+
// Adjust the initial cursor position and the to-be-set cursor position
392+
// after scrolling up the buffer.
393+
_initialY -= 1;
394+
point.Y -= 1;
248395
}
249396

250-
ForceRender();
397+
_console.SetCursorPosition(point.X, point.Y);
398+
_console.CursorVisible = true;
399+
400+
// Preserve the current render data.
401+
var renderData = new RenderData
402+
{
403+
lines = new RenderedLineData[] { new(currentBuffer, isFirstLogicalLine: true) },
404+
errorPrompt = (_parseErrors != null && _parseErrors.Length > 0) // Not yet used.
405+
};
406+
_previousRender = renderData;
407+
_previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y);
408+
_previousRender.initialY = _initialY;
409+
410+
_lastRenderTime.Restart();
411+
_waitingToRender = false;
251412
}
252413

253414
private void ForceRender()
@@ -261,7 +422,7 @@ private void ForceRender()
261422
// and minimize writing more than necessary on the next render.)
262423

263424
var renderLines = new RenderedLineData[logicalLineCount];
264-
var renderData = new RenderData {lines = renderLines};
425+
var renderData = new RenderData { lines = renderLines };
265426
for (var i = 0; i < logicalLineCount; i++)
266427
{
267428
var line = _consoleBufferLines[i].ToString();
@@ -872,9 +1033,6 @@ void UpdateColorsIfNecessary(string newColor)
8721033
WriteBlankLines(lineCount);
8731034
}
8741035

875-
// Preserve the current render data.
876-
_previousRender = renderData;
877-
8781036
// If we counted pseudo physical lines, deduct them to get the real physical line counts
8791037
// before updating '_initialY'.
8801038
physicalLine -= pseudoPhysicalLineOffset;
@@ -950,6 +1108,8 @@ void UpdateColorsIfNecessary(string newColor)
9501108
_console.SetCursorPosition(point.X, point.Y);
9511109
_console.CursorVisible = true;
9521110

1111+
// Preserve the current render data.
1112+
_previousRender = renderData;
9531113
_previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y);
9541114
_previousRender.initialY = _initialY;
9551115

@@ -1201,17 +1361,23 @@ internal Point EndOfBufferPosition()
12011361
return ConvertOffsetToPoint(_buffer.Length);
12021362
}
12031363

1204-
internal Point ConvertOffsetToPoint(int offset)
1364+
internal Point ConvertOffsetToPoint(int offset, StringBuilder buffer = null)
12051365
{
1366+
// This lets us re-use the logic in the screen reader rendering implementation
1367+
// where the status line is added to the buffer without modifying the local state.
1368+
buffer ??= _buffer;
1369+
12061370
int x = _initialX;
12071371
int y = _initialY;
12081372

12091373
int bufferWidth = _console.BufferWidth;
1210-
var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt);
1374+
var continuationPromptLength = Options.ScreenReaderModeEnabled
1375+
? 0
1376+
: LengthInBufferCells(Options.ContinuationPrompt);
12111377

12121378
for (int i = 0; i < offset; i++)
12131379
{
1214-
char c = _buffer[i];
1380+
char c = buffer[i];
12151381
if (c == '\n')
12161382
{
12171383
y += 1;
@@ -1229,7 +1395,7 @@ internal Point ConvertOffsetToPoint(int offset)
12291395

12301396
// If cursor is at column 0 and the next character is newline, let the next loop
12311397
// iteration increment y.
1232-
if (x != 0 || !(i + 1 < offset && _buffer[i + 1] == '\n'))
1398+
if (x != 0 || !(i + 1 < offset && buffer[i + 1] == '\n'))
12331399
{
12341400
y += 1;
12351401
}
@@ -1238,9 +1404,9 @@ internal Point ConvertOffsetToPoint(int offset)
12381404
}
12391405

12401406
// If next character actually exists, and isn't newline, check if wider than the space left on the current line.
1241-
if (_buffer.Length > offset && _buffer[offset] != '\n')
1407+
if (buffer.Length > offset && buffer[offset] != '\n')
12421408
{
1243-
int size = LengthInBufferCells(_buffer[offset]);
1409+
int size = LengthInBufferCells(buffer[offset]);
12441410
if (x + size > bufferWidth)
12451411
{
12461412
// Character was wider than remaining space, so character, and cursor, appear on next line.
@@ -1259,7 +1425,10 @@ private int ConvertLineAndColumnToOffset(Point point)
12591425
int y = _initialY;
12601426

12611427
int bufferWidth = _console.BufferWidth;
1262-
var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt);
1428+
var continuationPromptLength = Options.ScreenReaderModeEnabled
1429+
? 0
1430+
: LengthInBufferCells(Options.ContinuationPrompt);
1431+
12631432
for (offset = 0; offset < _buffer.Length; offset++)
12641433
{
12651434
// If we are on the correct line, return when we find

0 commit comments

Comments
 (0)