diff --git a/src/XTerm.NET.Tests/Buffer/ResizeEdgeCaseTests.cs b/src/XTerm.NET.Tests/Buffer/ResizeEdgeCaseTests.cs
index 9603143..1729946 100644
--- a/src/XTerm.NET.Tests/Buffer/ResizeEdgeCaseTests.cs
+++ b/src/XTerm.NET.Tests/Buffer/ResizeEdgeCaseTests.cs
@@ -131,4 +131,143 @@ public void ZeroRowBuffer_IsUsableAfterResize()
Assert.True(buffer.Lines.Length > 0, $"Lines.Length was {buffer.Lines.Length}");
}
+
+ ///
+ /// A shrink that was following the tail keeps following it, with nothing stranded below.
+ ///
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// A zero-row resize has no viewport to overflow out of, and must not scroll the buffer.
+ ///
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ /// Both directions are tested, because they fail through different mechanisms and fixing
+ /// one leaves the other.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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());
+ }
+ }
}
diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs
index 063837e..66d2fc2 100644
--- a/src/XTerm.NET/Buffer/TerminalBuffer.cs
+++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs
@@ -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)
@@ -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);