Skip to content

Add 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 7 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 PowerShell is 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. Windows Narrator is mostly not broken with
// PSReadLine, not in the way that NVDA and VoiceOver are.
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; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property name doesn't indicate the Boolean type. How about renamed it to be ScreenReaderIsActive?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems awkward as a CLI option, you don't usually type -FooIsActive to enable something. What about -ScreenReaderMode?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property name in PSConsoleReadLineOptions doesn't have to be the same as the parameter in the cmdlet SetPSReadLineOption. The former will be the one that is referenced a lot in the code base, so it would be better to make it more readable, such as UseScreenReader or ScreenReaderEnabled. The parameter name can be made -EnableScreenReadeMode or similar.

Should we use a different set of default key bindings when a Screen Reader is turned on? If so, then it may be better to add a new EditMode ScreenReaderMode, while defines its own set of default bindings.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know which two names you'd like it to be.

I think let's not do a new EditMode but simply, for the very few that it seems to affect, disable the function by returning early if in screen reader mode. Otherwise we have to copy/paste the default bindings, and then we'd have different defaults for Windows/non-Windows, or we'd have to add new logic for unbinding by function name instead of key chord.


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
Loading