diff --git a/src/XTerm.NET.Tests/SelectionTests.cs b/src/XTerm.NET.Tests/SelectionTests.cs new file mode 100644 index 0000000..777470c --- /dev/null +++ b/src/XTerm.NET.Tests/SelectionTests.cs @@ -0,0 +1,98 @@ +using XTerm.Options; + +namespace XTerm.NET.Tests; + +public class SelectionTests +{ + [Fact] + public void SelectionText_RemainsAnchored_WhenViewportScrolls() + { + var terminal = new Terminal(new TerminalOptions { Rows = 5, Cols = 80, Scrollback = 100 }); + + for (int i = 0; i < 20; i++) + { + terminal.WriteLine($"Line{i:00}"); + } + + terminal.ScrollToTop(); + terminal.Selection.StartSelection(4, 2); + terminal.Selection.UpdateSelection(5, 2); + terminal.Selection.EndSelection(); + + Assert.Equal("02", terminal.Selection.GetSelectionText()); + + terminal.ScrollLines(1); + + Assert.Equal("02", terminal.Selection.GetSelectionText()); + } + + [Fact] + public void IsCellSelected_TracksBufferSelectionAcrossViewportScroll() + { + var terminal = new Terminal(new TerminalOptions { Rows = 5, Cols = 80, Scrollback = 100 }); + + for (int i = 0; i < 20; i++) + { + terminal.WriteLine($"Line{i:00}"); + } + + terminal.ScrollToTop(); + terminal.Selection.StartSelection(4, 2); + terminal.Selection.UpdateSelection(5, 2); + terminal.Selection.EndSelection(); + + Assert.True(terminal.Selection.IsCellSelected(4, 2)); + Assert.False(terminal.Selection.IsCellSelected(4, 1)); + + terminal.ScrollLines(1); + + Assert.True(terminal.Selection.IsCellSelected(4, 1)); + Assert.False(terminal.Selection.IsCellSelected(4, 2)); + } + + [Fact] + public void SelectAll_IncludesScrollback_NotJustViewport() + { + var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 80, Scrollback = 20 }); + + for (int i = 0; i < 8; i++) + { + terminal.WriteLine($"Line{i}"); + } + + terminal.Selection.SelectAll(); + + var selectedText = terminal.Selection.GetSelectionText(); + + Assert.Contains("Line0", selectedText); + Assert.Contains("Line7", selectedText); + } + + [Fact] + public void Selection_IsCleared_WhenTrimRemovesSelectedLines() + { + var terminal = new Terminal(new TerminalOptions { Rows = 3, Cols = 80, Scrollback = 2 }); + + for (int i = 0; i < 5; i++) + { + terminal.WriteLine($"Line{i}"); + } + + terminal.ScrollToTop(); + var initialTopLine = terminal.GetVisibleLines()[0]; + var expectedSelectedText = initialTopLine[4].ToString(); + terminal.Selection.StartSelection(4, 0); + terminal.Selection.UpdateSelection(4, 0); + terminal.Selection.EndSelection(); + + Assert.Equal(expectedSelectedText, terminal.Selection.GetSelectionText()); + + for (int i = 5; i < 10; i++) + { + terminal.WriteLine($"Line{i}"); + } + + Assert.False(terminal.Selection.HasSelection); + Assert.Equal(string.Empty, terminal.Selection.GetSelectionText()); + } +} diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs index 5f5e8af..412c07c 100644 --- a/src/XTerm.NET/Buffer/TerminalBuffer.cs +++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs @@ -65,6 +65,11 @@ public int ViewportY public CircularList Lines => _lines; + /// + /// Fired when lines are trimmed from the start of the buffer. + /// + public event Action? Trimmed; + /// /// Saved cursor state for DECSC/DECRC. /// @@ -152,6 +157,11 @@ public void ScrollUp(int lines, bool isWrapped = false) // Push the new line at the end (bottom of screen in buffer terms) _lines.Push(newLine); + if (willBeRecycled) + { + Trimmed?.Invoke(1); + } + // Only increment yBase if the buffer didn't recycle if (!willBeRecycled) { diff --git a/src/XTerm.NET/Selection/SelectionManager.cs b/src/XTerm.NET/Selection/SelectionManager.cs index b483d00..0cdfea3 100644 --- a/src/XTerm.NET/Selection/SelectionManager.cs +++ b/src/XTerm.NET/Selection/SelectionManager.cs @@ -36,6 +36,7 @@ public SelectionManager(Terminal terminal) _terminal = terminal; _isSelecting = false; _selectionMode = SelectionMode.Normal; + _terminal.Buffer.Trimmed += HandleTrim; } /// @@ -45,8 +46,9 @@ public void StartSelection(int x, int y, SelectionMode mode = SelectionMode.Norm { _isSelecting = true; _selectionMode = mode; - _selectionStart = (x, y); - _selectionEnd = (x, y); + var absoluteY = ToAbsoluteY(y); + _selectionStart = (x, absoluteY); + _selectionEnd = (x, absoluteY); // Adjust for word or line mode if (mode == SelectionMode.Word) @@ -69,7 +71,7 @@ public void UpdateSelection(int x, int y) if (!_isSelecting || !_selectionStart.HasValue) return; - _selectionEnd = (x, y); + _selectionEnd = (x, ToAbsoluteY(y)); // Adjust for selection mode if (_selectionMode == SelectionMode.Word) @@ -109,7 +111,7 @@ public void ClearSelection() public void SelectAll() { _selectionStart = (0, 0); - _selectionEnd = (_terminal.Cols - 1, _terminal.Rows - 1); + _selectionEnd = (_terminal.Cols - 1, Math.Max(_terminal.Buffer.Lines.Length - 1, 0)); _isSelecting = false; SelectionChanged?.Invoke(); } @@ -136,7 +138,10 @@ public string GetSelectionText() for (int y = start.y; y <= end.y; y++) { - var line = buffer.Lines[buffer.YDisp + y]; + if (y < 0 || y >= buffer.Lines.Length) + continue; + + var line = buffer.Lines[y]; if (line == null) continue; @@ -164,6 +169,7 @@ public bool IsCellSelected(int x, int y) if (!HasSelection) return false; + var absoluteY = ToAbsoluteY(y); var start = _selectionStart!.Value; var end = _selectionEnd!.Value; @@ -174,16 +180,16 @@ public bool IsCellSelected(int x, int y) } // Check if cell is in selection - if (y < start.y || y > end.y) + if (absoluteY < start.y || absoluteY > end.y) return false; - if (y == start.y && y == end.y) + if (absoluteY == start.y && absoluteY == end.y) return x >= start.x && x <= end.x; - if (y == start.y) + if (absoluteY == start.y) return x >= start.x; - if (y == end.y) + if (absoluteY == end.y) return x <= end.x; return true; @@ -202,7 +208,7 @@ private void ExpandSelectionToWord() var end = _selectionEnd.Value; // Expand start to word boundary - var startLine = buffer.Lines[buffer.YDisp + start.y]; + var startLine = start.y >= 0 && start.y < buffer.Lines.Length ? buffer.Lines[start.y] : null; if (startLine != null) { while (start.x > 0 && IsWordChar(startLine[start.x - 1].Content)) @@ -212,7 +218,7 @@ private void ExpandSelectionToWord() } // Expand end to word boundary - var endLine = buffer.Lines[buffer.YDisp + end.y]; + var endLine = end.y >= 0 && end.y < buffer.Lines.Length ? buffer.Lines[end.y] : null; if (endLine != null) { while (end.x < _terminal.Cols - 1 && IsWordChar(endLine[end.x + 1].Content)) @@ -261,6 +267,46 @@ private bool IsWordChar(string ch) var c = ch[0]; return char.IsLetterOrDigit(c) || c == '_'; } + + private int ToAbsoluteY(int viewportY) + { + return _terminal.Buffer.YDisp + viewportY; + } + + private void HandleTrim(int amount) + { + if (amount <= 0) + return; + + if (_selectionStart.HasValue) + { + _selectionStart = (_selectionStart.Value.x, _selectionStart.Value.y - amount); + } + + if (_selectionEnd.HasValue) + { + _selectionEnd = (_selectionEnd.Value.x, _selectionEnd.Value.y - amount); + } + + if (_selectionEnd.HasValue && _selectionEnd.Value.y < 0) + { + ClearSelection(); + return; + } + + if (_selectionStart.HasValue && _selectionStart.Value.y < 0) + { + _selectionStart = (0, 0); + } + + if (_selectionEnd.HasValue) + { + var maxY = Math.Max(_terminal.Buffer.Lines.Length - 1, 0); + _selectionEnd = (_selectionEnd.Value.x, Math.Min(_selectionEnd.Value.y, maxY)); + } + + SelectionChanged?.Invoke(); + } } ///