From 590cffe0479bee96c3a84dc287a952f85a9e631c Mon Sep 17 00:00:00 2001 From: Weiyi Shi Date: Wed, 12 Aug 2026 15:05:46 -0400 Subject: [PATCH 1/2] 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); + } + } } } From 8fba17e303c5013750466227fa7ad33b64645937 Mon Sep 17 00:00:00 2001 From: Weiyi Shi Date: Tue, 18 Aug 2026 05:20:48 -0400 Subject: [PATCH 2/2] Recover the edit anchor from the cursor when the buffer width changes 'RecomputeInitialCoords' recovers the anchor's column with _initialX = _initialPromptCells % _console.BufferWidth; and '_initialPromptCells' is seeded from '_initialX', which is the column the console reported when 'ReadLine' started. That column is the prompt's cell width already reduced modulo the width in effect at the time, so when 'ReadLine' starts on a buffer narrower than the prompt the seed is the reduced column and not the prompt's width: a prompt of 82 cells entered at a width of 62 seeds 20, and widening to 82 recovers 20 % 82 == 20 rather than the anchor. Nothing shows while the input is empty. The first render of a non-empty input is then written 62 cells into the prompt, and the next one blanks exactly that span. Reducing '_initialX' in place, which is what this replaced, is wrong in the same way and for the same reason. The anchor is not observable after a resize - the terminal reflowed the screen and reported nothing about where it moved the prompt to - but the cursor is, and the terminal moved the two together. So take the anchor to be at the column that would put the cursor where the console says the cursor is, and at the row that many physical lines above the cursor's. With an empty input that is the cursor itself, which is exactly what capturing the initial coordinates would have given had 'ReadLine' been entered at the new width; the assumption that the cursor still points at the same character of the input after a reflow is the one '_initialY' is already recovered from. The cursor does not always tell the columns apart. A newline in the input moves the rendering to the continuation prompt's column no matter where the anchor is, so past one, every column agrees with the cursor; and a double width character pushed whole onto the next physical line leaves a cell of slack, so two neighbouring columns can agree. The column derived from '_initialPromptCells' is therefore where the search starts and the answer whenever the cursor agrees with it, which leaves every input the cursor says nothing about rendering as it does today, and makes this a refinement of that derivation rather than a replacement for it. Taking the cursor's own column as the offset to subtract, without that check, would put the anchor at column 0 for every multi-line input on every resize - the same overwritten prompt, on prompts that always fitted the buffer. '_initialPromptCells' is left alone once the anchor is known. What it holds over '_initialX' is how many physical lines the prompt spans at the width it was captured at, which the cursor never reveals, so rewriting it from a column observed at a different width would mix two units. Related to #3637 --- PSReadLine/Render.cs | 130 ++++++++++++++----- test/ResizingTest.cs | 291 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 367 insertions(+), 54 deletions(-) diff --git a/PSReadLine/Render.cs b/PSReadLine/Render.cs index 81c7d5d51..fd8ce1e35 100644 --- a/PSReadLine/Render.cs +++ b/PSReadLine/Render.cs @@ -172,9 +172,11 @@ struct LineInfoForRendering /// 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. + /// It is what we believe the anchor's column to be after a buffer width change, and it is only + /// a belief: it is seeded from '_initialX', so it is itself already reduced whenever 'ReadLine' + /// starts on a buffer narrower than the prompt. 'RecomputeInitialCoordsFromCursor' checks it + /// against the physical cursor, takes a column the cursor agrees with when it disagrees, and + /// falls back to it when the cursor cannot tell the columns apart. /// private int _initialPromptCells; @@ -1271,18 +1273,16 @@ 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 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; - // Calculate the new cursor position when assuming '_initialY' is at line 0. - var pt = ConvertOffsetToPoint(_current); - // Update '_initialY' based on the difference from the actual current cursor position after the resize. - _initialY = _console.CursorTop - pt.Y; + // Recompute both coordinates from the cursor. '_buffer' and '_current' say where the + // cursor is relative to the initial coordinates, and the console says where the cursor + // is on the screen; the difference between the two is the initial coordinates. + RecomputeInitialCoordsFromCursor(column => + { + // Put the anchor at 'column' of line 0 and render the input from there. + _initialX = column; + _initialY = 0; + return ConvertOffsetToPoint(_current); + }); } else { @@ -1310,24 +1310,98 @@ private void RecomputeInitialCoords(bool isTextBufferUnchanged) throw new InvalidOperationException(message); } - // 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; - // Now, use the new initial coordinates, new buffer width, and the rendering data offset to calculate - // the new cursor position when assuming '_initialY' is at line 0. - Point pt = ConvertRenderDataOffsetToPoint(_initialX, _initialY, _console.BufferWidth, _previousRender, offset); - // Update '_initialY' based on the difference from the actual current cursor position after the resize. + // Recompute both coordinates from the cursor, the same way as above, except that the + // rendering data offset stands in for '_buffer' and '_current'. // This is based on the assumption that the cursor is still pointing to the same character after resizing, // or at least pointing to the physical line where the same character is located after resizing. // However, that assumption is not always guaranteed in Windows Terminal, see the issue: // https://github.com/microsoft/terminal/issues/10848, and // https://github.com/microsoft/terminal/issues/10868 - _initialY = _console.CursorTop - pt.Y; + RecomputeInitialCoordsFromCursor( + column => ConvertRenderDataOffsetToPoint(column, 0, _console.BufferWidth, _previousRender, offset)); + } + } + + /// + /// Recompute the initial coordinates - the cell the edit line is anchored at - after the + /// buffer width changed, from where the console now says the cursor is. + /// + /// + /// Where the cursor would be drawn if the anchor were at the given column of line 0. That is + /// the rendering of the current input, so its distance from the anchor is the display cell + /// offset from the anchor to the cursor. + /// + /// + /// The anchor cannot be observed after a resize: the terminal reflowed the screen and said + /// nothing about where it moved the prompt to. The cursor can be observed, and the terminal + /// moved the two together, so the anchor is at the column that would put the cursor where the + /// console says the cursor is, and at the row that many physical lines above the cursor's. + /// + /// With an empty input this makes the anchor the cursor, which is exactly what capturing the + /// initial coordinates at the start of 'ReadLine' would have given had 'ReadLine' been entered + /// at the new width - including the case where the prompt is wider than the buffer, which is + /// the case no arithmetic on '_initialX' can recover, because '_initialX' is by then already + /// the prompt's cell width reduced modulo the width it was captured at. + /// + /// The cursor does not always tell the columns apart. A newline in the input moves the + /// rendering to the continuation prompt's column no matter where the anchor is, so once the + /// cursor is past one, every column agrees with it; and a double width character pushed whole + /// onto the next physical line leaves a cell of slack, so two neighbouring columns can agree. + /// '_initialPromptCells' is the belief we already hold about the column, so it is where the + /// search starts and the answer whenever the cursor agrees with it - which makes this a strict + /// refinement of deriving the column from the prompt's cell width alone, and leaves the + /// behaviour of every input the cursor says nothing about unchanged. + /// + private void RecomputeInitialCoordsFromCursor(Func cursorPointFrom) + { + int bufferWidth = _console.BufferWidth; + int cursorLeft = _console.CursorLeft; + int cursorTop = _console.CursorTop; + + int believedX = _initialPromptCells % bufferWidth; + Point believed = cursorPointFrom(believedX); + + int newX = believedX; + int newY = cursorTop - believed.Y; + + if (believed.X != cursorLeft || newY < 0) + { + // The belief is not consistent with the cursor, so take the nearest column that is. + // A cursor above the anchor is not a state we can be in, so a column that implies one + // is no more of an answer than a column that puts the cursor elsewhere entirely. + for (int delta = 1; delta < bufferWidth; delta++) + { + if (TryColumn(believedX - delta) || TryColumn(believedX + delta)) + { + break; + } + } + } + + _initialX = newX; + _initialY = newY; + + // '_initialPromptCells' is deliberately left as it is. What it holds over '_initialX' is + // how many physical lines the prompt spans at the width it was captured at, and the cursor + // never reveals that, so rewriting it here from a column observed at a different width + // would mix two units and lose the cases it does answer correctly today. + + bool TryColumn(int column) + { + if (column < 0 || column >= bufferWidth) + { + return false; + } + + Point candidate = cursorPointFrom(column); + if (candidate.X != cursorLeft || cursorTop < candidate.Y) + { + return false; + } + + newX = column; + newY = cursorTop - candidate.Y; + return true; } } diff --git a/test/ResizingTest.cs b/test/ResizingTest.cs index d4459b766..bedb1a1c7 100644 --- a/test/ResizingTest.cs +++ b/test/ResizingTest.cs @@ -189,20 +189,34 @@ private static FieldInfo GetInstanceField(string name) return typeof(PSConsoleReadLine).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); } - [Fact] - public void RecomputeInitialCoords_ShouldRecoverInitialXWhenBufferGetsWider() + /// + /// Drive 'RecomputeInitialCoords' through a chain of buffer widths and check that the anchor + /// it recovers at each width is the one the terminal's reflow put on the screen. + /// + /// + /// The cell width of the prompt's last logical line. The prompt is taken to start at column 0 + /// of a physical line, so at every buffer width the anchor is at row 'promptCells / width' and + /// column 'promptCells % width'. That is the only property of the prompt that matters here. + /// + /// The input as it stands on the screen when the buffer width changes. + /// Where '_current' points into that input. + /// + /// The width 'ReadLine' was entered at, followed by the widths the buffer is changed to. + /// + /// + /// The cursor the test console reports at each width is the one a terminal that reflowed the + /// screen would report: the anchor plus the rendering of the input up to '_current'. The + /// rendering is the forward direction of the very calculation under test, which is what makes + /// this a test of the inverse - given a rendering and where it ended up on the screen, where + /// does the anchor have to be - and not a restatement of it. + /// + private void AssertAnchorIsRecoveredAcrossWidths( + int promptCells, + string input, + int cursorOffset, + int[] bufferWidths) { - // 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 }; + const int bufferHeight = 200; PSConsoleReadLine instance = GetPSConsoleReadLineSingleton(); FieldInfo consoleField = GetInstanceField("_console"); @@ -219,38 +233,62 @@ public void RecomputeInitialCoords_ShouldRecoverInitialXWhenBufferGetsWider() object savedConsole = consoleField.GetValue(instance); object savedBuffer = bufferField.GetValue(instance); object savedCurrent = currentField.GetValue(instance); + object savedInitialX = initialXField.GetValue(instance); + object savedInitialY = initialYField.GetValue(instance); + object savedInitialPromptCells = initialPromptCellsField.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); + bufferField.SetValue(instance, new StringBuilder(input)); + currentField.SetValue(instance, cursorOffset); + + // What 'ReadLine' captured at the width it was entered at. '_initialX' is the column + // the console reported, which is the prompt's cell width reduced modulo that width, + // and '_initialPromptCells' is seeded from it - so the seed is the prompt's true cell + // width only when the prompt fitted the buffer it started on. + int entryWidth = bufferWidths[0]; + int heldX = promptCells % entryWidth; + int heldY = promptCells / entryWidth; + initialXField.SetValue(instance, heldX); + initialYField.SetValue(instance, heldY); + initialPromptCellsField.SetValue(instance, heldX); RenderData previousRender = new() { - lines = new[] { new RenderedLineData(line: "", isFirstLogicalLine: true) } + lines = new[] { new RenderedLineData(line: input, isFirstLogicalLine: true) } }; - foreach (int bufferWidth in bufferWidths) + for (int i = 1; i < bufferWidths.Length; i++) { + int bufferWidth = bufferWidths[i]; TestConsole console = new(_, bufferWidth, bufferHeight); consoleField.SetValue(instance, console); - previousRender.initialY = (int)initialYField.GetValue(instance); + // Where the reflow left the anchor, and hence where it left the cursor. + int expectedX = promptCells % bufferWidth; + int expectedY = promptCells / bufferWidth; + initialXField.SetValue(instance, expectedX); + initialYField.SetValue(instance, expectedY); + Point cursor = instance.ConvertOffsetToPoint(cursorOffset); + console.CursorLeft = cursor.X; + console.CursorTop = cursor.Y; + + // The coordinates PSReadLine actually holds are the ones from the previous width. + initialXField.SetValue(instance, heldX); + initialYField.SetValue(instance, heldY); + previousRender.initialY = heldY; previousRenderField.SetValue(instance, previousRender); handlePotentialResizingField.SetValue(instance, true); recomputeInitialCoords.Invoke(instance, new object[] { true }); - int initialX = (int)initialXField.GetValue(instance); + heldX = (int)initialXField.GetValue(instance); + heldY = (int)initialYField.GetValue(instance); Assert.True( - promptCells % bufferWidth == initialX, - $"buffer width {bufferWidth}: initial column is {initialX} but should be {promptCells % bufferWidth}"); + expectedX == heldX && expectedY == heldY, + $"prompt of {promptCells} cells, buffer width {bufferWidth}: the anchor was " + + $"recovered at ({heldX}, {heldY}) but the reflow left it at ({expectedX}, {expectedY})"); // The render data now describes the buffer as it was before the next change. previousRender.UpdateConsoleInfo(console); @@ -261,6 +299,207 @@ public void RecomputeInitialCoords_ShouldRecoverInitialXWhenBufferGetsWider() consoleField.SetValue(instance, savedConsole); bufferField.SetValue(instance, savedBuffer); currentField.SetValue(instance, savedCurrent); + initialXField.SetValue(instance, savedInitialX); + initialYField.SetValue(instance, savedInitialY); + initialPromptCellsField.SetValue(instance, savedInitialPromptCells); + previousRenderField.SetValue(instance, savedPreviousRender); + } + } + + [Fact] + public void RecomputeInitialCoords_ShouldRecoverInitialXWhenBufferGetsWider() + { + // The column of the anchor 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. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 36, + input: "", + cursorOffset: 0, + bufferWidths: new[] { 100, 35, 60, 25, 100 }); + } + + [Fact] + public void RecomputeInitialCoords_ShouldRecoverInitialXWhenThePromptDidNotFitTheInitialBuffer() + { + // 'ReadLine' is entered on a buffer narrower than the prompt, so the column the console + // reports - and therefore everything derived from it - is already reduced: 82 % 62 == 20. + // No arithmetic on that 20 can produce 82 again. The cursor can, because the terminal + // reflowed the prompt and the cursor together and the input is empty, which puts the + // cursor on the anchor itself. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 82, + input: "", + cursorOffset: 0, + bufferWidths: new[] { 62, 82 }); + + // The same, then on to widths the prompt does and does not fit, in both directions. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 82, + input: "", + cursorOffset: 0, + bufferWidths: new[] { 62, 100, 70, 120, 62 }); + } + + [Fact] + public void RecomputeInitialCoords_ShouldRecoverInitialXWithTextOnTheInputLine() + { + // The cursor is no longer on the anchor, so recovering the anchor means subtracting the + // rendering of the input from it. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 82, + input: "Get-ChildItem", + cursorOffset: 13, + bufferWidths: new[] { 62, 82 }); + + // Narrow to wide, with an input long enough to wrap at every width in the chain. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 82, + input: "Get-ChildItem -Path . -Recurse | Where-Object { $_.Length -gt 1024 }", + cursorOffset: 67, + bufferWidths: new[] { 62, 100, 130 }); + + // Wide to narrow, and with the cursor inside the input rather than at its end. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 36, + input: "Get-ChildItem -Path . -Recurse | Where-Object { $_.Length -gt 1024 }", + cursorOffset: 30, + bufferWidths: new[] { 120, 60, 40 }); + } + + [Fact] + public void RecomputeInitialCoords_ShouldKeepWorkingWhenThePromptFitsTheBuffer() + { + // The prompt fitted the buffer 'ReadLine' was entered on, so the belief about its cell + // width was never damaged and the cursor has to agree with it at every width. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 12, + input: "", + cursorOffset: 0, + bufferWidths: new[] { 80, 40, 120, 20, 80 }); + + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 12, + input: "Get-ChildItem -Path .", + cursorOffset: 21, + bufferWidths: new[] { 80, 40, 120, 20, 80 }); + } + + [Fact] + public void RecomputeInitialCoords_ShouldRecoverInitialXWithAMultiLineInput() + { + // A newline moves the rendering to the continuation prompt's column no matter where the + // anchor is, so past one the cursor says nothing about the anchor's column: taking the + // cursor's own column as the offset to subtract would put the anchor at column 0 and draw + // the first logical line over the prompt. The belief about the prompt's cell width is the + // answer here, and the cursor must be read as agreeing with it rather than replacing it. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 30, + input: "Get-ChildItem |\nForEach-Object { $_.Name }", + cursorOffset: 41, + bufferWidths: new[] { 80, 60, 25, 100 }); + + // With the cursor still on the first logical line, where the column is observable again. + AssertAnchorIsRecoveredAcrossWidths( + promptCells: 30, + input: "Get-ChildItem |\nForEach-Object { $_.Name }", + cursorOffset: 10, + bufferWidths: new[] { 80, 60, 25, 100 }); + } + + [Fact] + public void RecomputeInitialCoords_ShouldRecoverInitialXFromRenderDataWhenTheInputChanged() + { + // The other half of 'RecomputeInitialCoords': the input has changed since it was last + // rendered - 'Escape' cleared it after the resize, say - so the anchor has to be recovered + // from the previous rendering rather than from '_buffer' and '_current'. + const int promptCells = 82; + const int entryWidth = 62; + const int newWidth = 82; + const int bufferHeight = 200; + const string rendered = "Get-ChildItem -Path . -Recurse"; + + 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 savedInitialX = initialXField.GetValue(instance); + object savedInitialY = initialYField.GetValue(instance); + object savedInitialPromptCells = initialPromptCellsField.GetValue(instance); + object savedPreviousRender = previousRenderField.GetValue(instance); + + try + { + int heldX = promptCells % entryWidth; + int heldY = promptCells / entryWidth; + + // The input was cleared after the resize, which is what makes this the other branch. + bufferField.SetValue(instance, new StringBuilder()); + currentField.SetValue(instance, 0); + initialXField.SetValue(instance, heldX); + initialYField.SetValue(instance, heldY); + initialPromptCellsField.SetValue(instance, heldX); + + // The rendering as it stood at the entry width, with the cursor at the end of it. + RenderData previousRender = new() + { + lines = new[] { new RenderedLineData(rendered, isFirstLogicalLine: true) }, + bufferWidth = entryWidth, + bufferHeight = bufferHeight, + initialY = heldY, + }; + Point oldCursor = instance.ConvertRenderDataOffsetToPoint( + heldX, heldY, entryWidth, previousRender, new RenderDataOffset(0, int.MaxValue)); + previousRender.cursorLeft = oldCursor.X; + previousRender.cursorTop = oldCursor.Y; + previousRenderField.SetValue(instance, previousRender); + + // Where the reflow left the anchor, and the cursor that follows from it. + int expectedX = promptCells % newWidth; + int expectedY = promptCells / newWidth; + RenderDataOffset offset = instance.ConvertPointToRenderDataOffset(heldX, heldY, previousRender); + Assert.NotEqual(-1, offset.LogicalLineIndex); + Point newCursor = instance.ConvertRenderDataOffsetToPoint( + expectedX, expectedY, newWidth, previousRender, offset); + + TestConsole console = new(_, newWidth, bufferHeight) + { + CursorLeft = newCursor.X, + CursorTop = newCursor.Y, + }; + consoleField.SetValue(instance, console); + handlePotentialResizingField.SetValue(instance, true); + + recomputeInitialCoords.Invoke(instance, new object[] { false }); + + int initialX = (int)initialXField.GetValue(instance); + int initialY = (int)initialYField.GetValue(instance); + Assert.True( + expectedX == initialX && expectedY == initialY, + $"buffer width {newWidth}: the anchor was recovered at ({initialX}, {initialY}) " + + $"but the reflow left it at ({expectedX}, {expectedY})"); + } + finally + { + consoleField.SetValue(instance, savedConsole); + bufferField.SetValue(instance, savedBuffer); + currentField.SetValue(instance, savedCurrent); + initialXField.SetValue(instance, savedInitialX); + initialYField.SetValue(instance, savedInitialY); + initialPromptCellsField.SetValue(instance, savedInitialPromptCells); previousRenderField.SetValue(instance, savedPreviousRender); } }