Skip to content

Prototype screen reader support #4854

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

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
77 changes: 73 additions & 4 deletions PSReadLine/Accessibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Copyright (c) Microsoft Corporation. All rights reserved.
--********************************************************************/

using System.Diagnostics;
using System.Runtime.InteropServices;

namespace Microsoft.PowerShell.Internal
Expand All @@ -10,14 +11,82 @@ internal class Accessibility
{
internal static bool IsScreenReaderActive()
{
bool returnValue = false;

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
PlatformWindows.SystemParametersInfo(PlatformWindows.SPI_GETSCREENREADER, 0, ref returnValue, 0);
return IsAnyWindowsScreenReaderEnabled();
}

if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return IsVoiceOverEnabled();
}

// TODO: Support Linux per https://code.visualstudio.com/docs/configure/accessibility/accessibility
return false;
}

private static bool IsAnyWindowsScreenReaderEnabled()
{
// The supposedly official way to check for a screen reader on
// Windows is SystemParametersInfo(SPI_GETSCREENREADER, ...) but it
// doesn't detect the in-box Windows Narrator and is otherwise known
// to be problematic.
//
// Unfortunately, the alternative method used by Electron and
// Chromium, where the relevant screen reader libraries (modules)
// are checked for does not work in the context of PowerShell
// because it relies on those applications injecting themselves into
// the app. Which they do not because it's not a windowed app, so
// we're stuck using the known-to-be-buggy way.
bool spiScreenReader = false;
PlatformWindows.SystemParametersInfo(PlatformWindows.SPI_GETSCREENREADER, 0, ref spiScreenReader, 0);
if (spiScreenReader)
{
return true;
}

// At least we can correctly check for Windows Narrator using the
// NarratorRunning mutex (which is mostly not broken with
// PSReadLine, as it were).
if (PlatformWindows.IsMutexPresent("NarratorRunning"))
{
return true;
}

return false;
}

private static bool IsVoiceOverEnabled()
{
try
{
// Use the 'defaults' command to check if VoiceOver is enabled
// This checks the com.apple.universalaccess preference for voiceOverOnOffKey
ProcessStartInfo startInfo = new()
{
FileName = "defaults",
Arguments = "read com.apple.universalaccess voiceOverOnOffKey",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};

using Process process = Process.Start(startInfo);
process.WaitForExit(250);
if (process.HasExited && process.ExitCode == 0)
{
string output = process.StandardOutput.ReadToEnd().Trim();
// VoiceOver is enabled if the value is 1
return output == "1";
}
}
catch
{
// If we can't determine the status, assume VoiceOver is not enabled
}

return returnValue;
return false;
}
}
}
4 changes: 2 additions & 2 deletions PSReadLine/BasicEditing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public static void CancelLine(ConsoleKeyInfo? key = null, object arg = null)
_singleton._current = _singleton._buffer.Length;

using var _ = _singleton._prediction.DisableScoped();
_singleton.ForceRender();
_singleton.Render(force: true);

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

Expand Down Expand Up @@ -335,7 +335,7 @@ private bool AcceptLineImpl(bool validate)

if (renderNeeded)
{
ForceRender();
Render(force: true);
}

// Only run validation if we haven't before. If we have and status line shows an error,
Expand Down
17 changes: 17 additions & 0 deletions PSReadLine/Cmdlets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.PowerShell.PSReadLine;
using Microsoft.PowerShell.Internal;
using AllowNull = System.Management.Automation.AllowNullAttribute;

namespace Microsoft.PowerShell
Expand Down Expand Up @@ -163,6 +164,11 @@ public class PSConsoleReadLineOptions
/// </summary>
public const int DefaultAnsiEscapeTimeout = 100;

/// <summary>
/// If screen reader support is enabled, which enables safe rendering using ANSI control codes.
/// </summary>
public static readonly bool DefaultScreenReader = Accessibility.IsScreenReaderActive();

static PSConsoleReadLineOptions()
{
// For inline-view suggestion text, we use the new FG color 'dim white italic' when possible, because it provides
Expand Down Expand Up @@ -224,6 +230,7 @@ public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole)
: PredictionSource.HistoryAndPlugin;
PredictionViewStyle = DefaultPredictionViewStyle;
MaximumHistoryCount = 0;
ScreenReader = DefaultScreenReader;

var historyFileName = hostName + "_history.txt";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Expand Down Expand Up @@ -533,6 +540,8 @@ public object ListPredictionTooltipColor

public bool TerminateOrphanedConsoleApps { get; set; }

public bool ScreenReader { get; set; }

