Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions src/XTerm.NET.Tests/Buffer/ResizeEdgeCaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,143 @@ public void ZeroRowBuffer_IsUsableAfterResize()

Assert.True(buffer.Lines.Length > 0, $"Lines.Length was {buffer.Lines.Length}");
}

/// <summary>
/// A shrink that was following the tail keeps following it, with nothing stranded below.
/// </summary>
/// <remarks>
/// The screen is the last `rows` lines of the buffer, so a shrink has to move the difference
/// into scrollback. Shifting only far enough to bring the cursor back on screen left lines below
/// the screen — and the viewport tops out at YBase, so scrolling could never reach them again.
/// Caught in review on this PR.
/// </remarks>
[Fact]
public void ShrinkingRows_LeavesNothingStrandedBelowTheScreen()
{
var terminal = new Terminal(new TerminalOptions { Cols = 40, Rows = 24, Scrollback = 200 });
for (var i = 0; i < 20; i++)
terminal.Write($"line {i}\r\n");
terminal.Write("prompt$ ");

// Park the cursor with blank rows below it, which is what leaves room to strand.
terminal.Write("\u001b[24;1H");
terminal.Write("\u001b[6A");

var contentRow = terminal.Buffer.YBase + terminal.Buffer.Y;

terminal.Resize(40, 10);

// The cursor is still on its line...
Assert.Equal(contentRow, terminal.Buffer.YBase + terminal.Buffer.Y);

// ...and the screen reaches the end of the buffer, so nothing is below it.
Assert.Equal(terminal.Buffer.Lines.Length, terminal.Buffer.YBase + terminal.Rows);

// A viewport that was at the tail is still at the tail.
Assert.Equal(terminal.Buffer.YBase, terminal.Buffer.ViewportY);
}

/// <summary>
/// A zero-row resize has no viewport to overflow out of, and must not scroll the buffer.
/// </summary>
/// <remarks>
/// The bottom row of a zero-row viewport is not -1, and treating it as such makes the overflow
/// one line too large -- so a resize that shows nothing still moved the cursor's content row,
/// and the line that came back at the top when rows were restored was the wrong one. Zero rows
/// is a real case here: a buffer can be built with none and brought to life by a later resize.
/// </remarks>
[Fact]
public void ZeroRowResize_DoesNotScrollTheBuffer()
{
var terminal = new Terminal(new TerminalOptions { Cols = 40, Rows = 24, Scrollback = 200 });
for (var i = 0; i < 20; i++)
terminal.Write($"line {i}\r\n");
terminal.Write("prompt$ ");

var contentRow = terminal.Buffer.YBase + terminal.Buffer.Y;

terminal.Resize(40, 0);
Assert.Equal(contentRow, terminal.Buffer.YBase + terminal.Buffer.Y);

terminal.Resize(40, 24);
Assert.Equal(contentRow, terminal.Buffer.YBase + terminal.Buffer.Y);
}

/// <summary>
/// A resize must not move the cursor off the line it is on. Its position is YBase + Y, and both
/// halves of a resize used to change one without the other.
/// </summary>
/// <remarks>
/// <para>The consequence is silent corruption rather than a crash, which is why it survived: the
/// cursor lands on earlier content and the next write destroys a line the application never
/// touched. A shell hides its own damage, because it redraws its prompt on every SIGWINCH and
/// repaints what it just overwrote. Anything that does NOT repaint -- a Sixel picture, a
/// full-screen TUI mid-frame -- keeps the evidence.</para>
/// <para>Both directions are tested, because they fail through different mechanisms and fixing
/// one leaves the other.</para>
/// </remarks>
[Fact]
public void ShrinkingRows_KeepsTheCursorOnItsLine()
{
var terminal = new Terminal(new TerminalOptions { Cols = 40, Rows = 24, Scrollback = 200 });
for (var i = 0; i < 20; i++)
terminal.Write($"line {i}\r\n");
terminal.Write("prompt$ ");

var contentRow = terminal.Buffer.YBase + terminal.Buffer.Y;

terminal.Resize(40, 8);

Assert.Equal(contentRow, terminal.Buffer.YBase + terminal.Buffer.Y);
}

[Fact]
public void GrowingRows_KeepsTheCursorOnItsLine()
{
var terminal = new Terminal(new TerminalOptions { Cols = 40, Rows = 24, Scrollback = 200 });
for (var i = 0; i < 20; i++)
terminal.Write($"line {i}\r\n");
terminal.Write("prompt$ ");

terminal.Resize(40, 8);
var contentRow = terminal.Buffer.YBase + terminal.Buffer.Y;

terminal.Resize(40, 24);

Assert.Equal(contentRow, terminal.Buffer.YBase + terminal.Buffer.Y);
}

