Files
Andy Jordan 7b6f602405 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.
2025-08-27 17:46:47 -07:00

539 lines
22 KiB
C#

/********************************************************************++
Copyright (c) Microsoft Corporation. All rights reserved.
--********************************************************************/
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Management.Automation;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.PowerShell.PSReadLine;
namespace Microsoft.PowerShell
{
public partial class PSConsoleReadLine
{
private readonly PSConsoleReadLineOptions _options;
private PSConsoleReadLineOptions Options => _options;
private void SetOptionsInternal(SetPSReadLineOption options)
{
if (options.ContinuationPrompt != null)
{
Options.ContinuationPrompt = options.ContinuationPrompt;
}
if (options._historyNoDuplicates.HasValue)
{
Options.HistoryNoDuplicates = options.HistoryNoDuplicates;
}
if (options._historySearchCursorMovesToEnd.HasValue)
{
Options.HistorySearchCursorMovesToEnd = options.HistorySearchCursorMovesToEnd;
}
if (options._addToHistoryHandlerSpecified)
{
Options.AddToHistoryHandler = options.AddToHistoryHandler;
}
if (options._commandValidationHandlerSpecified)
{
Options.CommandValidationHandler = options.CommandValidationHandler;
}
if (options._maximumHistoryCount.HasValue)
{
Options.MaximumHistoryCount = options.MaximumHistoryCount;
if (_history != null)
{
var newHistory = new HistoryQueue<HistoryItem>(Options.MaximumHistoryCount);
while (_history.Count > Options.MaximumHistoryCount)
{
_history.Dequeue();
}
while (_history.Count > 0)
{
newHistory.Enqueue(_history.Dequeue());
}
_history = newHistory;
_currentHistoryIndex = _history.Count;
}
}
if (options._maximumKillRingCount.HasValue)
{
Options.MaximumKillRingCount = options.MaximumKillRingCount;
// TODO - make _killRing smaller
}
if (options._editMode.HasValue)
{
Options.EditMode = options.EditMode;
// Switching/resetting modes - clear out chord dispatch table
_chordDispatchTable.Clear();
SetDefaultBindings(Options.EditMode);
}
if (options._showToolTips.HasValue)
{
Options.ShowToolTips = options.ShowToolTips;
}
if (options._extraPromptLineCount.HasValue)
{
Options.ExtraPromptLineCount = options.ExtraPromptLineCount;
}
if (options._dingTone.HasValue)
{
Options.DingTone = options.DingTone;
}
if (options._dingDuration.HasValue)
{
Options.DingDuration = options.DingDuration;
}
if (options._bellStyle.HasValue)
{
Options.BellStyle = options.BellStyle;
}
if (options._completionQueryItems.HasValue)
{
Options.CompletionQueryItems = options.CompletionQueryItems;
}
if (options.WordDelimiters != null)
{
Options.WordDelimiters = options.WordDelimiters;
}
if (options._historySearchCaseSensitive.HasValue)
{
Options.HistorySearchCaseSensitive = options.HistorySearchCaseSensitive;
}
if (options._historySaveStyle.HasValue)
{
Options.HistorySaveStyle = options.HistorySaveStyle;
}
if (options._viModeIndicator.HasValue)
{
Options.ViModeIndicator = options.ViModeIndicator;
}
if (options.ViModeChangeHandler != null)
{
if (Options.ViModeIndicator != ViModeStyle.Script)
{
throw new ParameterBindingException("ViModeChangeHandler option requires ViModeStyle.Script");
}
Options.ViModeChangeHandler = options.ViModeChangeHandler;
}
if (options.HistorySavePath != null)
{
Options.HistorySavePath = options.HistorySavePath;
_historyFileMutex?.Dispose();
_historyFileMutex = new Mutex(false, GetHistorySaveFileMutexName());
_historyFileLastSavedSize = 0;
}
if (options._ansiEscapeTimeout.HasValue)
{
Options.AnsiEscapeTimeout = options.AnsiEscapeTimeout;
}
if (options.PromptText != null)
{
Options.PromptText = options.PromptText;
}
if (options._predictionSource.HasValue)
{
if (_console is PlatformWindows.LegacyWin32Console && options.PredictionSource != PredictionSource.None)
{
throw new ArgumentException(PSReadLineResources.PredictiveSuggestionNotSupported);
}
bool notTest = ReferenceEquals(_mockableMethods, this);
if ((options.PredictionSource & PredictionSource.Plugin) != 0 && Environment.Version.Major < 6 && notTest)
{
throw new ArgumentException(PSReadLineResources.PredictionPluginNotSupported);
}
Options.PredictionSource = options.PredictionSource;
}
if (options._predictionViewStyle.HasValue)
{
WarnWhenWindowSizeTooSmallForView(options.PredictionViewStyle, options);
Options.PredictionViewStyle = options.PredictionViewStyle;
_prediction.SetViewStyle(options.PredictionViewStyle);
}
if (options.Colors != null)
{
IDictionaryEnumerator e = options.Colors.GetEnumerator();
while (e.MoveNext())
{
if (e.Key is string property)
{
Options.SetColor(property, e.Value);
}
}
}
if (options._terminateOrphanedConsoleApps.HasValue)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Options.TerminateOrphanedConsoleApps = options.TerminateOrphanedConsoleApps;
PlatformWindows.SetTerminateOrphanedConsoleApps(Options.TerminateOrphanedConsoleApps);
}
else
{
throw new PlatformNotSupportedException(
string.Format(
CultureInfo.CurrentUICulture,
PSReadLineResources.OptionNotSupportedOnNonWindows,
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)
{
foreach (var key in keys)
{
var chord = ConsoleKeyChordConverter.Convert(key);
var firstKey = PSKeyInfo.FromConsoleKeyInfo(chord[0]);
if (chord.Length == 1)
{
_dispatchTable[firstKey] = MakeKeyHandler(handler, briefDescription, longDescription, scriptBlock);
}
else
{
_dispatchTable[firstKey] = MakeKeyHandler(Chord, "ChordFirstKey");
if (!_chordDispatchTable.TryGetValue(firstKey, out var secondDispatchTable))
{
secondDispatchTable = new Dictionary<PSKeyInfo, KeyHandler>();
_chordDispatchTable[firstKey] = secondDispatchTable;
}
secondDispatchTable[PSKeyInfo.FromConsoleKeyInfo(chord[1])] = MakeKeyHandler(handler, briefDescription, longDescription, scriptBlock);
}
}
}
private void RemoveKeyHandlerInternal(string[] keys)
{
foreach (var key in keys)
{
var chord = ConsoleKeyChordConverter.Convert(key);
var firstKey = PSKeyInfo.FromConsoleKeyInfo(chord[0]);
if (chord.Length == 1)
{
_dispatchTable.Remove(firstKey);
}
else
{
if (_chordDispatchTable.TryGetValue(firstKey, out var secondDispatchTable))
{
secondDispatchTable.Remove(PSKeyInfo.FromConsoleKeyInfo(chord[1]));
if (secondDispatchTable.Count == 0)
{
_dispatchTable.Remove(firstKey);
}
}
}
}
}
/// <summary>
/// Helper function for the Set-PSReadLineOption cmdlet.
/// </summary>
public static void SetOptions(SetPSReadLineOption options)
{
_singleton.SetOptionsInternal(options);
}
/// <summary>
/// Helper function for the Get-PSReadLineOption cmdlet.
/// </summary>
public static PSConsoleReadLineOptions GetOptions()
{
// Should we copy? It doesn't matter much, everything can be tweaked from
// the cmdlet anyway.
return _singleton._options;
}
class CustomHandlerException : Exception
{
internal CustomHandlerException(Exception innerException)
: base("", innerException)
{
}
}
/// <summary>
/// Helper function for the Set-PSReadLineKeyHandler cmdlet.
/// </summary>
public static void SetKeyHandler(string[] key, ScriptBlock scriptBlock, string briefDescription, string longDescription)
{
void HandlerWrapper(ConsoleKeyInfo? k, object arg)
{
try
{
scriptBlock.Invoke(k, arg);
}
catch (Exception e)
{
throw new CustomHandlerException(e);
}
}
if (string.IsNullOrWhiteSpace(briefDescription))
{
briefDescription = "CustomAction";
}
if (string.IsNullOrWhiteSpace(longDescription))
{
longDescription = PSReadLineResources.CustomActionDescription;
}
_singleton.SetKeyHandlerInternal(key, HandlerWrapper, briefDescription, longDescription, scriptBlock);
}
/// <summary>
/// Helper function for the Set-PSReadLineKeyHandler cmdlet.
/// </summary>
[SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures")]
public static void SetKeyHandler(string[] key, Action<ConsoleKeyInfo?, object> handler, string briefDescription, string longDescription)
{
_singleton.SetKeyHandlerInternal(key, handler, briefDescription, longDescription, null);
}
/// <summary>
/// Helper function for the Remove-PSReadLineKeyHandler cmdlet.
/// </summary>
public static void RemoveKeyHandler(string[] key)
{
_singleton.RemoveKeyHandlerInternal(key);
}
/// <summary>
/// Return all bound key handlers.
/// </summary>
public static IEnumerable<PowerShell.KeyHandler> GetKeyHandlers()
{
return GetKeyHandlers(includeBound: true, includeUnbound: false);
}
/// <summary>
/// Helper function for the Get-PSReadLineKeyHandler cmdlet.
/// </summary>
/// <returns></returns>
public static IEnumerable<PowerShell.KeyHandler> GetKeyHandlers(bool includeBound, bool includeUnbound)
{
var boundFunctions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in _singleton._dispatchTable)
{
if (entry.Value.BriefDescription == "Ignore"
|| entry.Value.BriefDescription == "ChordFirstKey")
{
continue;
}
boundFunctions.Add(entry.Value.BriefDescription);
if (includeBound)
{
yield return new PowerShell.KeyHandler
{
Key = entry.Key.KeyStr,
Function = entry.Value.BriefDescription,
Description = entry.Value.LongDescription,
Group = GetDisplayGrouping(entry.Value.BriefDescription),
};
}
}
// Added to support vi command mode mappings
if (_singleton._options.EditMode == EditMode.Vi)
{
foreach (var entry in _viCmdKeyMap)
{
if (entry.Value.BriefDescription == "Ignore"
|| entry.Value.BriefDescription == "ChordFirstKey")
{
continue;
}
boundFunctions.Add(entry.Value.BriefDescription);
if (includeBound)
{
yield return new PowerShell.KeyHandler
{
Key = "<" + entry.Key.KeyStr + ">",
Function = entry.Value.BriefDescription,
Description = entry.Value.LongDescription,
Group = GetDisplayGrouping(entry.Value.BriefDescription),
};
}
}
}
foreach( var entry in _singleton._chordDispatchTable )
{
foreach( var secondEntry in entry.Value )
{
boundFunctions.Add( secondEntry.Value.BriefDescription );
if (includeBound)
{
yield return new PowerShell.KeyHandler
{
Key = entry.Key.KeyStr + "," + secondEntry.Key.KeyStr,
Function = secondEntry.Value.BriefDescription,
Description = secondEntry.Value.LongDescription,
Group = GetDisplayGrouping(secondEntry.Value.BriefDescription),
};
}
}
}
// Added to support vi command mode chorded mappings
if (_singleton._options.EditMode == EditMode.Vi)
{
foreach (var entry in _viCmdChordTable)
{
foreach (var secondEntry in entry.Value)
{
if (secondEntry.Value.BriefDescription == "Ignore")
{
continue;
}
boundFunctions.Add(secondEntry.Value.BriefDescription);
if (includeBound)
{
yield return new PowerShell.KeyHandler
{
Key = "<" + entry.Key.KeyStr + "," + secondEntry.Key.KeyStr + ">",
Function = secondEntry.Value.BriefDescription,
Description = secondEntry.Value.LongDescription,
Group = GetDisplayGrouping(secondEntry.Value.BriefDescription),
};
}
}
}
}
if (includeUnbound)
{
// SelfInsert isn't really unbound, but we don't want UI to show it that way
boundFunctions.Add("SelfInsert");
var methods = typeof (PSConsoleReadLine).GetMethods(BindingFlags.Public | BindingFlags.Static);
foreach (var method in methods)
{
var parameters = method.GetParameters();
if (parameters.Length != 2 ||
parameters[0].ParameterType != typeof (ConsoleKeyInfo?) ||
parameters[1].ParameterType != typeof (object))
{
continue;
}
if (!boundFunctions.Contains(method.Name))
{
yield return new PowerShell.KeyHandler
{
Key = "Unbound",
Function = method.Name,
Description = null,
Group = GetDisplayGrouping(method.Name),
};
}
}
}
}
/// <summary>
/// Return key handlers bound to specified chords.
/// </summary>
/// <returns></returns>
public static IEnumerable<PowerShell.KeyHandler> GetKeyHandlers(string[] Chord)
{
var boundFunctions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (Chord == null || Chord.Length == 0)
{
yield break;
}
foreach (string Key in Chord)
{
ConsoleKeyInfo[] consoleKeyChord = ConsoleKeyChordConverter.Convert(Key);
PSKeyInfo firstKey = PSKeyInfo.FromConsoleKeyInfo(consoleKeyChord[0]);
if (_singleton._dispatchTable.TryGetValue(firstKey, out KeyHandler entry))
{
if (consoleKeyChord.Length == 1)
{
yield return new PowerShell.KeyHandler
{
Key = firstKey.KeyStr,
Function = entry.BriefDescription,
Description = entry.LongDescription,
Group = GetDisplayGrouping(entry.BriefDescription),
};
}
else
{
PSKeyInfo secondKey = PSKeyInfo.FromConsoleKeyInfo(consoleKeyChord[1]);
if (_singleton._chordDispatchTable.TryGetValue(firstKey, out var secondDispatchTable) &&
secondDispatchTable.TryGetValue(secondKey, out entry))
{
yield return new PowerShell.KeyHandler
{
Key = firstKey.KeyStr + "," + secondKey.KeyStr,
Function = entry.BriefDescription,
Description = entry.LongDescription,
Group = GetDisplayGrouping(entry.BriefDescription),
};
}
}
}
// If in Vi mode, also check Vi's command mode list.
if (_singleton._options.EditMode == EditMode.Vi)
{
if (_viCmdKeyMap.TryGetValue(firstKey, out entry))
{
if (consoleKeyChord.Length == 1)
{
if (entry.BriefDescription == "Ignore")
{
continue;
}
yield return new PowerShell.KeyHandler
{
Key = "<" + firstKey.KeyStr + ">",
Function = entry.BriefDescription,
Description = entry.LongDescription,
Group = GetDisplayGrouping(entry.BriefDescription),
};
}
else
{
PSKeyInfo secondKey = PSKeyInfo.FromConsoleKeyInfo(consoleKeyChord[1]);
if (_viCmdChordTable.TryGetValue(firstKey, out var secondDispatchTable) &&
secondDispatchTable.TryGetValue(secondKey, out entry))
{
if (entry.BriefDescription == "Ignore")
{
continue;
}
yield return new PowerShell.KeyHandler
{
Key = "<" + firstKey.KeyStr + "," + secondKey.KeyStr + ">",
Function = entry.BriefDescription,
Description = entry.LongDescription,
Group = GetDisplayGrouping(entry.BriefDescription),
};
}
}
}
}
}
}
}
}