diff --git a/PSReadLine/BasicEditing.cs b/PSReadLine/BasicEditing.cs index 33445ed..74c76ac 100644 --- a/PSReadLine/BasicEditing.cs +++ b/PSReadLine/BasicEditing.cs @@ -86,7 +86,7 @@ namespace Microsoft.PowerShell _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 @@ namespace Microsoft.PowerShell 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 7fecf4b..5633a69 100644 --- a/PSReadLine/Cmdlets.cs +++ b/PSReadLine/Cmdlets.cs @@ -15,6 +15,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Threading; using Microsoft.PowerShell.PSReadLine; +using Microsoft.PowerShell.Internal; using AllowNull = System.Management.Automation.AllowNullAttribute; namespace Microsoft.PowerShell @@ -150,11 +151,6 @@ namespace Microsoft.PowerShell public const HistorySaveStyle DefaultHistorySaveStyle = HistorySaveStyle.SaveIncrementally; - /// - /// The predictive suggestion feature is disabled by default. - /// - public const PredictionSource DefaultPredictionSource = PredictionSource.None; - public const PredictionViewStyle DefaultPredictionViewStyle = PredictionViewStyle.InlineView; /// @@ -201,6 +197,7 @@ namespace Microsoft.PowerShell { ResetColors(); EditMode = DefaultEditMode; + ScreenReaderModeEnabled = Accessibility.IsScreenReaderActive(); ContinuationPrompt = DefaultContinuationPrompt; ContinuationPromptColor = Console.ForegroundColor; ExtraPromptLineCount = DefaultExtraPromptLineCount; @@ -533,6 +530,8 @@ namespace Microsoft.PowerShell public bool TerminateOrphanedConsoleApps { get; set; } + public bool ScreenReaderModeEnabled { get; set; } + internal string _defaultTokenColor; internal string _commentColor; internal string _keywordColor; @@ -847,6 +846,14 @@ namespace Microsoft.PowerShell } internal SwitchParameter? _terminateOrphanedConsoleApps; + [Parameter] + public SwitchParameter EnableScreenReaderMode + { + get => _enableScreenReaderMode.GetValueOrDefault(); + set => _enableScreenReaderMode = value; + } + internal SwitchParameter? _enableScreenReaderMode; + [ExcludeFromCodeCoverage] protected override void EndProcessing() { diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs index 7485154..ffdf024 100644 --- a/PSReadLine/Options.cs +++ b/PSReadLine/Options.cs @@ -185,6 +185,10 @@ namespace Microsoft.PowerShell nameof(Options.TerminateOrphanedConsoleApps))); } } + if (options._enableScreenReaderMode.HasValue) + { + Options.ScreenReaderModeEnabled = options.EnableScreenReaderMode; + } } private void SetKeyHandlerInternal(string[] keys, Action handler, string briefDescription, string longDescription, ScriptBlock scriptBlock) diff --git a/PSReadLine/Render.cs b/PSReadLine/Render.cs index fe68848..253f7fb 100644 --- a/PSReadLine/Render.cs +++ b/PSReadLine/Render.cs @@ -218,36 +218,197 @@ namespace Microsoft.PowerShell 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) { - // 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 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 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; + } } - // 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) + // Use simplified rendering for screen readers + if (Options.ScreenReaderModeEnabled) { - _handlePotentialResizing = false; + RenderForScreenReader(); + } + else + { + ForceRender(); + } + } + + private void RenderForScreenReader() + { + 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; } - ForceRender(); + // For screen readers, we are just comparing the previous and current buffer text + // (without colors) and only writing the differences. + // + // Note that we don't call QueryForSuggestion() which is the only + // entry into the prediction logic, so while it could be enabled, it + // won't do anything in this rendering implementation. + string parsedInput = ParseInput(); + StringBuilder buffer = new(parsedInput); + + // Really simple handling of a status line: append it! + if (!string.IsNullOrEmpty(_statusLinePrompt)) + { + buffer.Append("\n"); + buffer.Append(_statusLinePrompt); + buffer.Append(_statusBuffer); + } + + string currentBuffer = buffer.ToString(); + 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; + + if (currentBuffer == previousBuffer) + { + // No-op, such as when selecting text or otherwise re-entering. + } + else if (previousBuffer.Length == 0) + { + // Previous buffer was empty so we just render the current buffer, + // and we don't need to move the cursor. + _console.Write(currentBuffer); + } + else + { + // Calculate what to render and where to start the rendering. + int commonPrefixLength = FindCommonPrefixLength(previousBuffer, currentBuffer); + + // If we're scrolling through history we always want to re-render. + // Writing only the diff in this scenario is a weird UX. + if (commonPrefixLength > 0 && _anyHistoryCommandCount == 0) + { + // We need to differentially render, possibly with a partial rewrite. + if (commonPrefixLength != previousBuffer.Length) + { + // The buffers share a common prefix but the previous buffer has additional content. + // Move cursor to where the difference starts and clear so we can rewrite. + var diffPoint = ConvertOffsetToPoint(commonPrefixLength, buffer); + _console.SetCursorPosition(diffPoint.X, diffPoint.Y); + _console.Write("\x1b[0J"); + } // Otherwise the previous buffer is a complete prefix and we just write. + + // TODO: There is a rare edge case where the common prefix can be incorrectly + // calculated because the incoming replacement text matches the text at the current + // cursor position. Unfortunately we don't have a solution yet. For example: + // + // 1. Previous line is "abcdef" and cursor is at (before) the letter "d" + // 2. Paste "defghi" so currentBuffer is "abcdefghidef" + // 3. The diff is "ghidef" and because commonPrefixLength == previousBuffer.Length + // 4. The terminal will incorrectly display "abcghidef" instead of "abcdefghidef" + + // Finally, write the diff. + var diffData = currentBuffer.Substring(commonPrefixLength); + _console.Write(diffData); + } + else + { + // The buffers are completely different so we need to rewrite from the start. + _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, buffer); + if (endPoint.Y >= bufferHeight) + { + // We had to scroll to render everything, update _initialY. + int offset = 1; // Base case to handle zero-indexing. + if (endPoint.X == 0 && !currentBuffer.EndsWith("\n")) + { + // The line hasn't actually wrapped yet because we have exactly filled the line. + offset -= 1; + } + int scrolledLines = endPoint.Y - bufferHeight + offset; + _initialY -= scrolledLines; + } + + // Calculate the coord to place the cursor for the next input. + var point = ConvertOffsetToPoint(_current, buffer); + + 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) + { + _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; + + // 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; + _previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y); + _previousRender.initialY = _initialY; + + _lastRenderTime.Restart(); + _waitingToRender = false; } private void ForceRender() @@ -261,7 +422,7 @@ namespace Microsoft.PowerShell // 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(); @@ -872,9 +1033,6 @@ namespace Microsoft.PowerShell WriteBlankLines(lineCount); } - // Preserve the current render data. - _previousRender = renderData; - // If we counted pseudo physical lines, deduct them to get the real physical line counts // before updating '_initialY'. physicalLine -= pseudoPhysicalLineOffset; @@ -950,6 +1108,8 @@ namespace Microsoft.PowerShell _console.SetCursorPosition(point.X, point.Y); _console.CursorVisible = true; + // Preserve the current render data. + _previousRender = renderData; _previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y); _previousRender.initialY = _initialY; @@ -1201,17 +1361,23 @@ namespace Microsoft.PowerShell return ConvertOffsetToPoint(_buffer.Length); } - internal Point ConvertOffsetToPoint(int offset) + internal Point ConvertOffsetToPoint(int offset, StringBuilder buffer = null) { + // This lets us re-use the logic in the screen reader rendering implementation + // where the status line is added to the buffer without modifying the local state. + buffer ??= _buffer; + int x = _initialX; int y = _initialY; int bufferWidth = _console.BufferWidth; - var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt); + var continuationPromptLength = Options.ScreenReaderModeEnabled + ? 0 + : LengthInBufferCells(Options.ContinuationPrompt); for (int i = 0; i < offset; i++) { - char c = _buffer[i]; + char c = buffer[i]; if (c == '\n') { y += 1; @@ -1229,7 +1395,7 @@ namespace Microsoft.PowerShell // If cursor is at column 0 and the next character is newline, let the next loop // iteration increment y. - if (x != 0 || !(i + 1 < offset && _buffer[i + 1] == '\n')) + if (x != 0 || !(i + 1 < offset && buffer[i + 1] == '\n')) { y += 1; } @@ -1238,9 +1404,9 @@ namespace Microsoft.PowerShell } // If next character actually exists, and isn't newline, check if wider than the space left on the current line. - if (_buffer.Length > offset && _buffer[offset] != '\n') + if (buffer.Length > offset && buffer[offset] != '\n') { - int size = LengthInBufferCells(_buffer[offset]); + int size = LengthInBufferCells(buffer[offset]); if (x + size > bufferWidth) { // Character was wider than remaining space, so character, and cursor, appear on next line. @@ -1259,7 +1425,10 @@ namespace Microsoft.PowerShell int y = _initialY; int bufferWidth = _console.BufferWidth; - var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt); + var continuationPromptLength = Options.ScreenReaderModeEnabled + ? 0 + : LengthInBufferCells(Options.ContinuationPrompt); + for (offset = 0; offset < _buffer.Length; offset++) { // If we are on the correct line, return when we find