/// <summary>
/// The live case: a drag is many resize events, and a shell writes between them. What the cursor
/// slides over is what gets destroyed, so the round trip is asserted on CONTENT and not only on
/// coordinates.
/// </summary>
[Fact]
public void ResizeLadderWithRedraws_LeavesEarlierLinesIntact()
{
var terminal = new Terminal(new TerminalOptions { Cols = 40, Rows = 24, Scrollback = 200 });
for (var i = 0; i < 20; i++)
terminal.Write($"line {i}\r\n");
terminal.Write("prompt$ ");

for (var rows = 20; rows >= 6; rows -= 4)
{
terminal.Resize(40, rows);
terminal.Write("\rprompt$ ");
}

for (var rows = 10; rows <= 24; rows += 4)
{
terminal.Resize(40, rows);
terminal.Write("\rprompt$ ");
}

// Every "line N" written before the drag must still read back exactly.
for (var i = 0; i < 20; i++)
{
var line = terminal.Buffer.Lines[i];
Assert.NotNull(line);
Assert.Equal($"line {i}", line!.TranslateToString(true).TrimEnd());
}
}
}
42 changes: 41 additions & 1 deletion src/XTerm.NET/Buffer/TerminalBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,14 @@ public void Resize(int newCols, int newRows)
_lines.Push(new BufferLine(newCols, nullCell));
}

// Growing the window pulls scrollback lines back into view, which is this clamp forcing
// YBase down. The CURSOR has to ride along: its position is YBase + Y, so every line YBase
// gives back must be added to Y, or the cursor slides UP the content by that much. A window
// dragged taller then has the shell's SIGWINCH redraws stamping prompts down through
// whatever the cursor slid over, one line per resize event.
var yBaseBefore = _yBase;
_yBase = Math.Min(_yBase, Math.Max(0, _lines.Length - newRows));
_y += yBaseBefore - _yBase;
_yDisp = Math.Clamp(_yDisp, 0, _yBase);

if (_lines.Length > 0)
Expand Down Expand Up @@ -410,7 +417,40 @@ public void Resize(int newCols, int newRows)
// the lower bound with it meant a negative cursor -- which SetCursorRaw exists to allow --
// survived the resize and left the buffer reporting an out-of-bounds position.
_x = Math.Clamp(_x, 0, Math.Max(0, newCols - 1));
_y = Math.Clamp(_y, 0, Math.Max(0, newRows - 1));
// The mirror case. A cursor below the new bottom is NOT simply clamped into place -- its
// overflow is pushed into scrollback, so the cursor stays on the LINE it was on. Clamping
// alone moved the cursor onto earlier content: shrink a window with a prompt at row 22 down
// to ten rows and the cursor landed on absolute row 9, where the next write destroyed
// whatever lived there.
// Floored, because newRows can be zero: a bare "newRows - 1" is -1 there, which makes the
// test true for any cursor and inflates the overflow by one, scrolling the buffer during a
// resize that has no viewport at all.
var newBottom = Math.Max(0, newRows - 1);

// The screen is the last `rows` lines of the buffer, so a shrink has to move the difference
// into scrollback. Shifting only enough to bring the cursor on screen left lines stranded
// BELOW the screen, where scrolling cannot reach them -- the viewport tops out at _yBase.
// So shift as far toward the tail as there is room for, stopping at the cursor: the cursor
// must not end up above the screen, and keeping it on its line is what this is all for.
if (_y > newBottom)
{
var overflow = _y - newBottom;
var room = Math.Max(0, _lines.Length - newRows - _yBase);
var wasFollowing = _yDisp == _yBase;

// At least enough to bring the cursor back on screen. But when the viewport was
// following the tail, take all the room there is -- bounded by the cursor, which must
// not end up above the screen. Shifting the bare minimum left lines stranded BELOW the
// screen, where scrolling cannot reach them, because the viewport tops out at _yBase.
var shift = Math.Min(room, wasFollowing ? _y : overflow);

_yBase += shift;
_y -= shift;
if (wasFollowing)
_yDisp = _yBase;
}

_y = Math.Clamp(_y, 0, newBottom);
SavedCursorState.X = Math.Clamp(SavedCursorState.X, 0, Math.Max(0, newCols - 1));
SavedCursorState.Y = Math.Max(SavedCursorState.Y, 0);

Expand Down
Loading