Skip to content

Commit 2c3b9cf

Browse files
committed
Implement SafeRender() for use under a screen reader
1 parent c38dbae commit 2c3b9cf

File tree

4 files changed

+148
-26
lines changed

4 files changed

+148
-26
lines changed

PSReadLine/BasicEditing.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public static void CancelLine(ConsoleKeyInfo? key = null, object arg = null)
8686
_singleton._current = _singleton._buffer.Length;
8787

8888
using var _ = _singleton._prediction.DisableScoped();
89-
_singleton.ForceRender();
89+
_singleton.Render(force: true);
9090

9191
_singleton._console.Write("\x1b[91m^C\x1b[0m");
9292

@@ -335,7 +335,7 @@ private bool AcceptLineImpl(bool validate)
335335

336336
if (renderNeeded)
337337
{
338-
ForceRender();
338+
Render(force: true);
339339
}
340340

341341
// Only run validation if we haven't before. If we have and status line shows an error,

PSReadLine/Options.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,12 @@ private void SetOptionsInternal(SetPSReadLineOption options)
189189
{
190190
Options.ScreenReader = options.ScreenReader;
191191

192-
// Disable prediction for better accessibility
193192
if (Options.ScreenReader)
194193
{
194+
// Disable prediction for better accessibility.
195195
Options.PredictionSource = PredictionSource.None;
196+
// Disable continuation prompt as multi-line is not available.
197+
Options.ContinuationPrompt = "";
196198
}
197199
}
198200
}

PSReadLine/Render.Helper.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ internal static int LengthInBufferCells(char c)
9696
if (c < 256)
9797
{
9898
// We render ^C for Ctrl+C, so return 2 for control characters
99+
// TODO: Do we care about this under a screen reader?
99100
return Char.IsControl(c) ? 2 : 1;
100101
}
101102

PSReadLine/Render.cs

Lines changed: 142 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -218,36 +218,155 @@ private void RenderWithPredictionQueryPaused()
218218
Render();
219219
}
220220

