Files
PowerShell-PSReadLine/PSReadLine/BasicEditing.cs
T
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

616 lines
23 KiB
C#

/********************************************************************++
Copyright (c) Microsoft Corporation. All rights reserved.
--********************************************************************/
using System;
using System.Globalization;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Language;
using Microsoft.PowerShell.PSReadLine;
namespace Microsoft.PowerShell
{
public partial class PSConsoleReadLine
{
/// <summary>
/// Insert the key.
/// </summary>
public static void SelfInsert(ConsoleKeyInfo? key = null, object arg = null)
{
if (!key.HasValue)
{
return;
}
var keyChar = key.Value.KeyChar;
if (keyChar == '\0')
return;
if (arg is int count)
{
if (count <= 0)
return;
}
else
{
count = 1;
}
if (_singleton._visualSelectionCommandCount > 0)
{
_singleton.GetRegion(out var start, out var length);
Replace(start, length, new string(keyChar, count));
}
else if (count > 1)
{
Insert(new string(keyChar, count));
}
else
{
Insert(keyChar);
}
}
/// <summary>
/// Reverts all of the input to the current input.
/// </summary>
public static void RevertLine(ConsoleKeyInfo? key = null, object arg = null)
{
if (_singleton._prediction.RevertSuggestion())
{
return;
}
if (_singleton._statusIsErrorMessage)
{
// After an edit, clear the error message
_singleton.ClearStatusMessage(render: false);
}
while (_singleton._undoEditIndex > 0)
{
_singleton._edits[_singleton._undoEditIndex - 1].Undo();
_singleton._undoEditIndex--;
}
_singleton.Render();
}
/// <summary>
/// Cancel the current input, leaving the input on the screen,
/// but returns back to the host so the prompt is evaluated again.
/// </summary>
public static void CancelLine(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.ClearStatusMessage(false);
_singleton._current = _singleton._buffer.Length;
using var _ = _singleton._prediction.DisableScoped();
_singleton.Render(force: true);
_singleton._console.Write("\x1b[91m^C\x1b[0m");
_singleton._buffer.Clear(); // Clear so we don't actually run the input
_singleton._current = 0; // If Render is called, _current must be correct.
_singleton._currentHistoryIndex = _singleton._history.Count;
_singleton._inputAccepted = true;
}
/// <summary>
/// Like KillLine - deletes text from the point to the end of the input,
/// but does not put the deleted text in the kill ring.
/// </summary>
public static void ForwardDeleteInput(ConsoleKeyInfo? key = null, object arg = null)
{
ForwardDeleteImpl(_singleton._buffer.Length, ForwardDeleteInput);
}
/// <summary>
/// Deletes text from the point to the end of the current logical line,
/// but does not put the deleted text in the kill ring.
/// </summary>
public static void ForwardDeleteLine(ConsoleKeyInfo? key = null, object arg = null)
{
ForwardDeleteImpl(GetEndOfLogicalLinePos(_singleton._current) + 1, ForwardDeleteLine);
}
/// <summary>
/// Deletes text from the cursor position to the specified end position
/// but does not put the deleted text in the kill ring.
/// </summary>
/// <param name="endPosition">0-based offset to one character past the end of the text.</param>
private static void ForwardDeleteImpl(int endPosition, Action<ConsoleKeyInfo?, object> instigator)
{
var current = _singleton._current;
var buffer = _singleton._buffer;
if (buffer.Length > 0 && current < endPosition)
{
int length = endPosition - current;
var str = buffer.ToString(current, length);
_singleton.SaveEditItem(
EditItemDelete.Create(
str,
current,
instigator,
instigatorArg: null,
!InViEditMode()));
buffer.Remove(current, length);
_singleton.Render();
}
}
/// <summary>
/// Like BackwardKillInput - deletes text from the point to the start of the input,
/// but does not put the deleted text in the kill ring.
public static void BackwardDeleteInput(ConsoleKeyInfo? key = null, object arg = null)
{
BackwardDeleteSubstring(0, BackwardDeleteInput);
}
/// <summary>
/// Like BackwardKillLine - deletes text from the point to the start of the logical line,
/// but does not put the deleted text in the kill ring.
/// </summary>
public static void BackwardDeleteLine(ConsoleKeyInfo? key = null, object arg = null)
{
var position = GetBeginningOfLinePos(_singleton._current);
BackwardDeleteSubstring(position, BackwardDeleteLine);
}
private static void BackwardDeleteSubstring(int position, Action<ConsoleKeyInfo?, object> instigator)
{
if (_singleton._current > position)
{
var count = _singleton._current - position;
_singleton.RemoveTextToViRegister(position, count, instigator, arg: null, !InViEditMode());
_singleton._current = position;
_singleton.Render();
}
}
/// <summary>
/// Delete the character before the cursor.
/// </summary>
public static void BackwardDeleteChar(ConsoleKeyInfo? key = null, object arg = null)
{
if (_singleton._visualSelectionCommandCount > 0)
{
_singleton.GetRegion(out var start, out var length);
Delete(start, length);
return;
}
if (_singleton._buffer.Length > 0 && _singleton._current > 0)
{
int qty = arg as int? ?? 1;
if (qty < 1) return; // Ignore useless counts
qty = Math.Min(qty, _singleton._current);
int startDeleteIndex = _singleton._current - qty;
_singleton.RemoveTextToViRegister(startDeleteIndex, qty, BackwardDeleteChar, arg, !InViEditMode());
_singleton._current = startDeleteIndex;
_singleton.Render();
}
}
private void DeleteCharImpl(int qty, bool orExit)
{
if (_visualSelectionCommandCount > 0)
{
GetRegion(out var start, out var length);
Delete(start, length);
return;
}
if (_buffer.Length > 0)
{
if (_current < _buffer.Length)
{
qty = Math.Min(qty, _singleton._buffer.Length - _singleton._current);
RemoveTextToViRegister(_current, qty, DeleteChar, qty, !InViEditMode());
if (_current >= _buffer.Length)
{
_current = Math.Max(0, _buffer.Length + ViEndOfLineFactor);
}
Render();
}
}
else if (orExit)
{
throw new ExitException();
}
}
/// <summary>
/// Delete the character under the cursor.
/// </summary>
public static void DeleteChar(ConsoleKeyInfo? key = null, object arg = null)
{
int qty = arg as int? ?? 1;
if (qty < 1) return; // Ignore useless counts
_singleton.DeleteCharImpl(qty, orExit: false);
}
/// <summary>
/// Delete the character under the cursor, or if the line is empty, exit the process.
/// </summary>
public static void DeleteCharOrExit(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.DeleteCharImpl(1, orExit: true);
}
/// <summary>
/// A helper function to change the case of the current word.
/// </summary>
private static void UpdateWordCase(bool toUpper)
{
if (_singleton._current >= _singleton._buffer.Length)
{
Ding();
return;
}
int endOfWord = _singleton.FindForwardWordPoint(_singleton.Options.WordDelimiters);
int wordlen = endOfWord - _singleton._current;
string word = _singleton._buffer.ToString(_singleton._current, wordlen);
word = toUpper ? word.ToUpper() : word.ToLower();
Replace(_singleton._current, wordlen, word);
_singleton.MoveCursor(endOfWord);
_singleton.Render();
}
/// <summary>
/// Upcase the current word and move to the next one.
/// </summary>
public static void UpcaseWord(ConsoleKeyInfo? key = null, object arg = null)
{
UpdateWordCase(toUpper: true);
}
/// <summary>
/// Downcase the current word and move to the next one.
/// </summary>
public static void DowncaseWord(ConsoleKeyInfo? key = null, object arg = null)
{
UpdateWordCase(toUpper: false);
}
/// <summary>
/// Capitalize the current word and move to the next one.
/// </summary>
public static void CapitalizeWord(ConsoleKeyInfo? key = null, object arg = null)
{
if (_singleton._current >= _singleton._buffer.Length)
{
Ding();
return;
}
int endOfWord = _singleton.FindForwardWordPoint(_singleton.Options.WordDelimiters);
int wordlen = endOfWord - _singleton._current;
char[] word = _singleton._buffer.ToString(_singleton._current, wordlen).ToLower().ToCharArray();
int firstLetterIdx = Array.FindIndex(word, static x => char.IsLetter(x));
if (firstLetterIdx >= 0)
{
word[firstLetterIdx] = char.ToUpper(word[firstLetterIdx]);
Replace(_singleton._current, wordlen, new string(word));
}
_singleton.MoveCursor(endOfWord);
_singleton.Render();
}
private bool AcceptLineImpl(bool validate)
{
using var _ = _prediction.DisableScoped();
ParseInput();
if (_parseErrors.Any(e => e.IncompleteInput))
{
Insert('\n');
return false;
}
// If text was pasted, for performance reasons we skip rendering for some time,
// but if input is accepted, we won't have another chance to render.
//
// Also - if there was an emphasis, we want to clear that before accepting
// and that requires rendering.
bool renderNeeded = _emphasisStart >= 0 || _queuedKeys.Count > 0;
_emphasisStart = -1;
_emphasisLength = 0;
if (renderNeeded)
{
Render(force: true);
}
// Only run validation if we haven't before. If we have and status line shows an error,
// treat that as a -Force and accept the input so it is added to history, and PowerShell
// can report an error as it normally does.
if (validate && !_statusIsErrorMessage)
{
var insertionPoint = _current;
var errorMessage = Validate(_ast);
if (!string.IsNullOrWhiteSpace(errorMessage))
{
// If there are more keys, assume the user pasted with a right click and
// we should insert a newline even though validation failed.
if (_queuedKeys.Count > 0)
{
// Validation may have moved the cursor. Because there are queued
// keys, we need to move the cursor back to the correct place, and
// ignore where validation put the cursor because the queued keys
// will be inserted in the wrong place.
SetCursorPosition(insertionPoint);
Insert('\n');
}
_statusLinePrompt = "";
_statusBuffer.Append(errorMessage);
_statusIsErrorMessage = true;
Render();
return false;
}
}
if (_statusIsErrorMessage)
{
ClearStatusMessage(render: true);
}
// Make sure cursor is at the end before writing the line.
if (_current != _buffer.Length)
{
// Let public API set cursor to end of line incase end of line is end of buffer.
_current = _buffer.Length;
SetCursorPosition(_current);
}
if (_prediction.ActiveView is PredictionListView listView)
{
// Send feedback to prediction plugin if a list item is accepted as the final command line.
listView.OnSuggestionAccepted();
}
// Clear the prediction view if there is one.
_prediction.ActiveView.Clear(cursorAtEol: true);
_console.Write("\n");
_inputAccepted = true;
return true;
}
class CommandValidationVisitor : AstVisitor
{
private readonly Ast _rootAst;
internal string detectedError;
internal CommandValidationVisitor(Ast rootAst)
{
_rootAst = rootAst;
}
public override AstVisitAction VisitCommand(CommandAst commandAst)
{
var commandName = commandAst.GetCommandName();
if (commandName != null)
{
if (_singleton._engineIntrinsics != null)
{
var commandInfo = _singleton._engineIntrinsics.InvokeCommand.GetCommand(commandName, CommandTypes.All);
if (commandInfo == null && !_singleton.UnresolvedCommandCouldSucceed(commandName, _rootAst))
{
_singleton._current = commandAst.CommandElements[0].Extent.EndOffset;
detectedError = string.Format(CultureInfo.CurrentCulture, PSReadLineResources.CommandNotFoundError, commandName);
return AstVisitAction.StopVisit;
}
}
if (commandAst.CommandElements.Any(e => e is ScriptBlockExpressionAst))
{
if (_singleton._options.CommandsToValidateScriptBlockArguments == null ||
!_singleton._options.CommandsToValidateScriptBlockArguments.Contains(commandName))
{
return AstVisitAction.SkipChildren;
}
}
}
if (_singleton._options.CommandValidationHandler != null)
{
try
{
_singleton._options.CommandValidationHandler(commandAst);
}
catch (Exception e)
{
detectedError = e.Message;
}
}
return !string.IsNullOrWhiteSpace(detectedError)
? AstVisitAction.StopVisit
: AstVisitAction.Continue;
}
}
private string Validate(Ast rootAst)
{
if (_parseErrors != null && _parseErrors.Length > 0)
{
// Move the cursor to the point of error
_current = _parseErrors[0].Extent.EndOffset;
return _parseErrors[0].Message;
}
var validationVisitor = new CommandValidationVisitor(rootAst);
rootAst.Visit(validationVisitor);
if (!string.IsNullOrWhiteSpace(validationVisitor.detectedError))
{
return validationVisitor.detectedError;
}
return null;
}
private bool UnresolvedCommandCouldSucceed(string commandName, Ast rootAst)
{
// This is a little hacky, but we check for a few things where part of the current
// command defines/imports new commands that PowerShell might not yet know about.
// There is little reason to go to great lengths at being correct here, validation
// is just a small usability tweak to avoid cluttering up history - PowerShell
// will report errors for stuff we actually let through.
// Do we define a function matching the command name?
var fnDefns = rootAst.FindAll(ast => ast is FunctionDefinitionAst, true).OfType<FunctionDefinitionAst>();
if (fnDefns.Any(fnDefnAst => fnDefnAst.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
var cmdAsts = rootAst.FindAll(ast => ast is CommandAst, true).OfType<CommandAst>();
foreach (var cmdAst in cmdAsts)
{
// If we dot source something, we can't in general know what is being
// dot sourced so just assume the unresolved command will work.
// If we use the invocation operator, allow that because an expression
// is being invoked and it's reasonable to just allow it.
if (cmdAst.InvocationOperator != TokenKind.Unknown)
{
return true;
}
// Are we importing a module or being tricky with Invoke-Expression? Let those through.
var candidateCommand = cmdAst.GetCommandName();
if (candidateCommand.Equals("Import-Module", StringComparison.OrdinalIgnoreCase)
|| candidateCommand.Equals("ipmo", StringComparison.OrdinalIgnoreCase)
|| candidateCommand.Equals("Invoke-Expression", StringComparison.OrdinalIgnoreCase)
|| candidateCommand.Equals("iex", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
if (commandName.Length == 1)
{
switch (commandName[0])
{
// The following are debugger commands that should be accepted if we're debugging
// because the console host will interpret these commands directly.
case 's': case 'v': case 'o': case 'c': case 'q': case 'k': case 'l':
case 'S': case 'V': case 'O': case 'C': case 'Q': case 'K': case 'L':
case '?': case 'h': case 'H':
// Ideally we would check $PSDebugContext, but it is set at function
// scope, and because we're in a module, we can't find that variable
// (arguably a PowerShell issue.)
// NestedPromptLevel is good enough though - it's rare to be in a nested.
var nestedPromptLevel = _engineIntrinsics.SessionState.PSVariable.GetValue("NestedPromptLevel");
if (nestedPromptLevel is int)
{
return ((int)nestedPromptLevel) > 0;
}
break;
}
}
return false;
}
/// <summary>
/// Attempt to execute the current input. If the current input is incomplete (for
/// example there is a missing closing parenthesis, bracket, or quote, then the
/// continuation prompt is displayed on the next line and PSReadLine waits for
/// keys to edit the current input.
/// </summary>
public static void AcceptLine(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.AcceptLineImpl(false);
}
/// <summary>
/// Attempt to execute the current input. If the current input is incomplete (for
/// example there is a missing closing parenthesis, bracket, or quote, then the
/// continuation prompt is displayed on the next line and PSReadLine waits for
/// keys to edit the current input.
/// </summary>
public static void ValidateAndAcceptLine(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.AcceptLineImpl(true);
}
/// <summary>
/// Attempt to execute the current input. If it can be executed (like AcceptLine),
/// then recall the next item from history the next time ReadLine is called.
/// </summary>
public static void AcceptAndGetNext(ConsoleKeyInfo? key = null, object arg = null)
{
if (_singleton.AcceptLineImpl(false))
{
if (_singleton._currentHistoryIndex < (_singleton._history.Count - 1))
{
_singleton._getNextHistoryIndex = _singleton._currentHistoryIndex + 1;
}
else
{
Ding();
}
}
}
/// <summary>
/// The continuation prompt is displayed on the next line and PSReadLine waits for
/// keys to edit the current input. This is useful to enter multi-line input as
/// a single command even when a single line is complete input by itself.
/// </summary>
public static void AddLine(ConsoleKeyInfo? key = null, object arg = null)
{
Insert('\n');
}
/// <summary>
/// A new empty line is created above the current line regardless of where the cursor
/// is on the current line. The cursor moves to the beginning of the new line.
/// </summary>
public static void InsertLineAbove(ConsoleKeyInfo? key = null, object arg = null)
{
// Move the current position to the beginning of the current line and only the current line.
_singleton._current = GetBeginningOfLinePos(_singleton._current);
Insert('\n');
PreviousLine();
}
/// <summary>
/// A new empty line is created below the current line regardless of where the cursor
/// is on the current line. The cursor moves to the beginning of the new line.
/// </summary>
public static void InsertLineBelow(ConsoleKeyInfo? key = null, object arg = null)
{
int i = _singleton._current;
for (; i < _singleton._buffer.Length; i++)
{
if (_singleton._buffer[i] == '\n')
{
break;
}
}
_singleton._current = i;
Insert('\n');
}
}
}