Fix the input anchor column when the buffer is narrowed and then widened - #5191
Open
lulu-loopp wants to merge 2 commits into
Open
Fix the input anchor column when the buffer is narrowed and then widened#5191lulu-loopp wants to merge 2 commits into
lulu-loopp wants to merge 2 commits into
Conversation
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 PowerShell#3637
3 tasks
Author
|
@microsoft-github-policy-service agree |
'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 PowerShell#3637
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Summary
When the terminal is made narrower than the prompt and then wider again, PSReadLine
draws the input on top of the prompt and leaves the text it drew at the narrow width
behind on the screen. The edit anchor is never restored for the rest of that
ReadLinecall, so every subsequent keystroke re-renders in the wrong column.This changes
RecomputeInitialCoordsto stop reducing the anchor's column in placeand to recover it from the physical cursor instead.
Two commits:
Derive the column from
_initialPromptCells, a width-independent quantity, ratherthan reducing
_initialXmodulo the new width._initialPromptCellsis itself seeded from a column the console reported, so it isalready reduced whenever
ReadLinestarts on a buffer narrower than the prompt.The cursor is the only observation that survives a reflow, so the anchor is taken
to be the column that would put the cursor where the console says it is, with the
derived column demoted to a starting point and a fallback.
The second commit supersedes the modulo arithmetic introduced by the first for the
cases the cursor can speak to, and keeps it for the cases it cannot.
Repro
Get-ChildItem -Recurse -Filter *.rs | Select-Object FullName-- and do not press Enter.The input wraps onto the following rows and is rendered correctly.
Expected: the input is rendered starting at column 36, right after the prompt.
Actual: the input is rendered starting at column 1, over the prompt:
Screen after step 4, on 2.4.5:
and with this change:
Rows 2 and 3 are what was drawn while the window was narrow. They are not cleaned up
in either case -- the prompt on row 1 is what this change is about.
Root cause
RecomputeInitialCoordsrecovers the anchor column after a buffer width change witha single statement, in both of its branches:
Render.cs#L1261(isTextBufferUnchanged: true)Render.cs#L1297(isTextBufferUnchanged: false)_initialXis the anchor's column at the width that was in effect before theresize, so it already is the prompt's cell width reduced modulo that width.
Reducing it a second time is correct the first time the buffer is narrowed past the
prompt, but it discards the quotient, and the quotient is the only record of how many
physical lines the prompt spans. Once it is gone the prompt's true width cannot be
reconstructed:
_initialXbefore_initialXafter36 % 35= 11 % 100= 1The statement dates back to b2979d1 ("Fix rendering after buffer resize", 2017) and is
present unchanged in every release from 2.0.0 through 2.4.5.
The first fix: keep the width-independent quantity
_initialPromptCellsis the cell width of the prompt's last logical line, measuredfrom column 0 of the physical line where that logical line starts. It is captured
wherever the anchor is captured -- input initialization in
ReadLine.cs,InvokePrompt, and the two prompt-reprint recovery paths inRender.cs-- and isnever modified afterwards.
_initialXis then derived from it on every buffer widthchange:
While the prompt fits in the buffer,
_initialPromptCells == _initialXand the newexpression is the identity, so nothing changes for the common case.
A second defect the first fix missed
_initialPromptCellsis seeded from_initialX, which is the column the consolereported when
ReadLinestarted. That column is the prompt's cell width alreadyreduced modulo the width in effect at the time. So when
ReadLinestarts on a buffernarrower than the prompt, the seed is not the prompt's width but the reduced column,
and the derivation above reproduces the reduced column forever:
_initialPromptCellsReadLinestarts82 % 62= 2020 % 82= 20This is the case listed as not covered in the first version of this PR. It turns out to
be reachable without the terminal ever being resized by hand -- opening a split pane, a
preview pane, or any window that starts narrow puts
ReadLineon a buffer narrowerthan the prompt -- and its symptom does not look like a resize bug, because nothing
renders while the input is empty.
Recipe
A prompt of 82 cells,
ReadLineentered at a buffer width of 62, widened to 82, thenone history entry of 36 characters recalled with Up and blanked with
Down. No further resize and no typing.
The first render of the recalled entry starts at column 20 instead of column 82, and
Down then blanks exactly that span, carving a hole out of the middle of the
prompt with its head and tail intact:
and the emitted sequences say the same thing --
CUPto row 3 column 21 versusCUPto row 3 column 82:
The second fix: recover the anchor from the cursor
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 the anchor is taken 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.
cursorPointFromis where the cursor would be drawn if the anchor were at the givencolumn of line 0, which is what the two branches of
RecomputeInitialCoordsalreadycomputed:
ConvertOffsetToPoint(_current)when the text buffer still describes what ison the screen, and
ConvertRenderDataOffsetToPoint(...)when it does not.With an empty input this makes the anchor the cursor itself, which is exactly what
capturing the initial coordinates would have given had
ReadLinebeen entered at thenew width -- including the case above, which no arithmetic on
_initialXcan recover.The assumption that the cursor still points at the same character of the input after a
reflow is the one
_initialYis already recovered from today.The multi-line pitfall that shaped it
The cursor does not always tell the columns apart, and reading it as an unconditional
answer is worse than the defect it fixes.
A newline in the input moves the rendering to the continuation prompt's column no
matter where the anchor is:
ConvertOffsetToPointresets the column at every logicalline break, so once the cursor is past one, every candidate column produces the same
cursor point. Measuring the offset from column 0 and subtracting it from the cursor
therefore yields column 0 for any multi-line input, on every resize -- the same
overwritten prompt this PR is about, newly inflicted on prompts that always fitted the
buffer. Separately, a double width character pushed whole onto the next physical line
leaves a cell of slack, so two neighbouring columns can agree with the cursor.
So the column derived from
_initialPromptCellsis where the search starts and is theanswer whenever the cursor agrees with it; only when the cursor disagrees does the
search step outward for the nearest column that agrees, and if none does, the derived
column stands. A candidate that would place the cursor above the anchor is rejected,
since that is not a state
ReadLinecan be in. This makes the second commit arefinement of the first rather than a replacement for it, and leaves every input the
cursor says nothing about rendering exactly as it does today.
_initialPromptCellsis deliberately left as it is once the anchor is known. What itholds over
_initialXis how many physical lines the prompt spans at the width it wascaptured at, which the cursor never reveals, so rewriting it from a column observed at
a different width would mix two units.
Behavior boundaries
are equal by definition, and for later ones the new expression is what the old one
was trying to compute.
(100 -> 35 -> 60 -> 25 -> 100 was measured).
ReadLinewas entered is now coveredas long as the cursor can distinguish the column, which it can whenever the cursor is
on the first logical line of the input. It is not covered for a cursor past a
newline, where every column agrees with the cursor and the derived column, still
reduced, is the best available answer.
the console reports a wrap-pending cursor at the last column rather than at column 0
of the next line, and the Console API does not expose the wrap-pending bit. That is
the same reading
ReadLinemakes when it captures the initial coordinates in thefirst place, so the recovered anchor has exactly the fidelity a fresh
ReadLineatthe new width would have had.
Verification
Unit tests
Six tests in
test/ResizingTest.cs, five of them added by the second commit. Theydrive
RecomputeInitialCoordsdirectly across a chain of buffer widths, setting thetest console's cursor at each width to where a terminal that reflowed the screen would
report it, and assert the recovered anchor against where the reflow left it:
..._ShouldRecoverInitialXWhenBufferGetsWider..._ShouldRecoverInitialXWhenThePromptDidNotFitTheInitialBuffer..._ShouldRecoverInitialXWithTextOnTheInputLine..._ShouldKeepWorkingWhenThePromptFitsTheBuffer..._ShouldRecoverInitialXWithAMultiLineInput..._ShouldRecoverInitialXFromRenderDataWhenTheInputChangedisTextBufferUnchanged: falsebranchThe rendering the tests place the cursor from is the forward direction of the
calculation under test, which makes them a test of the inverse -- given a rendering and
where it ended up on the screen, where does the anchor have to be -- rather than a
restatement of it.
Three of the six fail on the first commit alone. The multi-line one fails on a version
of the second commit that reads the cursor without checking it against the derived
column, which is what put the check there.
Full suite on this branch (
./build.ps1 -Test -Configuration Release, net8.0):Only the en-US layout is installed on my machine, so the rest of the layout-gated
SkippableFacts are skipped here; CI covers those.Against a real terminal
The unit tests cannot show that PSReadLine then renders where it says it will, so both
commits were also measured end to end through a ConPTY pseudoconsole, on Windows 11
26200.
For the first commit, a probe writes keys, resizes the pseudoconsole, and parses the
emitted
CUPsequences to read back the column PSReadLine actually renders at. Prompt= 36 cells, input = 60 characters. The last column is the first commit applied on top of
the v2.4.5 tag, so that it could be loaded next to the shipped module for a like-for-like
comparison. Each case starts at the first width listed, resizes as listed, and types one
character at each width.
ReadLinestartedWindows PowerShell 5.1 with 2.4.5 side-loaded measures the same as the two baseline
columns. The last row is the second defect above, and is what the second commit
addresses.
For the second commit, the recipe under "A second defect the first fix missed" was run
against a real
pwsh.exeover ConPTY, five runs per arm, comparing the child's ownconsole text buffer read back through the console API rather than the emitted sequences:
The instrumented recovery, one line per call:
One cell of the prompt's trailing space is still blanked, which is the wrap-pending
reading described under "Behavior boundaries" -- one cell, against 36 carved cells
before.
Editing regression matrix
Fourteen resize-then-edit scenarios (type, repeated type,
Escape,Backspace,Home,Homethen type,LeftArrowthen type,UpArrow, multi-line input; eachnarrowing and widening; short and medium prompts) were run against official 2.4.5 and
against the patched build. The two transcripts -- render columns, cursor moves, and
final screen contents -- are identical byte for byte, including the three
scenarios that already drifted before this change (
LeftArrowthen type, and the twomulti-line cases). Those three are separate pre-existing problems and are untouched
here.
Relationship to #3074
#3074 (f46f15d, first released in
v2.2.0-beta5) rewrote
RecomputeInitialCoordsaround the render data, and fixed thecases where the text buffer had changed across the resize. It carried this statement
forward unchanged -- it only reformatted
_initialX = _initialX % ...to_initialX %= ...and duplicated it into the newelsebranch -- so the anchor column has hadthe same defect before and after that work. That is consistent with 2.0.0 and 2.4.5
measuring identically in the table above.
Remaining work
The prompt string itself is available in
InvokePromptand in the prompt-reprintrecovery paths, so
_initialPromptCellscould be measured there directly instead ofbeing seeded from
_initialX. That would make the derived column correct on those twoentry paths even where the cursor cannot distinguish it, and would leave only the normal
entry path relying on the cursor. It is not folded in here because it would make the
field mean one thing on some paths and another on the rest; happy to add it if you would
like it handled in this PR.
Notes
Found while building a terminal on Windows, where this was reproducible against the
inbox PSReadLine 2.0.0 as well as current 2.4.5. The second defect surfaced there as a
history recall carving a hole in the prompt with no resize in sight, which is why the
recipe above is written in terms of Up and Down rather than a
window drag.
PR Checklist
the VS Code integrated terminal take, on both Windows PowerShell 5.1 and pwsh
7.6.4. I have not measured on macOS or Linux; the change reads
CursorLeft,CursorTopandBufferWidthand has no platform-specific part.Related to #3637