From 14dc6f7006733cf324fd996a708fd84df2a90a07 Mon Sep 17 00:00:00 2001 From: simonefil Date: Tue, 8 Sep 2026 17:52:50 +0200 Subject: [PATCH 1/3] Expose lines leaving the terminal viewport --- .../LineExitedViewportTests.cs | 91 +++++++++++++++++++ src/XTerm.NET/Buffer/TerminalBuffer.cs | 9 ++ src/XTerm.NET/Common/Types.cs | 9 ++ src/XTerm.NET/Events/TerminalEvents.cs | 18 ++++ src/XTerm.NET/Terminal.cs | 32 +++++++ 5 files changed, 159 insertions(+) create mode 100644 src/XTerm.NET.Tests/LineExitedViewportTests.cs diff --git a/src/XTerm.NET.Tests/LineExitedViewportTests.cs b/src/XTerm.NET.Tests/LineExitedViewportTests.cs new file mode 100644 index 00000000..ba15fca2 --- /dev/null +++ b/src/XTerm.NET.Tests/LineExitedViewportTests.cs @@ -0,0 +1,91 @@ +using XTerm; +using XTerm.Buffer; +using XTerm.Common; +using XTerm.Events; +using XTerm.Options; + +namespace XTerm.Tests; + +public class LineExitedViewportTests +{ + [Fact] + public void FullScreenScroll_RaisesBeforeAlternateBufferLineIsRecycled() + { + var terminal = new Terminal(new TerminalOptions { Rows = 2, Cols = 8, Scrollback = 1 }); + terminal.SwitchToAltBuffer(); + SetCell(terminal.Buffer.Lines[0]!, "old"); + + string? captured = null; + BufferType? buffer = null; + LineExitReason? reason = null; + terminal.LineExitedViewport += (_, args) => + { + captured = args.Line.TranslateToString(trimRight: true); + buffer = args.Buffer; + reason = args.Reason; + }; + + terminal.Buffer.ScrollUp(1); + + Assert.Equal("old", captured); + Assert.Equal(BufferType.Alternate, buffer); + Assert.Equal(LineExitReason.Scrolled, reason); + } + + [Fact] + public void PartialScrollRegion_RaisesForTheLineRemovedFromTheRegion() + { + var terminal = new Terminal(new TerminalOptions { Rows = 4, Cols = 8 }); + terminal.Buffer.SetScrollRegion(1, 2); + SetCell(terminal.Buffer.Lines[1]!, "gone"); + + string? captured = null; + terminal.LineExitedViewport += (_, args) => captured = args.Line.TranslateToString(trimRight: true); + + terminal.Buffer.ScrollUp(1); + + Assert.Equal("gone", captured); + } + + [Fact] + public void NarrowedMargins_DoNotRaiseBecauseNoWholeLineLeaves() + { + var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 8 }); + terminal.Buffer.SetLeftRightMargins(1, 6); + var count = 0; + terminal.LineExitedViewport += (_, _) => count++; + + terminal.Buffer.ScrollUp(1); + + Assert.Equal(0, count); + } + + [Fact] + public void BufferSwitch_RaisesMeaningfulRowsBeforeBufferChanged() + { + var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 8 }); + SetCell(terminal.Buffer.Lines[0]!, "first"); + var events = new List(); + TerminalEvents.LineExitedViewportEventArgs? exited = null; + terminal.LineExitedViewport += (_, args) => + { + exited = args; + events.Add("exit"); + }; + terminal.BufferChanged += (_, _) => events.Add("changed"); + + terminal.SwitchToAltBuffer(); + + Assert.NotNull(exited); + Assert.Equal("first", exited.Line.TranslateToString(trimRight: true)); + Assert.Equal(BufferType.Normal, exited.Buffer); + Assert.Equal(LineExitReason.BufferDeactivated, exited.Reason); + Assert.Equal(["exit", "changed"], events); + } + + private static void SetCell(BufferLine line, string content) + { + var cell = new BufferCell(content, 1, AttributeData.Default); + line.SetCell(0, ref cell); + } +} diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index e59abdff..6b003641 100644 --- a/src/XTerm.NET/Buffer/TerminalBuffer.cs +++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs @@ -90,6 +90,11 @@ public int ViewportY /// public event Action? Trimmed; + /// + /// Fired synchronously before a line scrolls out of the active viewport and can be recycled. + /// + internal event Action? LineExitedViewport; + /// /// Whether scrolling reuses the scrollback line it is about to discard instead of allocating a /// new one. On by default. Turn it off if a consumer holds references @@ -256,6 +261,10 @@ public void ScrollUp(int lines, bool isWrapped = false) for (int i = 0; i < lines; i++) { + var exitingLine = _lines[_yBase + _scrollTop]; + if (exitingLine is not null) + LineExitedViewport?.Invoke(exitingLine); + BufferLine newLine; // Only the full-screen scroll region contributes to scrollback. diff --git a/src/XTerm.NET/Common/Types.cs b/src/XTerm.NET/Common/Types.cs index 7cb4e580..b1add414 100644 --- a/src/XTerm.NET/Common/Types.cs +++ b/src/XTerm.NET/Common/Types.cs @@ -9,6 +9,15 @@ public enum BufferType Alternate } +/// +/// Identifies why a terminal line left the active viewport. +/// +public enum LineExitReason +{ + Scrolled, + BufferDeactivated +} + /// /// Cursor style for the terminal. /// diff --git a/src/XTerm.NET/Events/TerminalEvents.cs b/src/XTerm.NET/Events/TerminalEvents.cs index 3de73cdb..51262ac2 100644 --- a/src/XTerm.NET/Events/TerminalEvents.cs +++ b/src/XTerm.NET/Events/TerminalEvents.cs @@ -1,3 +1,4 @@ +using XTerm.Buffer; using XTerm.Common; namespace XTerm.Events; @@ -204,6 +205,23 @@ public LineFeedEventArgs(string data) } } + /// + /// Line exit event - fired before a line leaves the active viewport. + /// + public class LineExitedViewportEventArgs : EventArgs + { + public BufferLine Line { get; } + public BufferType Buffer { get; } + public LineExitReason Reason { get; } + + public LineExitedViewportEventArgs(BufferLine line, BufferType buffer, LineExitReason reason) + { + Line = line; + Buffer = buffer; + Reason = reason; + } + } + /// /// Scroll event - fired when the terminal scrolls. /// diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index 66693d99..4f761c98 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -562,6 +562,11 @@ internal bool TrySetUserVariable(string name, string value) /// public event EventHandler? LineFed; + /// + /// Fired before a line leaves the active viewport. + /// + public event EventHandler? LineExitedViewport; + /// /// Fired when the current directory changes. /// @@ -760,6 +765,8 @@ public Terminal(TerminalOptions? options = null) // Initialize buffers _normalBuffer = new Buffer.TerminalBuffer(Cols, Rows, Options.Scrollback); _altBuffer = new Buffer.TerminalBuffer(Cols, Rows, 0, hasScrollback: false); + _normalBuffer.LineExitedViewport += line => RaiseLineExitedViewport(line, BufferType.Normal, LineExitReason.Scrolled); + _altBuffer.LineExitedViewport += line => RaiseLineExitedViewport(line, BufferType.Alternate, LineExitReason.Scrolled); _buffer = _normalBuffer; _usingAltBuffer = false; @@ -2003,6 +2010,7 @@ public void SwitchToAltBuffer() if (_statusLineActive) SetActiveStatusDisplay(0); + RaiseBufferDeactivatedLines(_normalBuffer!); var x = _buffer.X; var y = _buffer.Y; _buffer = _altBuffer!; @@ -2039,6 +2047,7 @@ public void SwitchToNormalBuffer() if (_statusLineActive) SetActiveStatusDisplay(0); + RaiseBufferDeactivatedLines(_altBuffer!); var x = _buffer.X; var y = _buffer.Y; _buffer = _normalBuffer!; @@ -2142,6 +2151,28 @@ private void LineFeed() LineFed?.Invoke(this, new TerminalEvents.LineFeedEventArgs("\n")); } + /// Raises a synchronous snapshot opportunity before a row leaves the viewport. + private void RaiseLineExitedViewport(BufferLine line, BufferType buffer, LineExitReason reason) + { + LineExitedViewport?.Invoke(this, new TerminalEvents.LineExitedViewportEventArgs(line, buffer, reason)); + } + + /// Raises exit events for the meaningful rows of a buffer before deactivation. + private void RaiseBufferDeactivatedLines(Buffer.TerminalBuffer buffer) + { + var firstLine = buffer.BaseY; + var lastLine = Math.Min(firstLine + Rows, buffer.Lines.Length) - 1; + while (lastLine >= firstLine && buffer.Lines[lastLine]?.GetTrimmedLength() == 0) + lastLine--; + + for (int i = firstLine; i <= lastLine; i++) + { + var line = buffer.Lines[i]; + if (line is not null) + RaiseLineExitedViewport(line, ReferenceEquals(buffer, _altBuffer) ? BufferType.Alternate : BufferType.Normal, LineExitReason.BufferDeactivated); + } + } + /// Whether has run. A disposed terminal accepts no writes. private bool _disposed; @@ -2198,6 +2229,7 @@ public void Dispose() Resized = null; Scrolled = null; LineFed = null; + LineExitedViewport = null; DirectoryChanged = null; HyperlinkChanged = null; ShellIntegrationMarkReceived = null; From 1580e73273f8bc87c3eb444bbc359b74d8075905 Mon Sep 17 00:00:00 2001 From: simonefil Date: Thu, 1 Oct 2026 11:43:36 +0200 Subject: [PATCH 2/3] Preserve terminal history across redraws and buffer transitions --- src/XTerm.NET/Buffer/TerminalBuffer.cs | 4 ++++ src/XTerm.NET/InputHandler.Csi.cs | 5 +++++ src/XTerm.NET/Terminal.cs | 20 +++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index 6b003641..da502b1d 100644 --- a/src/XTerm.NET/Buffer/TerminalBuffer.cs +++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs @@ -380,6 +380,10 @@ public void ScrollDown(int lines) if (HasMultiRowSizedRuns) EraseSizedRunsSplitBy(scrollRegionStart, scrollRegionEnd); + var exitingLine = _lines[scrollRegionEnd]; + if (exitingLine is not null) + LineExitedViewport?.Invoke(exitingLine); + // Remove line from scroll region bottom _lines.Splice(scrollRegionEnd, 1); diff --git a/src/XTerm.NET/InputHandler.Csi.cs b/src/XTerm.NET/InputHandler.Csi.cs index 1e15eb95..22a8af62 100644 --- a/src/XTerm.NET/InputHandler.Csi.cs +++ b/src/XTerm.NET/InputHandler.Csi.cs @@ -371,6 +371,8 @@ private void CursorPosition(Params parameters) var col = Math.Max(parameters.GetParam(1, 1), 1) - 1; row = GetAbsoluteCursorRow(row); col = GetAbsoluteCursorCol(col); + if (row == 0 && col == 0) + _terminal.RaiseViewportRedrawStarting(); _buffer.SetCursor(col, row); } @@ -378,6 +380,9 @@ private void EraseInDisplay(Params parameters, bool selective = false) { var mode = parameters.GetParam(0, 0); + if (!selective && (mode == 2 || (mode == 0 && _buffer.X == 0 && _buffer.Y == 0))) + _terminal.RaiseViewportRedrawStarting(); + var hasBlocks = _buffer.HasMultiRowSizedRuns; switch (mode) diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index 4f761c98..7bd0e605 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -563,10 +563,20 @@ internal bool TrySetUserVariable(string name, string value) public event EventHandler? LineFed; /// - /// Fired before a line leaves the active viewport. + /// Fired synchronously before a line leaves the active viewport. /// + /// + /// Consumers that need the row contents must snapshot them during the callback because the + /// underlying line may be recycled as soon as the handler returns. + /// public event EventHandler? LineExitedViewport; + /// + /// Fired synchronously when an absolute home or a non-selective full display erase can begin a redraw. + /// + /// Snapshot the buffer during the callback, before its contents change. + public event EventHandler? ViewportRedrawStarting; + /// /// Fired when the current directory changes. /// @@ -1176,6 +1186,7 @@ public void Reset() // Reset to normal buffer if (_usingAltBuffer) { + RaiseBufferDeactivatedLines(_altBuffer!); _buffer = _normalBuffer!; _usingAltBuffer = false; _inputHandler.SetBuffer(_buffer); @@ -2157,6 +2168,12 @@ private void RaiseLineExitedViewport(BufferLine line, BufferType buffer, LineExi LineExitedViewport?.Invoke(this, new TerminalEvents.LineExitedViewportEventArgs(line, buffer, reason)); } + /// Raises the synchronous snapshot opportunity at a potential redraw boundary. + internal void RaiseViewportRedrawStarting() + { + ViewportRedrawStarting?.Invoke(this, EventArgs.Empty); + } + /// Raises exit events for the meaningful rows of a buffer before deactivation. private void RaiseBufferDeactivatedLines(Buffer.TerminalBuffer buffer) { @@ -2230,6 +2247,7 @@ public void Dispose() Scrolled = null; LineFed = null; LineExitedViewport = null; + ViewportRedrawStarting = null; DirectoryChanged = null; HyperlinkChanged = null; ShellIntegrationMarkReceived = null; From 1ff4cece094c595f3291cd203607bc9621fd89bd Mon Sep 17 00:00:00 2001 From: simonefil Date: Wed, 7 Oct 2026 14:27:26 +0200 Subject: [PATCH 3/3] Preserve metadata-only rows on buffer deactivation --- .../LineExitedViewportTests.cs | 298 +++++++++++++++++- src/XTerm.NET/Buffer/BufferLine.cs | 31 ++ src/XTerm.NET/Terminal.cs | 11 +- 3 files changed, 328 insertions(+), 12 deletions(-) diff --git a/src/XTerm.NET.Tests/LineExitedViewportTests.cs b/src/XTerm.NET.Tests/LineExitedViewportTests.cs index ba15fca2..2c094352 100644 --- a/src/XTerm.NET.Tests/LineExitedViewportTests.cs +++ b/src/XTerm.NET.Tests/LineExitedViewportTests.cs @@ -32,6 +32,31 @@ public void FullScreenScroll_RaisesBeforeAlternateBufferLineIsRecycled() Assert.Equal(LineExitReason.Scrolled, reason); } + [Fact] + public void FullWidthReverseScroll_RaisesForEachRemovedRowBeforeSplice() + { + var terminal = new Terminal(new TerminalOptions { Rows = 4, Cols = 8, Scrollback = 1 }); + terminal.Write("top\r\nnext\r\nthird\r\nbottom"); + + var snapshots = new List<(int Row, string Text, BufferType Buffer, LineExitReason Reason, bool StillInBuffer)>(); + terminal.LineExitedViewport += (_, args) => + { + var row = FindViewportRow(terminal, args.Line); + snapshots.Add((row, args.Line.TranslateToString(trimRight: true), args.Buffer, args.Reason, + row >= 0)); + }; + + terminal.Write("\u001b[2T"); + + Assert.Equal( + new[] + { + (3, "bottom", BufferType.Normal, LineExitReason.Scrolled, true), + (3, "third", BufferType.Normal, LineExitReason.Scrolled, true), + }, + snapshots); + } + [Fact] public void PartialScrollRegion_RaisesForTheLineRemovedFromTheRegion() { @@ -48,14 +73,38 @@ public void PartialScrollRegion_RaisesForTheLineRemovedFromTheRegion() } [Fact] - public void NarrowedMargins_DoNotRaiseBecauseNoWholeLineLeaves() + public void PartialVerticalReverseScroll_RaisesForTheLineRemovedFromTheRegion() + { + var terminal = new Terminal(new TerminalOptions { Rows = 4, Cols = 8 }); + terminal.Buffer.SetScrollRegion(1, 2); + SetCell(terminal.Buffer.Lines[2]!, "gone"); + + var snapshots = new List<(int Row, string Text, LineExitReason Reason)>(); + terminal.LineExitedViewport += (_, args) => + { + snapshots.Add((FindViewportRow(terminal, args.Line), + args.Line.TranslateToString(trimRight: true), args.Reason)); + }; + + terminal.Write("\u001b[T"); + + Assert.Equal([(2, "gone", LineExitReason.Scrolled)], snapshots); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NarrowedMargins_DoNotRaiseBecauseNoWholeLineLeaves(bool scrollDown) { var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 8 }); terminal.Buffer.SetLeftRightMargins(1, 6); var count = 0; terminal.LineExitedViewport += (_, _) => count++; - terminal.Buffer.ScrollUp(1); + if (scrollDown) + terminal.Buffer.ScrollDown(1); + else + terminal.Buffer.ScrollUp(1); Assert.Equal(0, count); } @@ -66,23 +115,256 @@ public void BufferSwitch_RaisesMeaningfulRowsBeforeBufferChanged() var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 8 }); SetCell(terminal.Buffer.Lines[0]!, "first"); var events = new List(); - TerminalEvents.LineExitedViewportEventArgs? exited = null; + string? captured = null; + BufferType? buffer = null; + LineExitReason? reason = null; terminal.LineExitedViewport += (_, args) => { - exited = args; + captured = args.Line.TranslateToString(trimRight: true); + buffer = args.Buffer; + reason = args.Reason; events.Add("exit"); }; terminal.BufferChanged += (_, _) => events.Add("changed"); terminal.SwitchToAltBuffer(); - Assert.NotNull(exited); - Assert.Equal("first", exited.Line.TranslateToString(trimRight: true)); - Assert.Equal(BufferType.Normal, exited.Buffer); - Assert.Equal(LineExitReason.BufferDeactivated, exited.Reason); + Assert.Equal("first", captured); + Assert.Equal(BufferType.Normal, buffer); + Assert.Equal(LineExitReason.BufferDeactivated, reason); Assert.Equal(["exit", "changed"], events); } + [Fact] + public void AlternateBufferSwitch_RaisesSnapshotsBeforeSwitchingToNormal() + { + var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 8 }); + terminal.SwitchToAltBuffer(); + terminal.Write("alt0\r\nalt1"); + + var snapshots = new List<(int Row, string Text, BufferType Buffer, LineExitReason Reason, bool AlternateActive)>(); + terminal.LineExitedViewport += (_, args) => + { + snapshots.Add((FindViewportRow(terminal, args.Line), args.Line.TranslateToString(trimRight: true), + args.Buffer, args.Reason, terminal.IsAlternateBufferActive)); + }; + + terminal.SwitchToNormalBuffer(); + + Assert.Equal( + new[] + { + (0, "alt0", BufferType.Alternate, LineExitReason.BufferDeactivated, true), + (1, "alt1", BufferType.Alternate, LineExitReason.BufferDeactivated, true), + }, + snapshots); + } + + [Fact] + public void Reset_FromAlternateBuffer_SnapshotsRowsBeforeClearAndSwitch() + { + var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 8 }); + terminal.SwitchToAltBuffer(); + terminal.Write("alt0\r\nalt1"); + + var snapshots = new List<(int Row, string Text, BufferType Buffer, LineExitReason Reason, bool AlternateActive)>(); + terminal.LineExitedViewport += (_, args) => + { + snapshots.Add((FindViewportRow(terminal, args.Line), args.Line.TranslateToString(trimRight: true), + args.Buffer, args.Reason, terminal.IsAlternateBufferActive)); + }; + + terminal.Reset(); + + Assert.Equal( + new[] + { + (0, "alt0", BufferType.Alternate, LineExitReason.BufferDeactivated, true), + (1, "alt1", BufferType.Alternate, LineExitReason.BufferDeactivated, true), + }, + snapshots); + Assert.False(terminal.IsAlternateBufferActive); + } + + [Fact] + public void Reset_OnNormalBuffer_DoesNotRaiseBufferDeactivated() + { + var terminal = new Terminal(new TerminalOptions { Rows = 2, Cols = 8 }); + terminal.Write("normal"); + var reasons = new List(); + terminal.LineExitedViewport += (_, args) => reasons.Add(args.Reason); + + terminal.Reset(); + + Assert.Empty(reasons); + } + + [Theory] + [InlineData("styled-blank")] + [InlineData("wrapped")] + [InlineData("line-attribute")] + [InlineData("wide-blank")] + [InlineData("combining-blank")] + [InlineData("link")] + [InlineData("image")] + [InlineData("mark")] + [InlineData("sized-run")] + public void BufferDeactivation_EmitsAnIntermediateBlankAndOneTrailingMetadataRow(string metadata) + { + const string esc = "\u001b"; + const string st = esc + "\\"; + const string bel = "\u0007"; + var terminal = new Terminal(new TerminalOptions + { + Cols = 12, + Rows = 4, + CellWidthPixels = 2, + CellHeightPixels = 6 + }); + terminal.SwitchToAltBuffer(); + + SetCell(terminal.Buffer.Lines[0]!, "head"); + + switch (metadata) + { + case "styled-blank": + var styled = AttributeData.Default; + styled.SetBold(true); + var styledBlank = new BufferCell(" ", 1, styled); + terminal.Buffer.Lines[2]!.SetCell(0, ref styledBlank); + break; + case "wrapped": + terminal.Buffer.Lines[2]!.IsWrapped = true; + break; + case "line-attribute": + terminal.Buffer.Lines[2]!.LineAttribute = LineAttribute.DoubleWidth; + break; + case "wide-blank": + var wideBlank = new BufferCell(" ", 2, AttributeData.Default); + terminal.Buffer.Lines[2]!.SetCell(0, ref wideBlank); + break; + case "combining-blank": + var combiningBlank = new BufferCell(" \u0301", 1, AttributeData.Default); + terminal.Buffer.Lines[2]!.SetCell(0, ref combiningBlank); + break; + case "link": + terminal.Write($"{esc}[3;1H{esc}]8;;https://example.com{st} {esc}]8;;{st}"); + break; + case "image": + terminal.Write($"{esc}[3;1H{esc}P0;1;0q#0;2;100;0;0!2~{st}"); + break; + case "mark": + terminal.Write($"{esc}[3;1H{esc}]133;A{bel}"); + break; + case "sized-run": + terminal.Write($"{esc}[3;1H{esc}]66;n=1:d=2; {st}"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(metadata), metadata, null); + } + + var snapshots = new List<(int Row, string Text, bool IsWrapped, LineAttribute LineAttribute, + string FirstCellContent, int FirstCellWidth, bool StyledBlank, bool HasImages, bool HasMarks, + bool HasLinks, bool HasSizedRuns)>(); + terminal.LineExitedViewport += (_, args) => + { + var line = args.Line; + var row = FindViewportRow(terminal, line); + if (row == 2) + AssertMetadataSnapshot(metadata, line); + + snapshots.Add((row, line.TranslateToString(trimRight: true), line.IsWrapped, line.LineAttribute, + line[0].Content, line[0].Width, + line[0].Content == " " && line[0].Attributes != AttributeData.Default, + line.HasImages, line.HasMarks, line.HasLinks, line.HasSizedRuns)); + }; + + terminal.SwitchToNormalBuffer(); + + Assert.Equal(3, snapshots.Count); + Assert.Equal([0, 1, 2], snapshots.Select(snapshot => snapshot.Row)); + Assert.Equal("head", snapshots[0].Text); + Assert.Equal(string.Empty, snapshots[1].Text); + Assert.False(snapshots[1].IsWrapped); + Assert.Equal(LineAttribute.Normal, snapshots[1].LineAttribute); + Assert.Equal(metadata == "combining-blank" ? " \u0301" : string.Empty, snapshots[2].Text); + } + + [Fact] + public void BufferDeactivation_OmitsDefaultTrailingBlankRows() + { + var terminal = new Terminal(new TerminalOptions { Cols = 12, Rows = 4 }); + terminal.SwitchToAltBuffer(); + SetCell(terminal.Buffer.Lines[0]!, "head"); + + var rows = new List(); + terminal.LineExitedViewport += (_, args) => rows.Add(FindViewportRow(terminal, args.Line)); + + terminal.SwitchToNormalBuffer(); + + Assert.Equal([0], rows); + } + + private static void AssertMetadataSnapshot(string metadata, BufferLine line) + { + switch (metadata) + { + case "styled-blank": + Assert.Equal(" ", line[0].Content); + Assert.NotEqual(AttributeData.Default, line[0].Attributes); + break; + case "wrapped": + Assert.True(line.IsWrapped); + break; + case "line-attribute": + Assert.Equal(LineAttribute.DoubleWidth, line.LineAttribute); + break; + case "wide-blank": + Assert.Equal(" ", line[0].Content); + Assert.Equal(2, line[0].Width); + break; + case "combining-blank": + Assert.Equal(" \u0301", line[0].Content); + Assert.Equal(1, line[0].Width); + break; + case "link": + Assert.Equal(" ", line[0].Content); + Assert.True(line.HasLinks); + break; + case "image": + Assert.Equal(" ", line[0].Content); + Assert.True(line.HasImages); + break; + case "mark": + Assert.Equal(" ", line[0].Content); + Assert.True(line.HasMarks); + break; + case "sized-run": + Assert.Equal(" ", line[0].Content); + Assert.Equal(1, line[0].Width); + Assert.True(line.HasSizedRuns); + Assert.False(line.HasImages); + Assert.False(line.HasMarks); + Assert.False(line.HasLinks); + Assert.False(line.IsWrapped); + Assert.Equal(LineAttribute.Normal, line.LineAttribute); + break; + default: + throw new ArgumentOutOfRangeException(nameof(metadata), metadata, null); + } + } + + private static int FindViewportRow(Terminal terminal, BufferLine line) + { + for (var index = 0; index < terminal.Buffer.Lines.Length; index++) + { + if (ReferenceEquals(terminal.Buffer.Lines[index], line)) + return index - terminal.Buffer.YBase; + } + + return -1; + } + private static void SetCell(BufferLine line, string content) { var cell = new BufferCell(content, 1, AttributeData.Default); diff --git a/src/XTerm.NET/Buffer/BufferLine.cs b/src/XTerm.NET/Buffer/BufferLine.cs index a4d83458..26797b4a 100644 --- a/src/XTerm.NET/Buffer/BufferLine.cs +++ b/src/XTerm.NET/Buffer/BufferLine.cs @@ -1059,6 +1059,37 @@ public int GetTrimmedLength() return 0; } + /// + /// Whether a viewport exit snapshot would carry text, rendition or line metadata. + /// Unlike text trimming, styled blanks and metadata-only rows must survive deactivation. + /// + internal bool HasSnapshotContent() + { + var defaultAttributes = AttributeData.Default; + for (var i = _length - 1; i >= 0; i--) + { + ref var cell = ref _cells[i]; + if ((!cell.IsSpace() && !cell.IsEmpty()) + || cell.Attributes != defaultAttributes || cell.Width > 1) + return true; + + // IsSpace/IsEmpty only inspect the base codepoint. A combining mark on a space + // still carries text; resolve clusters only for otherwise ordinary blank cells. + if (cell.ClusterId != Common.ClusterTable.None) + { + foreach (var character in Common.ClusterTable.Get(cell.ClusterId)) + { + if (character != ' ' && character != '\0') + return true; + } + } + } + + // Cache and HasWideCells describe rendering/history, not the current contents. + return IsWrapped || LineAttribute != LineAttribute.Normal + || HasImages || HasMarks || HasLinks || HasSizedRuns; + } + /// /// Refills this line in place, as if it had just been constructed with . /// diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index f0b4d0be..2eb3d583 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -1186,7 +1186,8 @@ public void Reset() // Reset to normal buffer if (_usingAltBuffer) { - RaiseBufferDeactivatedLines(_altBuffer!); + if (LineExitedViewport is not null) + RaiseBufferDeactivatedLines(_altBuffer!); _buffer = _normalBuffer!; _usingAltBuffer = false; _inputHandler.SetBuffer(_buffer); @@ -2038,7 +2039,8 @@ public void SwitchToAltBuffer() if (_statusLineActive) SetActiveStatusDisplay(0); - RaiseBufferDeactivatedLines(_normalBuffer!); + if (LineExitedViewport is not null) + RaiseBufferDeactivatedLines(_normalBuffer!); var x = _buffer.X; var y = _buffer.Y; _buffer = _altBuffer!; @@ -2075,7 +2077,8 @@ public void SwitchToNormalBuffer() if (_statusLineActive) SetActiveStatusDisplay(0); - RaiseBufferDeactivatedLines(_altBuffer!); + if (LineExitedViewport is not null) + RaiseBufferDeactivatedLines(_altBuffer!); var x = _buffer.X; var y = _buffer.Y; _buffer = _normalBuffer!; @@ -2196,7 +2199,7 @@ private void RaiseBufferDeactivatedLines(Buffer.TerminalBuffer buffer) { var firstLine = buffer.BaseY; var lastLine = Math.Min(firstLine + Rows, buffer.Lines.Length) - 1; - while (lastLine >= firstLine && buffer.Lines[lastLine]?.GetTrimmedLength() == 0) + while (lastLine >= firstLine && buffer.Lines[lastLine]?.HasSnapshotContent() == false) lastLine--; for (int i = firstLine; i <= lastLine; i++)