221-
private void Render()
221+
private void Render(bool force = false)
222222
{
223223
// If there are a bunch of keys queued up, skip rendering if we've rendered very recently.
224224
long elapsedMs = _lastRenderTime.ElapsedMilliseconds;
225-
if (_queuedKeys.Count > 10 && elapsedMs < 50)
226-
{
227-
// We won't render, but most likely the tokens will be different, so make
228-
// sure we don't use old tokens, also allow garbage to get collected.
229-
_tokens = null;
230-
_ast = null;
231-
_parseErrors = null;
232-
_waitingToRender = true;
233-
return;
225+
if (!force)
226+
{
227+
if (_queuedKeys.Count > 10 && elapsedMs < 50)
228+
{
229+
// We won't render, but most likely the tokens will be different, so make
230+
// sure we don't use old tokens, also allow garbage to get collected.
231+
_tokens = null;
232+
_ast = null;
233+
_parseErrors = null;
234+
_waitingToRender = true;
235+
return;
236+
}
237+
238+
// If we've rendered very recently, skip the terminal window resizing check as it's unlikely
239+
// to happen in such a short time interval.
240+
// We try to avoid unnecessary resizing check because it requires getting the cursor position
241+
// which would force a network round trip in an environment where front-end xtermjs talking to
242+
// a server-side PTY via websocket. Without querying for cursor position, content written on
243+
// the server side could be buffered, which is much more performant.
244+
// See the following 2 GitHub issues for more context:
245+
// - https://github.com/PowerShell/PSReadLine/issues/3879#issuecomment-2573996070
246+
// - https://github.com/PowerShell/PowerShell/issues/24696
247+
if (elapsedMs < 50)
248+
{
249+
_handlePotentialResizing = false;
250+
}
234251
}
235252

236-
// If we've rendered very recently, skip the terminal window resizing check as it's unlikely
237-
// to happen in such a short time interval.
238-
// We try to avoid unnecessary resizing check because it requires getting the cursor position
239-
// which would force a network round trip in an environment where front-end xtermjs talking to
240-
// a server-side PTY via websocket. Without querying for cursor position, content written on
241-
// the server side could be buffered, which is much more performant.
242-
// See the following 2 GitHub issues for more context:
243-
// - https://github.com/PowerShell/PSReadLine/issues/3879#issuecomment-2573996070
244-
// - https://github.com/PowerShell/PowerShell/issues/24696
245-
if (elapsedMs < 50)
253+
// Use simplified rendering for screen readers
254+
if (Options.ScreenReader)
246255
{
247-
_handlePotentialResizing = false;
256+
SafeRender();
248257
}
258+
else
259+
{
260+
ForceRender();
261+
}
262+
}
263+
264+
private void SafeRender()
265+
{
266+
int bufferWidth = _console.BufferWidth;
267+
int bufferHeight = _console.BufferHeight;
268+
269+
static int FindCommonPrefixLength(string leftStr, string rightStr)
270+
{
271+
if (string.IsNullOrEmpty(leftStr) || string.IsNullOrEmpty(rightStr))
272+
{
273+
return 0;
274+
}
249275

250-
ForceRender();
276+
int i = 0;
277+
int minLength = Math.Min(leftStr.Length, rightStr.Length);
278+
279+
while (i < minLength && leftStr[i] == rightStr[i])
280+
{
281+
i++;
282+
}
283+
284+
return i;
285+
}
286+
287+
// For screen readers, we are just comparing the previous and current buffer text
288+
// (without colors) and only writing the differences.
289+
string currentBuffer = ParseInput();
290+
string previousBuffer = _previousRender.lines[0].Line;
291+
292+
// In case the buffer was resized.
293+
RecomputeInitialCoords(isTextBufferUnchanged: false);
294+
295+
// Make cursor invisible while we're rendering.
296+
_console.CursorVisible = false;
297+
298+
// Calculate what to render and where to start the rendering.
299+
// TODO: Short circuit optimization when currentBuffer == previousBuffer.
300+
int commonPrefixLength = FindCommonPrefixLength(previousBuffer, currentBuffer);
301+
302+
if (commonPrefixLength > 0 && commonPrefixLength == previousBuffer.Length)
303+
{
304+
// Previous buffer is a complete prefix of current buffer.
305+
// Just append the new data.
306+
var appendedData = currentBuffer.Substring(commonPrefixLength);
307+
_console.Write(appendedData);
308+
}
309+
else if (commonPrefixLength > 0)
310+
{
311+
// Buffers share a common prefix but previous buffer has additional content.
312+
// Move cursor to where the difference starts, clear forward, and write the data.
313+
var diffPoint = ConvertOffsetToPoint(commonPrefixLength);
314+
_console.SetCursorPosition(diffPoint.X, diffPoint.Y);
315+
var changedData = currentBuffer.Substring(commonPrefixLength);
316+
_console.Write("\x1b[0J");
317+
_console.Write(changedData);
318+
}
319+
else
320+
{
321+
// No common prefix, rewrite entire buffer.
322+
_console.SetCursorPosition(_initialX, _initialY);
323+
_console.Write("\x1b[0J");
324+
_console.Write(currentBuffer);
325+
}
326+
327+
// If we had to wrap to render everything, update _initialY
328+
var endPoint = ConvertOffsetToPoint(currentBuffer.Length);
329+
int physicalLine = endPoint.Y - _initialY;
330+
if (_initialY + physicalLine > bufferHeight)
331+
{
332+
// We had to scroll to render everything, update _initialY.
333+
_initialY = bufferHeight - physicalLine;
334+
}
335+
336+
// Preserve the current render data.
337+
var renderData = new RenderData
338+
{
339+
lines = new RenderedLineData[] { new(currentBuffer, isFirstLogicalLine: true) },
340+
errorPrompt = (_parseErrors != null && _parseErrors.Length > 0) // Not yet used.
341+
};
342+
_previousRender = renderData;
343+
344+
// Calculate the coord to place the cursor for the next input.
345+
var point = ConvertOffsetToPoint(_current);
346+
347+
if (point.Y == bufferHeight)
348+
{
349+
// The cursor top exceeds the buffer height and it hasn't already wrapped,
350+
// so we need to scroll up the buffer by 1 line.
351+
if (point.X == 0)
352+
{
353+
_console.Write("\n");
354+
}
355+
356+
// Adjust the initial cursor position and the to-be-set cursor position
357+
// after scrolling up the buffer.
358+
_initialY -= 1;
359+
point.Y -= 1;
360+
}
361+
362+
_console.SetCursorPosition(point.X, point.Y);
363+
_console.CursorVisible = true;
364+
365+
_previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y);
366+
_previousRender.initialY = _initialY;
367+
368+
_lastRenderTime.Restart();
369+
_waitingToRender = false;
251370
}
252371

253372
private void ForceRender()
@@ -261,7 +380,7 @@ private void ForceRender()
261380
// and minimize writing more than necessary on the next render.)
262381

263382
var renderLines = new RenderedLineData[logicalLineCount];
264-
var renderData = new RenderData {lines = renderLines};
383+
var renderData = new RenderData { lines = renderLines };
265384
for (var i = 0; i < logicalLineCount; i++)
266385
{
267386
var line = _consoleBufferLines[i].ToString();

0 commit comments

Comments
 (0)