diff --git a/src/XTerm.NET.Tests/LineExitedViewportTests.cs b/src/XTerm.NET.Tests/LineExitedViewportTests.cs new file mode 100644 index 0000000..ba15fca --- /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 e59abdf..6b00364 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 7cb4e58..b1add41 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 3de73cd..51262ac 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 66693d9..4f761c9 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;