diff --git a/src/XTerm.NET.Tests/LineExitedViewportTests.cs b/src/XTerm.NET.Tests/LineExitedViewportTests.cs new file mode 100644 index 00000000..2c094352 --- /dev/null +++ b/src/XTerm.NET.Tests/LineExitedViewportTests.cs @@ -0,0 +1,373 @@ +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 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() + { + 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 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++; + + if (scrollDown) + terminal.Buffer.ScrollDown(1); + else + 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(); + string? captured = null; + BufferType? buffer = null; + LineExitReason? reason = null; + terminal.LineExitedViewport += (_, args) => + { + captured = args.Line.TranslateToString(trimRight: true); + buffer = args.Buffer; + reason = args.Reason; + events.Add("exit"); + }; + terminal.BufferChanged += (_, _) => events.Add("changed"); + + terminal.SwitchToAltBuffer(); + + 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); + line.SetCell(0, ref cell); + } +} 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/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index e59abdff..da502b1d 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. @@ -371,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/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/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 f656c110..feda20ec 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -562,6 +562,21 @@ internal bool TrySetUserVariable(string name, string value) /// public event EventHandler? LineFed; + /// + /// 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. /// @@ -760,6 +775,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; @@ -1169,6 +1186,8 @@ public void Reset() // Reset to normal buffer if (_usingAltBuffer) { + if (LineExitedViewport is not null) + RaiseBufferDeactivatedLines(_altBuffer!); _buffer = _normalBuffer!; _usingAltBuffer = false; _inputHandler.SetBuffer(_buffer); @@ -2066,6 +2085,8 @@ public void SwitchToAltBuffer() if (_statusLineActive) SetActiveStatusDisplay(0); + if (LineExitedViewport is not null) + RaiseBufferDeactivatedLines(_normalBuffer!); var x = _buffer.X; var y = _buffer.Y; _buffer = _altBuffer!; @@ -2102,6 +2123,8 @@ public void SwitchToNormalBuffer() if (_statusLineActive) SetActiveStatusDisplay(0); + if (LineExitedViewport is not null) + RaiseBufferDeactivatedLines(_altBuffer!); var x = _buffer.X; var y = _buffer.Y; _buffer = _normalBuffer!; @@ -2205,6 +2228,34 @@ 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 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) + { + var firstLine = buffer.BaseY; + var lastLine = Math.Min(firstLine + Rows, buffer.Lines.Length) - 1; + while (lastLine >= firstLine && buffer.Lines[lastLine]?.HasSnapshotContent() == false) + 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; @@ -2261,6 +2312,8 @@ public void Dispose() Resized = null; Scrolled = null; LineFed = null; + LineExitedViewport = null; + ViewportRedrawStarting = null; DirectoryChanged = null; HyperlinkChanged = null; ShellIntegrationMarkReceived = null;