mirror of
https://github.com/PowerShell/PSReadLine
synced 2026-06-08 12:12:13 +00:00
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.
This commit is contained in:
@@ -86,7 +86,7 @@ namespace Microsoft.PowerShell
|
||||
_singleton._current = _singleton._buffer.Length;
|
||||
|
||||
using var _ = _singleton._prediction.DisableScoped();
|
||||
_singleton.ForceRender();
|
||||
_singleton.Render(force: true);
|
||||
|
||||
_singleton._console.Write("\x1b[91m^C\x1b[0m");
|
||||
|
||||
@@ -335,7 +335,7 @@ namespace Microsoft.PowerShell
|
||||
|
||||
if (renderNeeded)
|
||||
{
|
||||
ForceRender();
|
||||
Render(force: true);
|
||||
}
|
||||
|
||||
// Only run validation if we haven't before. If we have and status line shows an error,
|
||||
|
||||
+12
-5
@@ -15,6 +15,7 @@ using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using Microsoft.PowerShell.PSReadLine;
|
||||
using Microsoft.PowerShell.Internal;
|
||||
using AllowNull = System.Management.Automation.AllowNullAttribute;
|
||||
|
||||
namespace Microsoft.PowerShell
|
||||
@@ -150,11 +151,6 @@ namespace Microsoft.PowerShell
|
||||
|
||||
public const HistorySaveStyle DefaultHistorySaveStyle = HistorySaveStyle.SaveIncrementally;
|
||||
|
||||
/// <summary>
|
||||
/// The predictive suggestion feature is disabled by default.
|
||||
/// </summary>
|
||||
public const PredictionSource DefaultPredictionSource = PredictionSource.None;
|
||||
|
||||
public const PredictionViewStyle DefaultPredictionViewStyle = PredictionViewStyle.InlineView;
|
||||
|
||||
/// <summary>
|
||||
@@ -201,6 +197,7 @@ namespace Microsoft.PowerShell
|
||||
{
|
||||
ResetColors();
|
||||
EditMode = DefaultEditMode;
|
||||
ScreenReaderModeEnabled = Accessibility.IsScreenReaderActive();
|
||||
ContinuationPrompt = DefaultContinuationPrompt;
|
||||
ContinuationPromptColor = Console.ForegroundColor;
|
||||
ExtraPromptLineCount = DefaultExtraPromptLineCount;
|
||||
@@ -533,6 +530,8 @@ namespace Microsoft.PowerShell
|
||||
|
||||
public bool TerminateOrphanedConsoleApps { get; set; }
|
||||
|
||||
public bool ScreenReaderModeEnabled { get; set; }
|
||||
|
||||
internal string _defaultTokenColor;
|
||||
internal string _commentColor;
|
||||
internal string _keywordColor;
|
||||
@@ -847,6 +846,14 @@ namespace Microsoft.PowerShell
|
||||
}
|
||||
internal SwitchParameter? _terminateOrphanedConsoleApps;
|
||||
|
||||
[Parameter]
|
||||
public SwitchParameter EnableScreenReaderMode
|
||||
{
|
||||
get => _enableScreenReaderMode.GetValueOrDefault();
|
||||
set => _enableScreenReaderMode = value;
|
||||
}
|
||||
internal SwitchParameter? _enableScreenReaderMode;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
protected override void EndProcessing()
|
||||
{
|
||||
|
||||
@@ -185,6 +185,10 @@ namespace Microsoft.PowerShell
|
||||
nameof(Options.TerminateOrphanedConsoleApps)));
|
||||
}
|
||||
}
|
||||
if (options._enableScreenReaderMode.HasValue)
|
||||
{
|
||||
Options.ScreenReaderModeEnabled = options.EnableScreenReaderMode;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetKeyHandlerInternal(string[] keys, Action<ConsoleKeyInfo?, object> handler, string briefDescription, string longDescription, ScriptBlock scriptBlock)
|
||||
|
||||
+203
-34
@@ -218,36 +218,197 @@ namespace Microsoft.PowerShell
|
||||
Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
private void Render(bool force = false)
|
||||
{
|
||||
// If there are a bunch of keys queued up, skip rendering if we've rendered very recently.
|
||||
long elapsedMs = _lastRenderTime.ElapsedMilliseconds;
|
||||
if (_queuedKeys.Count > 10 && elapsedMs < 50)
|
||||
if (!force)
|
||||
{
|
||||
// We won't render, but most likely the tokens will be different, so make
|
||||
// sure we don't use old tokens, also allow garbage to get collected.
|
||||
_tokens = null;
|
||||
_ast = null;
|
||||
_parseErrors = null;
|
||||
_waitingToRender = true;
|
||||
return;
|
||||
// If there are a bunch of keys queued up, skip rendering if we've rendered very recently.
|
||||
long elapsedMs = _lastRenderTime.ElapsedMilliseconds;
|
||||
if (_queuedKeys.Count > 10 && elapsedMs < 50)
|
||||
{
|
||||
// We won't render, but most likely the tokens will be different, so make
|
||||
// sure we don't use old tokens, also allow garbage to get collected.
|
||||
_tokens = null;
|
||||
_ast = null;
|
||||
_parseErrors = null;
|
||||
_waitingToRender = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we've rendered very recently, skip the terminal window resizing check as it's unlikely
|
||||
// to happen in such a short time interval.
|
||||
// We try to avoid unnecessary resizing check because it requires getting the cursor position
|
||||
// which would force a network round trip in an environment where front-end xtermjs talking to
|
||||
// a server-side PTY via websocket. Without querying for cursor position, content written on
|
||||
// the server side could be buffered, which is much more performant.
|
||||
// See the following 2 GitHub issues for more context:
|
||||
// - https://github.com/PowerShell/PSReadLine/issues/3879#issuecomment-2573996070
|
||||
// - https://github.com/PowerShell/PowerShell/issues/24696
|
||||
if (elapsedMs < 50)
|
||||
{
|
||||
_handlePotentialResizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've rendered very recently, skip the terminal window resizing check as it's unlikely
|
||||
// to happen in such a short time interval.
|
||||
// We try to avoid unnecessary resizing check because it requires getting the cursor position
|
||||
// which would force a network round trip in an environment where front-end xtermjs talking to
|
||||
// a server-side PTY via websocket. Without querying for cursor position, content written on
|
||||
// the server side could be buffered, which is much more performant.
|
||||
// See the following 2 GitHub issues for more context:
|
||||
// - https://github.com/PowerShell/PSReadLine/issues/3879#issuecomment-2573996070
|
||||
// - https://github.com/PowerShell/PowerShell/issues/24696
|
||||
if (elapsedMs < 50)
|
||||
// Use simplified rendering for screen readers
|
||||
if (Options.ScreenReaderModeEnabled)
|
||||
{
|
||||
_handlePotentialResizing = false;
|
||||
RenderForScreenReader();
|
||||
}
|
||||
else
|
||||
{
|
||||
ForceRender();
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderForScreenReader()
|
||||
{
|
||||
int bufferWidth = _console.BufferWidth;
|
||||
int bufferHeight = _console.BufferHeight;
|
||||
|
||||
static int FindCommonPrefixLength(string leftStr, string rightStr)
|
||||
{
|
||||
if (string.IsNullOrEmpty(leftStr) || string.IsNullOrEmpty(rightStr))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
int minLength = Math.Min(leftStr.Length, rightStr.Length);
|
||||
|
||||
while (i < minLength && leftStr[i] == rightStr[i])
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
ForceRender();
|
||||
// For screen readers, we are just comparing the previous and current buffer text
|
||||
// (without colors) and only writing the differences.
|
||||
//
|
||||
// Note that we don't call QueryForSuggestion() which is the only
|
||||
// entry into the prediction logic, so while it could be enabled, it
|
||||
// won't do anything in this rendering implementation.
|
||||
string parsedInput = ParseInput();
|
||||
StringBuilder buffer = new(parsedInput);
|
||||
|
||||
// Really simple handling of a status line: append it!
|
||||
if (!string.IsNullOrEmpty(_statusLinePrompt))
|
||||
{
|
||||
buffer.Append("\n");
|
||||
buffer.Append(_statusLinePrompt);
|
||||
buffer.Append(_statusBuffer);
|
||||
}
|
||||
|
||||
string currentBuffer = buffer.ToString();
|
||||
string previousBuffer = _previousRender.lines[0].Line;
|
||||
|
||||
// In case the buffer was resized.
|
||||
RecomputeInitialCoords(isTextBufferUnchanged: false);
|
||||
|
||||
// Make cursor invisible while we're rendering.
|
||||
_console.CursorVisible = false;
|
||||
|
||||
if (currentBuffer == previousBuffer)
|
||||
{
|
||||
// No-op, such as when selecting text or otherwise re-entering.
|
||||
}
|
||||
else if (previousBuffer.Length == 0)
|
||||
{
|
||||
// Previous buffer was empty so we just render the current buffer,
|
||||
// and we don't need to move the cursor.
|
||||
_console.Write(currentBuffer);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate what to render and where to start the rendering.
|
||||
int commonPrefixLength = FindCommonPrefixLength(previousBuffer, currentBuffer);
|
||||
|
||||
// If we're scrolling through history we always want to re-render.
|
||||
// Writing only the diff in this scenario is a weird UX.
|
||||
if (commonPrefixLength > 0 && _anyHistoryCommandCount == 0)
|
||||
{
|
||||
// We need to differentially render, possibly with a partial rewrite.
|
||||
if (commonPrefixLength != previousBuffer.Length)
|
||||
{
|
||||
// The buffers share a common prefix but the previous buffer has additional content.
|
||||
// Move cursor to where the difference starts and clear so we can rewrite.
|
||||
var diffPoint = ConvertOffsetToPoint(commonPrefixLength, buffer);
|
||||
_console.SetCursorPosition(diffPoint.X, diffPoint.Y);
|
||||
_console.Write("\x1b[0J");
|
||||
} // Otherwise the previous buffer is a complete prefix and we just write.
|
||||
|
||||
// TODO: There is a rare edge case where the common prefix can be incorrectly
|
||||
// calculated because the incoming replacement text matches the text at the current
|
||||
// cursor position. Unfortunately we don't have a solution yet. For example:
|
||||
//
|
||||
// 1. Previous line is "abcdef" and cursor is at (before) the letter "d"
|
||||
// 2. Paste "defghi" so currentBuffer is "abcdefghidef"
|
||||
// 3. The diff is "ghidef" and because commonPrefixLength == previousBuffer.Length
|
||||
// 4. The terminal will incorrectly display "abcghidef" instead of "abcdefghidef"
|
||||
|
||||
// Finally, write the diff.
|
||||
var diffData = currentBuffer.Substring(commonPrefixLength);
|
||||
_console.Write(diffData);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The buffers are completely different so we need to rewrite from the start.
|
||||
_console.SetCursorPosition(_initialX, _initialY);
|
||||
_console.Write("\x1b[0J");
|
||||
_console.Write(currentBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// If we had to wrap to render everything, update _initialY
|
||||
var endPoint = ConvertOffsetToPoint(currentBuffer.Length, buffer);
|
||||
if (endPoint.Y >= bufferHeight)
|
||||
{
|
||||
// We had to scroll to render everything, update _initialY.
|
||||
int offset = 1; // Base case to handle zero-indexing.
|
||||
if (endPoint.X == 0 && !currentBuffer.EndsWith("\n"))
|
||||
{
|
||||
// The line hasn't actually wrapped yet because we have exactly filled the line.
|
||||
offset -= 1;
|
||||
}
|
||||
int scrolledLines = endPoint.Y - bufferHeight + offset;
|
||||
_initialY -= scrolledLines;
|
||||
}
|
||||
|
||||
// Calculate the coord to place the cursor for the next input.
|
||||
var point = ConvertOffsetToPoint(_current, buffer);
|
||||
|
||||
if (point.Y == bufferHeight)
|
||||
{
|
||||
// The cursor top exceeds the buffer height and it hasn't already wrapped,
|
||||
// (because we have exactly filled the line) so we need to scroll up the buffer by 1 line.
|
||||
if (point.X == 0)
|
||||
{
|
||||
_console.Write("\n");
|
||||
}
|
||||
|
||||
// Adjust the initial cursor position and the to-be-set cursor position
|
||||
// after scrolling up the buffer.
|
||||
_initialY -= 1;
|
||||
point.Y -= 1;
|
||||
}
|
||||
|
||||
_console.SetCursorPosition(point.X, point.Y);
|
||||
_console.CursorVisible = true;
|
||||
|
||||
// Preserve the current render data.
|
||||
var renderData = new RenderData
|
||||
{
|
||||
lines = new RenderedLineData[] { new(currentBuffer, isFirstLogicalLine: true) },
|
||||
errorPrompt = (_parseErrors != null && _parseErrors.Length > 0) // Not yet used.
|
||||
};
|
||||
_previousRender = renderData;
|
||||
_previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y);
|
||||
_previousRender.initialY = _initialY;
|
||||
|
||||
_lastRenderTime.Restart();
|
||||
_waitingToRender = false;
|
||||
}
|
||||
|
||||
private void ForceRender()
|
||||
@@ -261,7 +422,7 @@ namespace Microsoft.PowerShell
|
||||
// and minimize writing more than necessary on the next render.)
|
||||
|
||||
var renderLines = new RenderedLineData[logicalLineCount];
|
||||
var renderData = new RenderData {lines = renderLines};
|
||||
var renderData = new RenderData { lines = renderLines };
|
||||
for (var i = 0; i < logicalLineCount; i++)
|
||||
{
|
||||
var line = _consoleBufferLines[i].ToString();
|
||||
@@ -872,9 +1033,6 @@ namespace Microsoft.PowerShell
|
||||
WriteBlankLines(lineCount);
|
||||
}
|
||||
|
||||
// Preserve the current render data.
|
||||
_previousRender = renderData;
|
||||
|
||||
// If we counted pseudo physical lines, deduct them to get the real physical line counts
|
||||
// before updating '_initialY'.
|
||||
physicalLine -= pseudoPhysicalLineOffset;
|
||||
@@ -950,6 +1108,8 @@ namespace Microsoft.PowerShell
|
||||
_console.SetCursorPosition(point.X, point.Y);
|
||||
_console.CursorVisible = true;
|
||||
|
||||
// Preserve the current render data.
|
||||
_previousRender = renderData;
|
||||
_previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y);
|
||||
_previousRender.initialY = _initialY;
|
||||
|
||||
@@ -1201,17 +1361,23 @@ namespace Microsoft.PowerShell
|
||||
return ConvertOffsetToPoint(_buffer.Length);
|
||||
}
|
||||
|
||||
internal Point ConvertOffsetToPoint(int offset)
|
||||
internal Point ConvertOffsetToPoint(int offset, StringBuilder buffer = null)
|
||||
{
|
||||
// This lets us re-use the logic in the screen reader rendering implementation
|
||||
// where the status line is added to the buffer without modifying the local state.
|
||||
buffer ??= _buffer;
|
||||
|
||||
int x = _initialX;
|
||||
int y = _initialY;
|
||||
|
||||
int bufferWidth = _console.BufferWidth;
|
||||
var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt);
|
||||
var continuationPromptLength = Options.ScreenReaderModeEnabled
|
||||
? 0
|
||||
: LengthInBufferCells(Options.ContinuationPrompt);
|
||||
|
||||
for (int i = 0; i < offset; i++)
|
||||
{
|
||||
char c = _buffer[i];
|
||||
char c = buffer[i];
|
||||
if (c == '\n')
|
||||
{
|
||||
y += 1;
|
||||
@@ -1229,7 +1395,7 @@ namespace Microsoft.PowerShell
|
||||
|
||||
// If cursor is at column 0 and the next character is newline, let the next loop
|
||||
// iteration increment y.
|
||||
if (x != 0 || !(i + 1 < offset && _buffer[i + 1] == '\n'))
|
||||
if (x != 0 || !(i + 1 < offset && buffer[i + 1] == '\n'))
|
||||
{
|
||||
y += 1;
|
||||
}
|
||||
@@ -1238,9 +1404,9 @@ namespace Microsoft.PowerShell
|
||||
}
|
||||
|
||||
// If next character actually exists, and isn't newline, check if wider than the space left on the current line.
|
||||
if (_buffer.Length > offset && _buffer[offset] != '\n')
|
||||
if (buffer.Length > offset && buffer[offset] != '\n')
|
||||
{
|
||||
int size = LengthInBufferCells(_buffer[offset]);
|
||||
int size = LengthInBufferCells(buffer[offset]);
|
||||
if (x + size > bufferWidth)
|
||||
{
|
||||
// Character was wider than remaining space, so character, and cursor, appear on next line.
|
||||
@@ -1259,7 +1425,10 @@ namespace Microsoft.PowerShell
|
||||
int y = _initialY;
|
||||
|
||||
int bufferWidth = _console.BufferWidth;
|
||||
var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt);
|
||||
var continuationPromptLength = Options.ScreenReaderModeEnabled
|
||||
? 0
|
||||
: LengthInBufferCells(Options.ContinuationPrompt);
|
||||
|
||||
for (offset = 0; offset < _buffer.Length; offset++)
|
||||
{
|
||||
// If we are on the correct line, return when we find
|
||||
|
||||
Reference in New Issue
Block a user