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);