diff --git a/FIXES.md b/FIXES.md index 0f553c5..8771604 100644 --- a/FIXES.md +++ b/FIXES.md @@ -1,3 +1,39 @@ +# Resize reflow for the normal buffer + +## Summary + +This change ports xterm.js 5.5.0 resize reflow into XTerm.NET. Shrinking column width re-wraps long logical lines onto additional `IsWrapped` rows instead of truncating them; growing width merges wrapped groups back. The alternate buffer is excluded via an explicit `hasScrollback: false` constructor flag. + +A related latent bug is fixed: when the buffer was at capacity and row count shrank, `CircularList.Resize` ran before trimming and kept the oldest lines, silently discarding the live screen bottom. Resize now trims from the top (raising `Trimmed`) before shrinking capacity. + +## Why + +Without reflow, shrinking a terminal window and growing it back left every long line permanently truncated at the narrowest width — the primary defect tracked as ISS-007. Scrollback lines were also lost on capacity shrink because `CircularList.Resize` preserved the wrong end of the buffer. + +## Files changed + +- `src/XTerm.NET/Buffer/BufferReflow.cs` — pure reflow functions ported from `BufferReflow.ts` +- `src/XTerm.NET/Buffer/TerminalBuffer.cs` — `Resize` restructure, `ReflowLarger`/`ReflowSmaller`, `hasScrollback` flag +- `src/XTerm.NET/Buffer/BufferLine.cs` — `GetWidth`, `HasContent`, `ReplaceCells`; `GetTrimmedLength` wide-char width +- `src/XTerm.NET/Buffer/CircularList.cs` — `SetLength` for reflow batching +- `src/XTerm.NET/Terminal.cs` — alt buffer `hasScrollback: false` +- `src/XTerm.NET.Tests/Buffer/BufferReflowTests.cs` — pure-function tests +- `src/XTerm.NET.Tests/Buffer/BufferTests.cs` — reflow integration tests + +## Validation + +```powershell +dotnet test src/XTerm.NET.slnx +``` + +Result on this branch: + +```text +Passed: 624 +Failed: 0 +Skipped: 0 +``` + # Docker progress rendering fixes ## Summary diff --git a/src/XTerm.NET.Tests/Buffer/BufferReflowTests.cs b/src/XTerm.NET.Tests/Buffer/BufferReflowTests.cs new file mode 100644 index 0000000..8203575 --- /dev/null +++ b/src/XTerm.NET.Tests/Buffer/BufferReflowTests.cs @@ -0,0 +1,112 @@ +using XTerm.Buffer; + +namespace XTerm.Tests.Buffer; + +public class BufferReflowTests +{ + [Fact] + public void ReflowSmallerGetNewLineLengths_SmallLineWithWideCharacters() + { + var line = new BufferLine(4); + SetCell(line, 0, "汉", 2); + SetCell(line, 1, "", 0); + SetCell(line, 2, "语", 2); + SetCell(line, 3, "", 0); + + Assert.Equal("汉语", line.TranslateToString(trimRight: true)); + Assert.Equal(new[] { 2, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 4, 3)); + Assert.Equal(new[] { 2, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 4, 2)); + } + + [Fact] + public void ReflowSmallerGetNewLineLengths_LargeLineWithWideCharacters() + { + var line = new BufferLine(12); + for (var i = 0; i < 12; i += 4) + { + SetCell(line, i, "汉", 2); + SetCell(line, i + 2, "语", 2); + } + for (var i = 1; i < 12; i += 2) + { + SetCell(line, i, "", 0); + } + + Assert.Equal("汉语汉语汉语", line.TranslateToString()); + Assert.Equal(new[] { 10, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 11)); + Assert.Equal(new[] { 10, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 10)); + Assert.Equal(new[] { 8, 4 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 9)); + Assert.Equal(new[] { 8, 4 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 8)); + Assert.Equal(new[] { 6, 6 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 7)); + Assert.Equal(new[] { 6, 6 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 6)); + Assert.Equal(new[] { 4, 4, 4 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 5)); + Assert.Equal(new[] { 4, 4, 4 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 4)); + Assert.Equal(new[] { 2, 2, 2, 2, 2, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 3)); + Assert.Equal(new[] { 2, 2, 2, 2, 2, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 12, 2)); + } + + [Fact] + public void ReflowSmallerGetNewLineLengths_MixedWideAndSingleCharacters() + { + var line = new BufferLine(6); + SetCell(line, 0, "a", 1); + SetCell(line, 1, "汉", 2); + SetCell(line, 2, "", 0); + SetCell(line, 3, "语", 2); + SetCell(line, 4, "", 0); + SetCell(line, 5, "b", 1); + + Assert.Equal("a汉语b", line.TranslateToString()); + Assert.Equal(new[] { 5, 1 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 6, 5)); + Assert.Equal(new[] { 3, 3 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 6, 4)); + Assert.Equal(new[] { 3, 3 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 6, 3)); + Assert.Equal(new[] { 1, 2, 2, 1 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 6, 2)); + } + + [Fact] + public void ReflowSmallerGetNewLineLengths_WrappedLineWithWideAndSingleCharacters() + { + var line1 = new BufferLine(6); + SetCell(line1, 0, "a", 1); + SetCell(line1, 1, "汉", 2); + SetCell(line1, 2, "", 0); + SetCell(line1, 3, "语", 2); + SetCell(line1, 4, "", 0); + SetCell(line1, 5, "b", 1); + + var line2 = new BufferLine(6) { IsWrapped = true }; + SetCell(line2, 0, "a", 1); + SetCell(line2, 1, "汉", 2); + SetCell(line2, 2, "", 0); + SetCell(line2, 3, "语", 2); + SetCell(line2, 4, "", 0); + SetCell(line2, 5, "b", 1); + + Assert.Equal(new[] { 5, 4, 3 }, BufferReflow.ReflowSmallerGetNewLineLengths([line1, line2], 6, 5)); + Assert.Equal(new[] { 3, 4, 4, 1 }, BufferReflow.ReflowSmallerGetNewLineLengths([line1, line2], 6, 4)); + Assert.Equal(new[] { 3, 3, 3, 3 }, BufferReflow.ReflowSmallerGetNewLineLengths([line1, line2], 6, 3)); + Assert.Equal(new[] { 1, 2, 2, 2, 2, 2, 1 }, BufferReflow.ReflowSmallerGetNewLineLengths([line1, line2], 6, 2)); + } + + [Fact] + public void ReflowSmallerGetNewLineLengths_LinesEndingInNullSpace() + { + var line = new BufferLine(5); + SetCell(line, 0, "汉", 2); + SetCell(line, 1, "", 0); + SetCell(line, 2, "语", 2); + SetCell(line, 3, "", 0); + var empty = BufferCell.Empty; + line.SetCell(4, ref empty); + + Assert.Equal("汉语", line.TranslateToString(trimRight: true)); + Assert.Equal(new[] { 2, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 4, 3)); + Assert.Equal(new[] { 2, 2 }, BufferReflow.ReflowSmallerGetNewLineLengths([line], 4, 2)); + } + + private static void SetCell(BufferLine line, int col, string content, int width = 1) + { + var cell = content == "" ? BufferCell.Empty : new BufferCell(content, width, AttributeData.Default); + line.SetCell(col, ref cell); + } +} diff --git a/src/XTerm.NET.Tests/Buffer/BufferTests.cs b/src/XTerm.NET.Tests/Buffer/BufferTests.cs index 83053d6..fa911c5 100644 --- a/src/XTerm.NET.Tests/Buffer/BufferTests.cs +++ b/src/XTerm.NET.Tests/Buffer/BufferTests.cs @@ -1187,6 +1187,364 @@ public void NormalBuffer_WithScrollback_ScrollUpAtTop_YBaseIncrements() Assert.Equal(5, buffer.YDisp); } + #endregion + + #region Reflow Tests + + [Fact] + public void Reflow_DoesNotWrapEmptyLines() + { + var buffer = new TerminalBuffer(80, 24, 1000); + var initialLength = buffer.Lines.Length; + + buffer.Resize(75, 24); + + Assert.Equal(initialLength, buffer.Lines.Length); + } + + [Fact] + public void Reflow_ShrinksRowLength() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(5, 10); + + for (var i = 0; i < 10; i++) + { + Assert.Equal(5, buffer.Lines[i]!.Length); + } + } + + [Fact] + public void Reflow_WrapsAndUnwrapsLines() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(5, 10); + + var firstLine = buffer.Lines[0]!; + for (var i = 0; i < 5; i++) + { + SetCell(firstLine, i, ((char)('a' + i)).ToString()); + } + + buffer.SetCursorRaw(0, 1); + Assert.Equal("abcde", buffer.Lines[0]!.TranslateToString()); + + buffer.Resize(1, 10); + Assert.Equal("a", buffer.Lines[0]!.TranslateToString()); + Assert.Equal("b", buffer.Lines[1]!.TranslateToString()); + Assert.Equal("c", buffer.Lines[2]!.TranslateToString()); + Assert.Equal("d", buffer.Lines[3]!.TranslateToString()); + Assert.Equal("e", buffer.Lines[4]!.TranslateToString()); + + buffer.Resize(5, 10); + Assert.Equal("abcde", buffer.Lines[0]!.TranslateToString()); + } + + [Fact] + public void Reflow_RemovesCorrectRowsWhenGrowingLarger() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(10, 10); + buffer.SetCursorRaw(0, 2); + + for (var i = 0; i < 10; i++) + { + SetCell(buffer.Lines[0]!, i, ((char)('a' + i)).ToString()); + SetCell(buffer.Lines[1]!, i, ((char)('0' + i)).ToString()); + } + + buffer.Resize(2, 10); + Assert.Equal("ab", buffer.Lines[0]!.TranslateToString()); + Assert.Equal("cd", buffer.Lines[1]!.TranslateToString()); + Assert.Equal("ef", buffer.Lines[2]!.TranslateToString()); + Assert.Equal("gh", buffer.Lines[3]!.TranslateToString()); + Assert.Equal("ij", buffer.Lines[4]!.TranslateToString()); + Assert.Equal("01", buffer.Lines[5]!.TranslateToString()); + Assert.Equal("23", buffer.Lines[6]!.TranslateToString()); + Assert.Equal("45", buffer.Lines[7]!.TranslateToString()); + Assert.Equal("67", buffer.Lines[8]!.TranslateToString()); + Assert.Equal("89", buffer.Lines[9]!.TranslateToString()); + + buffer.Resize(10, 10); + Assert.Equal("abcdefghij", buffer.Lines[0]!.TranslateToString()); + Assert.Equal("0123456789", buffer.Lines[1]!.TranslateToString()); + } + + [Fact] + public void Reflow_TransfersCombinedCharData() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(4, 3); + buffer.SetCursorRaw(0, 2); + + SetCell(buffer.Lines[0]!, 0, "a"); + SetCell(buffer.Lines[0]!, 1, "b"); + SetCell(buffer.Lines[0]!, 2, "c"); + SetCell(buffer.Lines[0]!, 3, "😁"); + + buffer.Resize(2, 3); + Assert.Equal("ab", buffer.Lines[0]!.TranslateToString()); + Assert.Equal("c😁", buffer.Lines[1]!.TranslateToString()); + } + + [Fact] + public void Reflow_WideCharactersWhenShrinking() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(12, 10); + buffer.SetCursorRaw(0, 2); + + for (var i = 0; i < 12; i += 4) + { + SetWideCell(buffer.Lines[0]!, i, "汉"); + SetWideCell(buffer.Lines[1]!, i, "汉"); + } + for (var i = 2; i < 12; i += 4) + { + SetWideCell(buffer.Lines[0]!, i, "语"); + SetWideCell(buffer.Lines[1]!, i, "语"); + } + buffer.Lines[1]!.IsWrapped = true; + + buffer.Resize(11, 10); + Assert.Equal("汉语汉语汉", buffer.Lines[0]!.TranslateToString(trimRight: true)); + Assert.Equal("语汉语汉语", buffer.Lines[1]!.TranslateToString(trimRight: true)); + Assert.Equal("汉语", buffer.Lines[2]!.TranslateToString(trimRight: true)); + + buffer.Resize(7, 10); + Assert.Equal("汉语汉", buffer.Lines[0]!.TranslateToString(trimRight: true)); + Assert.Equal("语汉语", buffer.Lines[1]!.TranslateToString(trimRight: true)); + Assert.Equal("汉语汉", buffer.Lines[2]!.TranslateToString(trimRight: true)); + Assert.Equal("语汉语", buffer.Lines[3]!.TranslateToString(trimRight: true)); + } + + [Fact] + public void Reflow_SkipsGroupsWithNonNormalLineAttribute() + { + var buffer = new TerminalBuffer(10, 5, 100); + buffer.Resize(4, 5); + + SetCell(buffer.Lines[0]!, 0, "a"); + SetCell(buffer.Lines[0]!, 1, "b"); + SetCell(buffer.Lines[0]!, 2, "c"); + SetCell(buffer.Lines[0]!, 3, "d"); + buffer.Lines[1]!.IsWrapped = true; + buffer.Lines[1]!.LineAttribute = LineAttribute.DoubleWidth; + SetCell(buffer.Lines[1]!, 0, "e"); + SetCell(buffer.Lines[1]!, 1, "f"); + SetCell(buffer.Lines[1]!, 2, "g"); + SetCell(buffer.Lines[1]!, 3, "h"); + + buffer.Resize(2, 5); + + Assert.Equal("ab", buffer.Lines[0]!.TranslateToString()); + Assert.Equal("ef", buffer.Lines[1]!.TranslateToString(trimRight: false)); + Assert.Equal(LineAttribute.DoubleWidth, buffer.Lines[1]!.LineAttribute); + } + + [Fact] + public void Reflow_RaisesTrimmedWhenLinesRemovedFromTop() + { + var buffer = new TerminalBuffer(80, 5, 1); + buffer.Resize(10, 5); + + for (var i = 0; i < 10; i++) + { + SetCell(buffer.Lines[3]!, i, ((char)('a' + i)).ToString()); + } + + buffer.SetCursorRaw(0, 4); + var trimmedTotal = 0; + buffer.Trimmed += amount => trimmedTotal += amount; + + buffer.Resize(2, 5); + + Assert.True(trimmedTotal > 0); + } + + [Fact] + public void Reflow_CursorStaysOnSameCharacterThroughShrinkGrow() + { + var buffer = new TerminalBuffer(20, 5, 100); + buffer.Resize(20, 5); + + for (var i = 0; i < 20; i++) + { + SetCell(buffer.Lines[0]!, i, ((char)('a' + i)).ToString()); + } + + buffer.SetCursorRaw(9, 2); + buffer.Resize(10, 5); + buffer.Resize(20, 5); + + Assert.Equal(9, buffer.X); + Assert.Equal(2, buffer.Y); + Assert.Equal("abcdefghijklmnopqrst", buffer.Lines[0]!.TranslateToString(trimRight: true)); + } + + [Fact] + public void Reflow_PendingWrapXEqualsColsDoesNotCrash() + { + var buffer = new TerminalBuffer(10, 5, 100); + buffer.Resize(10, 5); + buffer.SetCursorRaw(10, 0); + + buffer.Resize(5, 5); + buffer.Resize(10, 5); + + Assert.True(buffer.X >= 0); + } + + [Fact] + public void Reflow_AtCapacityShrinkingRows_KeepsNewestLines() + { + var buffer = new TerminalBuffer(10, 5, 5); + buffer.ScrollUp(5); + Assert.Equal(10, buffer.Lines.Length); + + SetCell(buffer.Lines[buffer.YBase + 4]!, 0, ">"); + buffer.SetCursorRaw(0, 4); + + buffer.Resize(10, 3); + + Assert.Equal(">", buffer.Lines[7]![0].Content); + } + + [Fact] + public void Reflow_AltBufferTruncatesWithoutReflow() + { + var buffer = new TerminalBuffer(10, 5, 0, hasScrollback: false); + for (var i = 0; i < 10; i++) + { + SetCell(buffer.Lines[0]!, i, ((char)('a' + i)).ToString()); + } + + buffer.Resize(5, 5); + + Assert.Equal("abcde", buffer.Lines[0]!.TranslateToString(trimRight: true)); + Assert.Equal(5, buffer.Lines.Length); + } + + [Fact] + public void Reflow_GrowWhenLastBufferRowIsWrappedContinuation() + { + var buffer = new TerminalBuffer(5, 4, 100); + buffer.Resize(5, 4); + for (var i = 0; i < 5; i++) + { + SetCell(buffer.Lines[2]!, i, ((char)('a' + i)).ToString()); + SetCell(buffer.Lines[3]!, i, ((char)('A' + i)).ToString()); + } + buffer.Lines[3]!.IsWrapped = true; // last buffer row is a continuation + buffer.SetCursorRaw(0, 0); // cursor parked away so the group is not skipped + + buffer.Resize(10, 4); + + Assert.Equal("abcdeABCDE", buffer.Lines[2]!.TranslateToString(trimRight: true)); + } + + [Fact] + public void Reflow_WideCharactersWhenGrowing() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(6, 12); + + var glyphs = new[] { "汉", "语", "測", "試" }; + for (var row = 0; row < 4; row++) + { + for (var col = 0; col < 6; col += 2) + { + var glyphIndex = (row * 3 + col / 2) % glyphs.Length; + SetWideCell(buffer.Lines[row]!, col, glyphs[glyphIndex]); + } + } + buffer.Lines[1]!.IsWrapped = true; + buffer.Lines[2]!.IsWrapped = true; + buffer.Lines[3]!.IsWrapped = true; + + Assert.Equal("汉语測", buffer.Lines[0]!.TranslateToString(trimRight: true)); + Assert.Equal("試汉语", buffer.Lines[1]!.TranslateToString(trimRight: true)); + Assert.Equal("測試汉", buffer.Lines[2]!.TranslateToString(trimRight: true)); + Assert.Equal("语測試", buffer.Lines[3]!.TranslateToString(trimRight: true)); + + buffer.SetCursorRaw(0, 5); + + buffer.Resize(7, 12); + + var combined = buffer.Lines[0]!.TranslateToString(trimRight: true) + + buffer.Lines[1]!.TranslateToString(trimRight: true) + + buffer.Lines[2]!.TranslateToString(trimRight: true) + + buffer.Lines[3]!.TranslateToString(trimRight: true); + Assert.Equal("汉语測試汉语測試汉语測試", combined); + } + + [Fact] + public void ReflowSmaller_MovesCursorDownWhenViewportNotFilled() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(4, 10); + + SetCell(buffer.Lines[0]!, 0, "a"); + SetCell(buffer.Lines[0]!, 1, "b"); + SetCell(buffer.Lines[0]!, 2, "c"); + SetCell(buffer.Lines[0]!, 3, "d"); + SetCell(buffer.Lines[1]!, 0, "e"); + SetCell(buffer.Lines[1]!, 1, "f"); + SetCell(buffer.Lines[1]!, 2, "g"); + SetCell(buffer.Lines[1]!, 3, "h"); + buffer.Lines[1]!.IsWrapped = true; + SetCell(buffer.Lines[2]!, 0, "i"); + SetCell(buffer.Lines[2]!, 1, "j"); + SetCell(buffer.Lines[2]!, 2, "k"); + SetCell(buffer.Lines[2]!, 3, "l"); + + buffer.SetCursorRaw(0, 3); + buffer.Resize(2, 10); + + Assert.Equal(6, buffer.Y); + Assert.Equal(0, buffer.YDisp); + Assert.Equal(0, buffer.YBase); + Assert.Equal("ab", buffer.Lines[0]!.TranslateToString()); + Assert.Equal("cd", buffer.Lines[1]!.TranslateToString()); + Assert.Equal("ef", buffer.Lines[2]!.TranslateToString()); + Assert.Equal("gh", buffer.Lines[3]!.TranslateToString()); + Assert.True(buffer.Lines[1]!.IsWrapped); + Assert.True(buffer.Lines[3]!.IsWrapped); + } + + [Fact] + public void ReflowLarger_MovesCursorUpWhenViewportNotFilled() + { + var buffer = new TerminalBuffer(80, 24, 1000); + buffer.Resize(2, 10); + + SetCell(buffer.Lines[0]!, 0, "a"); + SetCell(buffer.Lines[0]!, 1, "b"); + SetCell(buffer.Lines[1]!, 0, "c"); + SetCell(buffer.Lines[1]!, 1, "d"); + buffer.Lines[1]!.IsWrapped = true; + SetCell(buffer.Lines[2]!, 0, "e"); + SetCell(buffer.Lines[2]!, 1, "f"); + SetCell(buffer.Lines[3]!, 0, "g"); + SetCell(buffer.Lines[3]!, 1, "h"); + buffer.Lines[3]!.IsWrapped = true; + SetCell(buffer.Lines[4]!, 0, "i"); + SetCell(buffer.Lines[4]!, 1, "j"); + SetCell(buffer.Lines[5]!, 0, "k"); + SetCell(buffer.Lines[5]!, 1, "l"); + buffer.Lines[5]!.IsWrapped = true; + + buffer.SetCursorRaw(0, 6); + buffer.Resize(4, 10); + + Assert.Equal(3, buffer.Y); + Assert.Equal(0, buffer.YDisp); + Assert.Equal(0, buffer.YBase); + Assert.Equal("abcd", buffer.Lines[0]!.TranslateToString()); + Assert.Equal("efgh", buffer.Lines[1]!.TranslateToString()); + Assert.Equal("ijkl", buffer.Lines[2]!.TranslateToString()); + } + private static void SetCell(TerminalBuffer buffer, int row, string content) { var line = buffer.GetLine(row); @@ -1194,5 +1552,17 @@ private static void SetCell(TerminalBuffer buffer, int row, string content) line?.SetCell(0, ref cell); } + private static void SetCell(BufferLine line, int col, string content, int width = 1) + { + var cell = new BufferCell(content, width, AttributeData.Default); + line.SetCell(col, ref cell); + } + + private static void SetWideCell(BufferLine line, int col, string content) + { + SetCell(line, col, content, 2); + SetCell(line, col + 1, "", 0); + } + #endregion } diff --git a/src/XTerm.NET/Buffer/BufferLine.cs b/src/XTerm.NET/Buffer/BufferLine.cs index f8028f5..b69f4ee 100644 --- a/src/XTerm.NET/Buffer/BufferLine.cs +++ b/src/XTerm.NET/Buffer/BufferLine.cs @@ -199,6 +199,47 @@ public string TranslateToString(bool trimRight = false, int startCol = 0, int en return sb.ToString(); } + /// + /// Gets the width of the cell at the given column. + /// + public int GetWidth(int index) + { + if (index < 0 || index >= _length) + return 1; + return _cells[index].Width; + } + + /// + /// Returns whether the cell at the given column has content. + /// + public bool HasContent(int index) + { + if (index < 0 || index >= _length) + return false; + var cell = _cells[index]; + return !cell.IsSpace() && !cell.IsEmpty(); + } + + /// + /// Replaces cells in the range [startCol, endCol) with the fill cell. + /// + public void ReplaceCells(int startCol, int endCol, BufferCell fillCell) + { + if (startCol > 0 && GetWidth(startCol - 1) == 2) + { + _cells[startCol - 1] = fillCell; + } + if (endCol < _length && GetWidth(endCol - 1) == 2) + { + _cells[endCol] = fillCell; + } + while (startCol < endCol && startCol < _length) + { + _cells[startCol++] = fillCell; + } + Cache = null; + } + /// /// Gets the last non-whitespace cell index. /// @@ -207,7 +248,7 @@ public int GetTrimmedLength() for (int i = _length - 1; i >= 0; i--) { if (!_cells[i].IsSpace() && !_cells[i].IsEmpty()) - return i + 1; + return i + Math.Max(_cells[i].Width, 1); } return 0; } diff --git a/src/XTerm.NET/Buffer/BufferReflow.cs b/src/XTerm.NET/Buffer/BufferReflow.cs new file mode 100644 index 0000000..a70609b --- /dev/null +++ b/src/XTerm.NET/Buffer/BufferReflow.cs @@ -0,0 +1,254 @@ +// Ported from xterm.js BufferReflow.ts, (c) The xterm.js authors, MIT + +namespace XTerm.Buffer; + +/// +/// Result of creating a new buffer layout after reflowing larger. +/// +public readonly struct NewLayoutResult +{ + public int[] Layout { get; init; } + public int CountRemoved { get; init; } +} + +/// +/// Pure reflow helpers ported from xterm.js. +/// +public static class BufferReflow +{ + /// + /// Evaluates indexes of rows to remove after a reflow-larger operation. + /// + public static int[] ReflowLargerGetLinesToRemove( + CircularList lines, + int oldCols, + int newCols, + int bufferAbsoluteY, + BufferCell nullCell) + { + var toRemove = new List(); + + for (var y = 0; y < lines.Length - 1; y++) + { + var i = y; + var nextLine = lines[++i]; + if (nextLine is not { IsWrapped: true }) + { + continue; + } + + var wrappedLines = new List { lines[y]! }; + while (i < lines.Length && nextLine is { IsWrapped: true }) + { + wrappedLines.Add(nextLine); + nextLine = ++i < lines.Length ? lines[i] : null; + } + + if (HasNonNormalLineAttribute(wrappedLines)) + { + y += wrappedLines.Count - 1; + continue; + } + + if (bufferAbsoluteY >= y && bufferAbsoluteY < i) + { + y += wrappedLines.Count - 1; + continue; + } + + var destLineIndex = 0; + var destCol = GetWrappedLineTrimmedLength(wrappedLines, destLineIndex, oldCols); + var srcLineIndex = 1; + var srcCol = 0; + while (srcLineIndex < wrappedLines.Count) + { + var srcTrimmedLineLength = GetWrappedLineTrimmedLength(wrappedLines, srcLineIndex, oldCols); + var srcRemainingCells = srcTrimmedLineLength - srcCol; + var destRemainingCells = newCols - destCol; + var cellsToCopy = Math.Min(srcRemainingCells, destRemainingCells); + + wrappedLines[destLineIndex].CopyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false); + + destCol += cellsToCopy; + if (destCol == newCols) + { + destLineIndex++; + destCol = 0; + } + srcCol += cellsToCopy; + if (srcCol == srcTrimmedLineLength) + { + srcLineIndex++; + srcCol = 0; + } + + if (destCol == 0 && destLineIndex != 0) + { + if (wrappedLines[destLineIndex - 1].GetWidth(newCols - 1) == 2) + { + wrappedLines[destLineIndex].CopyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false); + var nullAtEnd = nullCell; + wrappedLines[destLineIndex - 1].SetCell(newCols - 1, ref nullAtEnd); + } + } + } + + wrappedLines[destLineIndex].ReplaceCells(destCol, newCols, nullCell); + + var countToRemove = 0; + for (var removeIndex = wrappedLines.Count - 1; removeIndex > 0; removeIndex--) + { + if (removeIndex > destLineIndex || wrappedLines[removeIndex].GetTrimmedLength() == 0) + { + countToRemove++; + } + else + { + break; + } + } + + if (countToRemove > 0) + { + toRemove.Add(y + wrappedLines.Count - countToRemove); + toRemove.Add(countToRemove); + } + + y += wrappedLines.Count - 1; + } + + return toRemove.ToArray(); + } + + /// + /// Creates the new layout for lines given indexes to remove. + /// + public static NewLayoutResult ReflowLargerCreateNewLayout(CircularList lines, int[] toRemove) + { + var layout = new List(); + var nextToRemoveIndex = 0; + var nextToRemoveStart = toRemove.Length > 0 ? toRemove[nextToRemoveIndex] : -1; + var countRemovedSoFar = 0; + + for (var i = 0; i < lines.Length; i++) + { + if (nextToRemoveStart == i) + { + var countToRemove = toRemove[++nextToRemoveIndex]; + i += countToRemove - 1; + countRemovedSoFar += countToRemove; + nextToRemoveStart = ++nextToRemoveIndex < toRemove.Length ? toRemove[nextToRemoveIndex] : -1; + } + else + { + layout.Add(i); + } + } + + return new NewLayoutResult + { + Layout = layout.ToArray(), + CountRemoved = countRemovedSoFar + }; + } + + /// + /// Applies a new layout to the buffer in a single pass. + /// + public static void ReflowLargerApplyNewLayout(CircularList lines, int[] newLayout) + { + var newLayoutLines = new BufferLine[newLayout.Length]; + for (var i = 0; i < newLayout.Length; i++) + { + newLayoutLines[i] = lines[newLayout[i]]!; + } + + for (var i = 0; i < newLayoutLines.Length; i++) + { + lines[i] = newLayoutLines[i]; + } + + lines.SetLength(newLayout.Length); + } + + /// + /// Gets new line lengths for a wrapped line group when shrinking columns. + /// + public static int[] ReflowSmallerGetNewLineLengths( + IReadOnlyList wrappedLines, + int oldCols, + int newCols) + { + var newLineLengths = new List(); + var cellsNeeded = 0; + for (var i = 0; i < wrappedLines.Count; i++) + { + cellsNeeded += GetWrappedLineTrimmedLength(wrappedLines, i, oldCols); + } + + var srcCol = 0; + var srcLine = 0; + var cellsAvailable = 0; + while (cellsAvailable < cellsNeeded) + { + if (cellsNeeded - cellsAvailable < newCols) + { + newLineLengths.Add(cellsNeeded - cellsAvailable); + break; + } + + srcCol += newCols; + var oldTrimmedLength = GetWrappedLineTrimmedLength(wrappedLines, srcLine, oldCols); + if (srcCol > oldTrimmedLength) + { + srcCol -= oldTrimmedLength; + srcLine++; + } + + var endsWithWide = wrappedLines[srcLine].GetWidth(srcCol - 1) == 2; + if (endsWithWide) + { + srcCol--; + } + + var lineLength = endsWithWide ? newCols - 1 : newCols; + newLineLengths.Add(lineLength); + cellsAvailable += lineLength; + } + + return newLineLengths.ToArray(); + } + + /// + /// Gets the trimmed length of a row within a wrapped line group. + /// + public static int GetWrappedLineTrimmedLength(IReadOnlyList lines, int i, int cols) + { + if (i == lines.Count - 1) + { + return lines[i].GetTrimmedLength(); + } + + var endsInNull = !lines[i].HasContent(cols - 1) && lines[i].GetWidth(cols - 1) == 1; + var followingLineStartsWithWide = lines[i + 1].GetWidth(0) == 2; + if (endsInNull && followingLineStartsWithWide) + { + return cols - 1; + } + + return cols; + } + + internal static bool HasNonNormalLineAttribute(IReadOnlyList lines) + { + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].LineAttribute != LineAttribute.Normal) + { + return true; + } + } + + return false; + } +} diff --git a/src/XTerm.NET/Buffer/CircularList.cs b/src/XTerm.NET/Buffer/CircularList.cs index 1c31044..dd87963 100644 --- a/src/XTerm.NET/Buffer/CircularList.cs +++ b/src/XTerm.NET/Buffer/CircularList.cs @@ -187,6 +187,16 @@ public void Clear() _length = 0; } + /// + /// Sets the logical length of the list. Used by buffer reflow batching. + /// + internal void SetLength(int newLength) + { + if (newLength < 0 || newLength > MaxLength) + throw new ArgumentOutOfRangeException(nameof(newLength)); + _length = newLength; + } + /// /// Resizes the maximum length of the circular list. /// diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index ce5da2e..7c0b13c 100644 --- a/src/XTerm.NET/Buffer/TerminalBuffer.cs +++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs @@ -9,6 +9,7 @@ namespace XTerm.Buffer; public class TerminalBuffer { private readonly CircularList _lines; + private readonly bool _hasScrollback; private int _yDisp; private int _yBase; private int _y; @@ -91,8 +92,9 @@ public SavedCursor() public SavedCursor SavedCursorState { get; set; } - public TerminalBuffer(int cols, int rows, int scrollback) + public TerminalBuffer(int cols, int rows, int scrollback, bool hasScrollback = true) { + _hasScrollback = hasScrollback; _cols = cols; _rows = rows; _lines = new CircularList(rows + scrollback); @@ -111,6 +113,8 @@ public TerminalBuffer(int cols, int rows, int scrollback) } } + private bool IsReflowEnabled => _hasScrollback && _lines.MaxLength > _rows; + /// /// Gets a line from the buffer. /// @@ -290,30 +294,53 @@ public int GetAbsoluteY(int y) /// public void Resize(int newCols, int newRows) { - // Calculate new max length keeping the same scrollback capacity + var nullCell = BufferCell.Space; var newMaxLength = newRows + (_lines.MaxLength - _rows); - // Resize max length of circular list (may drop oldest lines if shrinking) - _lines.Resize(newMaxLength); - - // Resize existing lines to the new column count - var fillCell = BufferCell.Space; - for (int i = 0; i < _lines.Length; i++) + if (newMaxLength > _lines.MaxLength) { - _lines[i]?.Resize(newCols, fillCell); + _lines.Resize(newMaxLength); } - // Ensure we have at least viewport rows available - while (_lines.Length < newRows) + if (_lines.Length > 0) { - _lines.Push(new BufferLine(newCols, fillCell)); - } + if (_cols < newCols) + { + for (int i = 0; i < _lines.Length; i++) + { + _lines[i]?.Resize(newCols, nullCell); + } + } - // If we have fewer rows, ensure ybase/ydisp stay in range - _yBase = Math.Min(_yBase, Math.Max(0, _lines.Length - newRows)); - _yDisp = Math.Clamp(_yDisp, 0, _yBase); + while (_lines.Length < newRows) + { + _lines.Push(new BufferLine(newCols, nullCell)); + } + + _yBase = Math.Min(_yBase, Math.Max(0, _lines.Length - newRows)); + _yDisp = Math.Clamp(_yDisp, 0, _yBase); + + if (IsReflowEnabled && newCols != _cols) + { + if (newCols > _cols) + { + ReflowLarger(newCols, newRows); + } + else + { + ReflowSmaller(newCols, newRows); + } + } + + if (_cols > newCols) + { + for (int i = 0; i < _lines.Length; i++) + { + _lines[i]?.Resize(newCols, nullCell); + } + } + } - // Update scroll region and dimensions var oldRows = _rows; _cols = newCols; _rows = newRows; @@ -328,9 +355,260 @@ public void Resize(int newCols, int newRows) } _scrollTop = Math.Min(_scrollTop, newRows - 1); - // Clamp cursor within new bounds - _x = Math.Clamp(_x, 0, _cols - 1); - _y = Math.Clamp(_y, 0, _rows - 1); + _x = Math.Min(_x, newCols - 1); + _y = Math.Min(_y, newRows - 1); + SavedCursorState.X = Math.Min(SavedCursorState.X, newCols - 1); + + if (newMaxLength < _lines.MaxLength) + { + var amountToTrim = _lines.Length - newMaxLength; + if (amountToTrim > 0) + { + _lines.TrimStart(amountToTrim); + Trimmed?.Invoke(amountToTrim); + _yBase = Math.Max(_yBase - amountToTrim, 0); + _yDisp = Math.Max(_yDisp - amountToTrim, 0); + SavedCursorState.Y = Math.Max(SavedCursorState.Y - amountToTrim, 0); + } + _lines.Resize(newMaxLength); + } + } + + private void ReflowLarger(int newCols, int newRows) + { + var nullCell = BufferCell.Space; + var toRemove = BufferReflow.ReflowLargerGetLinesToRemove( + _lines, _cols, newCols, _yBase + _y, nullCell); + if (toRemove.Length > 0) + { + var newLayoutResult = BufferReflow.ReflowLargerCreateNewLayout(_lines, toRemove); + BufferReflow.ReflowLargerApplyNewLayout(_lines, newLayoutResult.Layout); + ReflowLargerAdjustViewport(newCols, newRows, newLayoutResult.CountRemoved); + } + } + + private void ReflowLargerAdjustViewport(int newCols, int newRows, int countRemoved) + { + var nullCell = BufferCell.Space; + var viewportAdjustments = countRemoved; + while (viewportAdjustments-- > 0) + { + if (_yBase == 0) + { + if (_y > 0) + { + _y--; + } + if (_lines.Length < newRows) + { + _lines.Push(new BufferLine(newCols, nullCell)); + } + } + else + { + if (_yDisp == _yBase) + { + _yDisp--; + } + _yBase--; + } + } + SavedCursorState.Y = Math.Max(SavedCursorState.Y - countRemoved, 0); + } + + private void ReflowSmaller(int newCols, int newRows) + { + var nullCell = BufferCell.Space; + var toInsert = new List<(int Start, List NewLines)>(); + var countToInsert = 0; + + for (var y = _lines.Length - 1; y >= 0; y--) + { + var nextLine = _lines[y]; + if (nextLine == null || (!nextLine.IsWrapped && nextLine.GetTrimmedLength() <= newCols)) + { + continue; + } + + var wrappedLines = new List { nextLine }; + while (nextLine.IsWrapped && y > 0) + { + nextLine = _lines[--y]!; + wrappedLines.Insert(0, nextLine); + } + + if (BufferReflow.HasNonNormalLineAttribute(wrappedLines)) + { + continue; + } + + var absoluteY = _yBase + _y; + if (absoluteY >= y && absoluteY < y + wrappedLines.Count) + { + continue; + } + + var lastLineLength = wrappedLines[^1].GetTrimmedLength(); + var destLineLengths = BufferReflow.ReflowSmallerGetNewLineLengths(wrappedLines, _cols, newCols); + var linesToAdd = destLineLengths.Length - wrappedLines.Count; + int trimmedLines; + if (_yBase == 0 && _y != _lines.Length - 1) + { + trimmedLines = Math.Max(0, _y - _lines.MaxLength + linesToAdd); + } + else + { + trimmedLines = Math.Max(0, _lines.Length - _lines.MaxLength + linesToAdd); + } + + var newLines = new List(); + for (var i = 0; i < linesToAdd; i++) + { + newLines.Add(GetBlankLine(AttributeData.Default, isWrapped: true)); + } + + if (newLines.Count > 0) + { + toInsert.Add((y + wrappedLines.Count + countToInsert, newLines)); + countToInsert += newLines.Count; + } + + wrappedLines.AddRange(newLines); + + var destLineIndex = destLineLengths.Length - 1; + var destCol = destLineLengths[destLineIndex]; + if (destCol == 0) + { + destLineIndex--; + destCol = destLineLengths[destLineIndex]; + } + + var srcLineIndex = wrappedLines.Count - linesToAdd - 1; + var srcCol = lastLineLength; + while (srcLineIndex >= 0) + { + var cellsToCopy = Math.Min(srcCol, destCol); + if (wrappedLines[destLineIndex] == null) + { + break; + } + + wrappedLines[destLineIndex].CopyCellsFrom( + wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true); + destCol -= cellsToCopy; + if (destCol == 0) + { + destLineIndex--; + if (destLineIndex < 0) + { + break; + } + destCol = destLineLengths[destLineIndex]; + } + srcCol -= cellsToCopy; + if (srcCol == 0) + { + srcLineIndex--; + var wrappedLinesIndex = Math.Max(srcLineIndex, 0); + srcCol = BufferReflow.GetWrappedLineTrimmedLength(wrappedLines, wrappedLinesIndex, _cols); + } + } + + for (var i = 0; i < wrappedLines.Count && i < destLineLengths.Length; i++) + { + if (destLineLengths[i] < newCols) + { + wrappedLines[i].ReplaceCells(destLineLengths[i], newCols, nullCell); + } + } + + var viewportAdjustments = linesToAdd - trimmedLines; + while (viewportAdjustments-- > 0) + { + if (_yBase == 0) + { + if (_y < newRows - 1) + { + _y++; + _lines.Pop(); + } + else + { + _yBase++; + _yDisp++; + } + } + else + { + if (_yBase < Math.Min(_lines.MaxLength, _lines.Length + countToInsert) - newRows) + { + if (_yBase == _yDisp) + { + _yDisp++; + } + _yBase++; + } + } + } + + SavedCursorState.Y = Math.Min(SavedCursorState.Y + linesToAdd, _yBase + newRows - 1); + } + + if (toInsert.Count > 0) + { + var originalLines = new List(_lines.Length); + for (var i = 0; i < _lines.Length; i++) + { + originalLines.Add(_lines[i]!); + } + + var originalLinesLength = originalLines.Count; + RebuildWithInsertions(originalLines, toInsert, countToInsert); + + var amountToTrim = Math.Max(0, originalLinesLength + countToInsert - _lines.MaxLength); + if (amountToTrim > 0) + { + _yBase = Math.Max(_yBase - amountToTrim, 0); + _yDisp = Math.Max(_yDisp - amountToTrim, 0); + SavedCursorState.Y = Math.Max(SavedCursorState.Y - amountToTrim, 0); + Trimmed?.Invoke(amountToTrim); + } + } + } + + private void RebuildWithInsertions( + IReadOnlyList originalLines, + IReadOnlyList<(int Start, List NewLines)> toInsert, + int countInserted) + { + var originalLinesLength = originalLines.Count; + _lines.SetLength(Math.Min(_lines.MaxLength, originalLinesLength + countInserted)); + + var originalLineIndex = originalLinesLength - 1; + var nextToInsertIndex = 0; + var nextToInsert = nextToInsertIndex < toInsert.Count ? toInsert[nextToInsertIndex] : ((int Start, List NewLines)?)null; + var countInsertedSoFar = 0; + + for (var i = Math.Min(_lines.MaxLength - 1, originalLinesLength + countInserted - 1); i >= 0; i--) + { + if (nextToInsert.HasValue && nextToInsert.Value.Start > originalLineIndex + countInsertedSoFar) + { + var insert = nextToInsert.Value; + for (var nextI = insert.NewLines.Count - 1; nextI >= 0; nextI--) + { + _lines[i--] = insert.NewLines[nextI]; + } + i++; + + countInsertedSoFar += insert.NewLines.Count; + nextToInsertIndex++; + nextToInsert = nextToInsertIndex < toInsert.Count ? toInsert[nextToInsertIndex] : null; + } + else + { + _lines[i] = originalLines[originalLineIndex--]; + } + } } /// diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index a66ef2d..cb0bb71 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -178,7 +178,7 @@ public Terminal(TerminalOptions? options = null) // Initialize buffers _normalBuffer = new Buffer.TerminalBuffer(Cols, Rows, Options.Scrollback); - _altBuffer = new Buffer.TerminalBuffer(Cols, Rows, 0); // Alt buffer has no scrollback + _altBuffer = new Buffer.TerminalBuffer(Cols, Rows, 0, hasScrollback: false); _buffer = _normalBuffer; _usingAltBuffer = false;