internal string _defaultTokenColor;
internal string _commentColor;
internal string _keywordColor;
Expand Down Expand Up @@ -847,6 +856,14 @@ public SwitchParameter TerminateOrphanedConsoleApps
}
internal SwitchParameter? _terminateOrphanedConsoleApps;

[Parameter]
public SwitchParameter ScreenReader
{
get => _screenReader.GetValueOrDefault();
set => _screenReader = value;
}
internal SwitchParameter? _screenReader;

[ExcludeFromCodeCoverage]
protected override void EndProcessing()
{
Expand Down
12 changes: 12 additions & 0 deletions PSReadLine/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ private void SetOptionsInternal(SetPSReadLineOption options)
nameof(Options.TerminateOrphanedConsoleApps)));
}
}
if (options._screenReader.HasValue)
{
Options.ScreenReader = options.ScreenReader;

if (Options.ScreenReader)
{
// Disable prediction for better accessibility.
Options.PredictionSource = PredictionSource.None;
// Disable continuation prompt as multi-line is not available.
Options.ContinuationPrompt = "";
}
}
}

private void SetKeyHandlerInternal(string[] keys, Action<ConsoleKeyInfo?, object> handler, string briefDescription, string longDescription, ScriptBlock scriptBlock)
Expand Down
15 changes: 15 additions & 0 deletions PSReadLine/PlatformWindows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ IntPtr templateFileWin32Handle
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern IntPtr GetStdHandle(uint handleId);

internal const int ERROR_ALREADY_EXISTS = 0xB7;

internal static bool IsMutexPresent(string name)
{
try
{
using var mutex = new System.Threading.Mutex(false, name);
return Marshal.GetLastWin32Error() == ERROR_ALREADY_EXISTS;
}
catch
{
return false;
}
}

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool SetConsoleCtrlHandler(BreakHandler handlerRoutine, bool add);

Expand Down
1 change: 1 addition & 0 deletions PSReadLine/Render.Helper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ internal static int LengthInBufferCells(char c)
if (c < 256)
{
// We render ^C for Ctrl+C, so return 2 for control characters
// TODO: Do we care about this under a screen reader?
return Char.IsControl(c) ? 2 : 1;
}

Expand Down
126 changes: 121 additions & 5 deletions PSReadLine/Render.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,11 @@ private void RenderWithPredictionQueryPaused()
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 && _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.
Expand All @@ -242,12 +242,128 @@ private void Render()
// 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)
if (!force && elapsedMs < 50)
{
_handlePotentialResizing = false;
}

ForceRender();
// Use simplified rendering for screen readers
if (Options.ScreenReader)
{
SafeRender();
}
else
{
ForceRender();
}
}

private void SafeRender()
{
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;
}

// For screen readers, we are just comparing the previous and current buffer text
// (without colors) and only writing the differences.
string currentBuffer = ParseInput();
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;

// Calculate what to render and where to start the rendering.
// TODO: Short circuit optimization when currentBuffer == previousBuffer.
int commonPrefixLength = FindCommonPrefixLength(previousBuffer, currentBuffer);

if (commonPrefixLength > 0 && commonPrefixLength == previousBuffer.Length)
{
// Previous buffer is a complete prefix of current buffer.
// Just append the new data.
var appendedData = currentBuffer.Substring(commonPrefixLength);
_console.Write(appendedData);
}
else if (commonPrefixLength > 0)
{
// Buffers share a common prefix but previous buffer has additional content.
// Move cursor to where the difference starts, clear forward, and write the data.
var diffPoint = ConvertOffsetToPoint(commonPrefixLength);
_console.SetCursorPosition(diffPoint.X, diffPoint.Y);
var changedData = currentBuffer.Substring(commonPrefixLength);
_console.Write("\x1b[0J");
_console.Write(changedData);
}
else
{
// No common prefix, rewrite entire buffer.
_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);
int physicalLine = endPoint.Y - _initialY;
if (_initialY + physicalLine > bufferHeight)
{
// We had to scroll to render everything, update _initialY.
_initialY = bufferHeight - physicalLine;
}

// 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;

// Calculate the coord to place the cursor for the next input.
var point = ConvertOffsetToPoint(_current);

if (point.Y == bufferHeight)
{
// The cursor top exceeds the buffer height and it hasn't already wrapped,
// 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;

_previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y);
_previousRender.initialY = _initialY;

_lastRenderTime.Restart();
_waitingToRender = false;
}

private void ForceRender()
Expand All @@ -261,7 +377,7 @@ private void ForceRender()
// 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();
Expand Down