diff --git a/PSReadLine/Accessibility.cs b/PSReadLine/Accessibility.cs
index 4938da23..aae6c0f5 100644
--- a/PSReadLine/Accessibility.cs
+++ b/PSReadLine/Accessibility.cs
@@ -2,6 +2,7 @@
Copyright (c) Microsoft Corporation. All rights reserved.
--********************************************************************/
+using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Microsoft.PowerShell.Internal
@@ -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;
}
}
}
diff --git a/PSReadLine/BasicEditing.cs b/PSReadLine/BasicEditing.cs
index 33445ed3..74c76ac0 100644
--- a/PSReadLine/BasicEditing.cs
+++ b/PSReadLine/BasicEditing.cs
@@ -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");
@@ -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,
diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs
index 7fecf4b4..af4d633f 100644
--- a/PSReadLine/Cmdlets.cs
+++ b/PSReadLine/Cmdlets.cs
@@ -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
@@ -163,6 +164,11 @@ public class PSConsoleReadLineOptions
///
public const int DefaultAnsiEscapeTimeout = 100;
+ ///
+ /// If screen reader support is enabled, which enables safe rendering using ANSI control codes.
+ ///
+ 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
@@ -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))
@@ -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;
@@ -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()
{
diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs
index 7485154b..0416dba3 100644
--- a/PSReadLine/Options.cs
+++ b/PSReadLine/Options.cs
@@ -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 handler, string briefDescription, string longDescription, ScriptBlock scriptBlock)
diff --git a/PSReadLine/PlatformWindows.cs b/PSReadLine/PlatformWindows.cs
index c7e0313b..32cf653f 100644
--- a/PSReadLine/PlatformWindows.cs
+++ b/PSReadLine/PlatformWindows.cs
@@ -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);
diff --git a/PSReadLine/Render.Helper.cs b/PSReadLine/Render.Helper.cs
index 8fc56bea..584ce795 100644
--- a/PSReadLine/Render.Helper.cs
+++ b/PSReadLine/Render.Helper.cs
@@ -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;
}
diff --git a/PSReadLine/Render.cs b/PSReadLine/Render.cs
index fe688481..f13d15b9 100644
--- a/PSReadLine/Render.cs
+++ b/PSReadLine/Render.cs
@@ -218,36 +218,162 @@ 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)
- {
- // 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 (!force)
+ {
+ 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;
+ }
+ }
+
+ // 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'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)
+ // If we had to wrap to render everything, update _initialY
+ var endPoint = ConvertOffsetToPoint(currentBuffer.Length);
+ if (endPoint.Y >= bufferHeight)
{
- _handlePotentialResizing = false;
+
+ // We had to scroll to render everything, update _initialY.
+ int offset = 1; // Base case to handle zero-indexing.
+ if (endPoint.X == 0)
+ {
+ // The line hasn't actually wrapped yet because we have exactly filled the line.
+ offset -= 1;
+ }
+ int scrolledLines = endPoint.Y - bufferHeight + offset;
+ _initialY -= scrolledLines;
}
- ForceRender();
+ // 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,
+ // (because we have exactly filled the line) so we need to scroll up the buffer by 1 line.
+ if (point.X == 0 && !currentBuffer.EndsWith("\n"))
+ {
+ _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()
@@ -261,7 +387,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();