From 590cffe0479bee96c3a84dc287a952f85a9e631c Mon Sep 17 00:00:00 2001 From: Weiyi Shi Date: Wed, 12 Aug 2026 15:05:46 -0400 Subject: [PATCH] Fix the input anchor column when the buffer is narrowed and then widened When the buffer width changes, 'RecomputeInitialCoords' recovers the column of the edit anchor with _initialX %= _console.BufferWidth; '_initialX' is the anchor's column at the width that was in effect before the resize, so it is already the prompt's cell width reduced modulo that width. Reducing it a second time gives the right answer the first time the buffer is narrowed past the prompt, but it discards how many physical lines the prompt spans, and that is never recovered: a 36-cell prompt narrowed to a width of 35 leaves '_initialX' at 1, and widening back to 100 computes 1 % 100 == 1. Every subsequent render of a non-empty input is then written one column into the prompt, overwriting it, and the text drawn at the narrow width is left behind on the screen. Keep the width-independent quantity instead. '_initialPromptCells' is captured wherever the anchor is captured and is never modified afterwards, and '_initialX' is derived from it on every buffer width change. Narrowing behaves exactly as before, and widening now restores the anchor, including across several successive resizes. This does not cover a prompt that was already wider than the buffer when 'ReadLine' was entered. 'CursorLeft' is the only observation available in that case and it is already reduced, so the prompt's width cannot be recovered without re-invoking the user's prompt function. That case behaves as it did before. Related to #3637 --- PSReadLine/ReadLine.cs | 2 ++ PSReadLine/Render.cs | 28 ++++++++++++--- test/ResizingTest.cs | 82 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/PSReadLine/ReadLine.cs b/PSReadLine/ReadLine.cs index da890bbba..d075f23a8 100644 --- a/PSReadLine/ReadLine.cs +++ b/PSReadLine/ReadLine.cs @@ -784,6 +784,7 @@ private void Initialize(Runspace runspace, EngineIntrinsics engineIntrinsics) _parseErrors = null; _inputAccepted = false; _initialX = _console.CursorLeft; + _initialPromptCells = _initialX; _initialY = _console.CursorTop; _initialForeground = _console.ForegroundColor; _initialBackground = _console.BackgroundColor; @@ -1105,6 +1106,7 @@ public static void InvokePrompt(ConsoleKeyInfo? key = null, object arg = null) console.Write(newPrompt); _singleton._initialX = console.CursorLeft; + _singleton._initialPromptCells = _singleton._initialX; _singleton._initialY = console.CursorTop; _singleton._previousRender = _initialPrevRender; _singleton._previousRender.UpdateConsoleInfo(console); diff --git a/PSReadLine/Render.cs b/PSReadLine/Render.cs index 253f7fbef..81c7d5d51 100644 --- a/PSReadLine/Render.cs +++ b/PSReadLine/Render.cs @@ -166,6 +166,18 @@ struct LineInfoForRendering }; private int _initialX; private int _initialY; + + /// + /// The width, in buffer cells, of the last logical line of the prompt, measured from column 0 + /// of the physical line where that logical line starts. + /// This does not depend on the buffer width, whereas '_initialX' is the column of the same + /// point at the current buffer width, and hence is only ever this value modulo that width. + /// We keep it so that '_initialX' can be recomputed after the buffer width changes: reducing + /// '_initialX' in place would discard how many physical lines the prompt spans, and the + /// column could then never be recovered when the buffer is made wider again. + /// + private int _initialPromptCells; + private bool _waitingToRender; private bool _handlePotentialResizing; @@ -885,6 +897,7 @@ private void CalculateWhereAndWhatToRender(bool cursorMovedToInitialPos, RenderD } _initialX = _console.CursorLeft; + _initialPromptCells = _initialX; _initialY = _console.CursorTop; _previousRender = _initialPrevRender; } @@ -1244,6 +1257,7 @@ private void RecomputeInitialCoords(bool isTextBufferUnchanged) } _initialX = _console.CursorLeft; + _initialPromptCells = _initialX; _initialY = _console.CursorTop; _previousRender = _initialPrevRender; } @@ -1257,8 +1271,11 @@ private void RecomputeInitialCoords(bool isTextBufferUnchanged) // The '_buffer' and '_current' still reflects what has been rendered on the screen, // so we can use them to re-calculate the initial coordinates in this case. - // Recompute X from the buffer width: - _initialX %= _console.BufferWidth; + // Recompute X from the prompt's cell width, which doesn't change with the buffer width. + // Reducing '_initialX' in place instead gives the same result for the first narrowing, + // but loses how many physical lines the prompt spans, so the column could not be + // recovered when the buffer is made wider again. + _initialX = _initialPromptCells % _console.BufferWidth; // Recompute Y from the cursor _initialY = 0; @@ -1293,8 +1310,11 @@ private void RecomputeInitialCoords(bool isTextBufferUnchanged) throw new InvalidOperationException(message); } - // Recompute X from the buffer width: - _initialX %= _console.BufferWidth; + // Recompute X from the prompt's cell width, which doesn't change with the buffer width. + // Reducing '_initialX' in place instead gives the same result for the first narrowing, + // but loses how many physical lines the prompt spans, so the column could not be + // recovered when the buffer is made wider again. + _initialX = _initialPromptCells % _console.BufferWidth; // Recompute Y from the cursor _initialY = 0; diff --git a/test/ResizingTest.cs b/test/ResizingTest.cs index a02ed783a..d4459b766 100644 --- a/test/ResizingTest.cs +++ b/test/ResizingTest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Reflection; +using System.Text; using Microsoft.PowerShell; using Newtonsoft.Json; using Xunit; @@ -182,5 +183,86 @@ public void PhysicalLineCountMethod_ShouldWork() } } } + + private static FieldInfo GetInstanceField(string name) + { + return typeof(PSConsoleReadLine).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + } + + [Fact] + public void RecomputeInitialCoords_ShouldRecoverInitialXWhenBufferGetsWider() + { + // The column of the initial coordinates is the width of the prompt reduced modulo the + // buffer width, so a prompt of 36 cells walked through the buffer widths below has to + // give 36, 1, 36, 11 and 36 in turn. Reducing the column in place on each change gives + // the right answer only for the first one, because it discards how many physical lines + // the prompt spans and the column can then no longer be recovered. + // + // Only the column is checked here. Recovering the row relies on the terminal having + // reflowed the screen buffer, which the test console does not do. + const int promptCells = 36; + const int bufferHeight = 100; + int[] bufferWidths = { 100, 35, 60, 25, 100 }; + + PSConsoleReadLine instance = GetPSConsoleReadLineSingleton(); + FieldInfo consoleField = GetInstanceField("_console"); + FieldInfo bufferField = GetInstanceField("_buffer"); + FieldInfo currentField = GetInstanceField("_current"); + FieldInfo initialXField = GetInstanceField("_initialX"); + FieldInfo initialYField = GetInstanceField("_initialY"); + FieldInfo initialPromptCellsField = GetInstanceField("_initialPromptCells"); + FieldInfo previousRenderField = GetInstanceField("_previousRender"); + FieldInfo handlePotentialResizingField = GetInstanceField("_handlePotentialResizing"); + MethodInfo recomputeInitialCoords = typeof(PSConsoleReadLine) + .GetMethod("RecomputeInitialCoords", BindingFlags.Instance | BindingFlags.NonPublic); + + object savedConsole = consoleField.GetValue(instance); + object savedBuffer = bufferField.GetValue(instance); + object savedCurrent = currentField.GetValue(instance); + object savedPreviousRender = previousRenderField.GetValue(instance); + + try + { + // An empty input keeps the initial row at 0 throughout, so a plain test console is + // all that is needed to report each new buffer width. + bufferField.SetValue(instance, new StringBuilder()); + currentField.SetValue(instance, 0); + initialPromptCellsField.SetValue(instance, promptCells); + initialXField.SetValue(instance, promptCells % bufferWidths[0]); + initialYField.SetValue(instance, 0); + + RenderData previousRender = new() + { + lines = new[] { new RenderedLineData(line: "", isFirstLogicalLine: true) } + }; + + foreach (int bufferWidth in bufferWidths) + { + TestConsole console = new(_, bufferWidth, bufferHeight); + consoleField.SetValue(instance, console); + + previousRender.initialY = (int)initialYField.GetValue(instance); + previousRenderField.SetValue(instance, previousRender); + handlePotentialResizingField.SetValue(instance, true); + + recomputeInitialCoords.Invoke(instance, new object[] { true }); + + int initialX = (int)initialXField.GetValue(instance); + Assert.True( + promptCells % bufferWidth == initialX, + $"buffer width {bufferWidth}: initial column is {initialX} but should be {promptCells % bufferWidth}"); + + // The render data now describes the buffer as it was before the next change. + previousRender.UpdateConsoleInfo(console); + } + } + finally + { + consoleField.SetValue(instance, savedConsole); + bufferField.SetValue(instance, savedBuffer); + currentField.SetValue(instance, savedCurrent); + previousRenderField.SetValue(instance, savedPreviousRender); + } + } } }