diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs index 4e5753d4a4..e17a052e73 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.cs @@ -746,7 +746,7 @@ private void DoDrawComplete (DrawContext? context) if (!viewTransparent) { - exclusion.Combine (ViewportToScreen (Viewport), RegionOp.Union); + exclusion.Combine (ViewportToScreen (new Rectangle (Point.Empty, Viewport.Size)), RegionOp.Union); } // For transparent layers, also include context drawn regions (text, content, subviews) diff --git a/Terminal.Gui/Views/TextInput/TextModel.cs b/Terminal.Gui/Views/TextInput/TextModel.cs index f066acb137..6a9849354f 100644 --- a/Terminal.Gui/Views/TextInput/TextModel.cs +++ b/Terminal.Gui/Views/TextInput/TextModel.cs @@ -16,6 +16,7 @@ internal class TextModel // Cached max visible line width to avoid O(N×L) rescans on every layout/scroll. private int _cachedMaxWidth = -1; private int _cachedMaxWidthTabWidth = -1; + private Dictionary _cachedMaxWidthPerLine = []; /// /// Gets the number of times performed a full line scan. @@ -34,6 +35,38 @@ internal class TextModel /// Invalidates the cached max line width so the next call to will rescan. internal void InvalidateMaxWidthCache () => _cachedMaxWidth = -1; + /// + /// Determines whether the max width cache should be invalidated based on the line being modified, the column width of the modification, and whether it's an insert or delete operation. + /// + /// The line number being modified. + /// Indicates whether the operation is an insert. Defaults to true. + /// The width of the column being modified. Defaults to -1 on delete. + /// if the cache should be invalidated; otherwise, . + internal bool ShouldInvalidateMaxWidthCache (int line, bool isInsert = true, int columnWidth = -1) + { + if (_cachedMaxWidth < 0) + { + return true; + } + + if (isInsert) + { + if (_cachedMaxWidthPerLine.TryGetValue (line, out int cachedLineWidth) && columnWidth > cachedLineWidth) + { + return true; + } + } + else + { + if (_cachedMaxWidthPerLine.Count == 1 && _cachedMaxWidthPerLine.ContainsKey (line)) + { + return true; + } + } + + return false; + } + /// Adds a line to the model at the specified position. /// Line number where the line will be inserted. /// The line of text and color, as a List of Cell. @@ -96,6 +129,12 @@ public int GetMaxVisibleLine (int first, int last, int tabWidth) var maxLength = 0; last = last < _lines.Count ? last : _lines.Count; + // When scanning the full range, reset cached max width per line + if (first == 0 && last >= _lines.Count) + { + _cachedMaxWidthPerLine = []; + } + for (int i = first; i < last; i++) { List line = GetLine (i); @@ -104,6 +143,17 @@ public int GetMaxVisibleLine (int first, int last, int tabWidth) if (colsWidth > maxLength) { maxLength = colsWidth; + + // Cache max width per line when scanning the full range, and remove cached widths for lines that are no longer the max + if (first != 0 || last < _lines.Count) + { + continue; + } + _cachedMaxWidthPerLine = new Dictionary { { i, maxLength } }; + } + else if (maxLength == colsWidth) + { + _cachedMaxWidthPerLine [i] = maxLength; } } diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs index 06efc32c40..7af7ad1763 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs @@ -383,8 +383,8 @@ public bool DeleteCharLeft () bool retValue = DeleteTextLeft (); - DoNeededAction (); OnContentsChanged (); + DoNeededAction (); return retValue; } @@ -453,37 +453,45 @@ private bool DeleteTextLeft () } SetWrapModel (); - int prowIdx = CurrentRow - 1; - List prevRow = _model.GetLine (prowIdx); - _historyText.Add ([[.. prevRow]], InsertionPoint); + if (CurrentRow - 1 > -1) + { + int prowIdx = CurrentRow - 1; + List prevRow = _model.GetLine (prowIdx); + + _historyText.Add ([[.. prevRow]], InsertionPoint); - List> removedLines = [[.. prevRow], [.. GetCurrentLine ()]]; + List> removedLines = [[.. prevRow], [.. GetCurrentLine ()]]; - _historyText.Add (removedLines, new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Removed); + _historyText.Add (removedLines, new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Removed); - int prevCount = prevRow.Count; - _model.GetLine (prowIdx).AddRange (GetCurrentLine ()); - _model.RemoveLine (CurrentRow); + int prevCount = prevRow.Count; + _model.GetLine (prowIdx).AddRange (GetCurrentLine ()); + _model.RemoveLine (CurrentRow); + + CurrentRow--; + + _historyText.Add ([GetCurrentLine ()], new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Replaced); + + CurrentColumn = prevCount; + } if (_wordWrap) { _wrapNeeded = true; } - - CurrentRow--; - - _historyText.Add ([GetCurrentLine ()], new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Replaced); - - CurrentColumn = prevCount; } - // Always redraw and update content size because a glyph was deleted + // Text was deleted, so it's always needed to redraw and update content size if needed SetNeedsDraw (); - UpdateContentSize (); + + if (_model.ShouldInvalidateMaxWidthCache (CurrentRow, false)) + { + _model.InvalidateMaxWidthCache (); + UpdateContentSize (); + } UpdateWrapModel (); - OnContentsChanged (); return true; } @@ -513,8 +521,13 @@ private bool DeleteTextRight () _historyText.Add (removedLines, InsertionPoint, TextEditingLineStatus.Removed); currentLine.AddRange (nextLine); _model.RemoveLine (CurrentRow + 1); + + // Text was deleted, so it's always needed to redraw and update content size if needed SetNeedsDraw (); + // _model.RemoveLine already invalidates the max width cache for the removed line, but we also need to check if the merged line's width changed + UpdateContentSize (); + _historyText.Add ([[.. currentLine]], InsertionPoint, TextEditingLineStatus.Replaced); if (_wordWrap) @@ -531,8 +544,16 @@ private bool DeleteTextRight () _historyText.Add ([[.. currentLine]], InsertionPoint); currentLine.RemoveAt (CurrentColumn); + + // Text was deleted, so it's always needed to redraw and update content size if needed SetNeedsDraw (); + if (_model.ShouldInvalidateMaxWidthCache (CurrentRow, false)) + { + _model.InvalidateMaxWidthCache (); + UpdateContentSize (); + } + _historyText.Add ([[.. currentLine]], InsertionPoint, TextEditingLineStatus.Replaced); if (_wordWrap) @@ -660,6 +681,7 @@ private bool CutToStartOfLine () UpdateWrapModel (); DeleteTextLeft (); + OnContentsChanged (); return true; } @@ -750,6 +772,7 @@ private bool KillWordLeft () if (CurrentColumn == 0) { DeleteTextLeft (); + OnContentsChanged (); _historyText.ReplaceLast ([[.. GetCurrentLine ()]], InsertionPoint, TextEditingLineStatus.Replaced); @@ -1010,6 +1033,7 @@ private bool ProcessTab (bool addTab) if (CurrentColumn - 1 > -1 && CurrentColumn - 1 < line.Count && line [CurrentColumn - 1].Grapheme == "\t") { DeleteTextLeft (); + OnContentsChanged (); } else { diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs index 25aae3a33d..dbffdc6f9c 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs @@ -108,7 +108,7 @@ private bool MoveDown () CurrentRow++; - if (CurrentRow >= Viewport.Y + Viewport.Height) + if (CurrentRow >= Viewport.Y + Viewport.Height || CurrentRow < Viewport.Y) { SetNeedsDraw (); } @@ -145,8 +145,9 @@ private bool MoveEndOfLine () List currentLine = GetCurrentLine (); CurrentColumn = currentLine.Count; - if (CurrentColumn >= Viewport.X + Viewport.Width || TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _) - Viewport.X >= Viewport.Width) -{ + if (CurrentColumn >= Viewport.X + Viewport.Width + || TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _) - Viewport.X >= Viewport.Width) + { SetNeedsDraw (); } DoNeededAction (); @@ -160,7 +161,10 @@ private bool MoveLeft () { CurrentColumn--; - if (Viewport.X > 0 && CurrentColumn <= Viewport.X) + List currentLine = GetCurrentLine (); + int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _); + + if ((Viewport.X > 0 && cursorColumn <= Viewport.X) || cursorColumn - Viewport.X >= Viewport.Width) { SetNeedsDraw (); } @@ -296,7 +300,9 @@ private bool MoveRight () { CurrentColumn++; - if (CurrentColumn >= currentLine.Count || TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _) >= Viewport.Width) + int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _); + + if (cursorColumn >= currentLine.Count || (Viewport.X > 0 && cursorColumn < Viewport.X) || cursorColumn >= Viewport.X + Viewport.Width) { SetNeedsDraw (); } @@ -346,16 +352,19 @@ private bool MoveTopHomeExtend () private bool MoveUp () { - if (CurrentRow > 0) + if (CurrentRow > 0 || (CurrentRow == 0 && CurrentRow < Viewport.Y)) { if (_columnTrack == -1) { _columnTrack = CurrentColumn; } - CurrentRow--; + if (CurrentRow > 0) + { + CurrentRow--; + } - if (CurrentRow < Viewport.Y) + if (CurrentRow < Viewport.Y || CurrentRow >= Viewport.Y + Viewport.Height) { SetNeedsDraw (); } @@ -393,6 +402,14 @@ private bool MoveWordLeft () CurrentRow = newPos.Value.row; } + List currentLine = GetCurrentLine (); + int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _); + + if (CurrentRow < Viewport.Y || cursorColumn < Viewport.X || cursorColumn >= Viewport.X + Viewport.Width) + { + SetNeedsDraw (); + } + DoNeededAction (); return true; @@ -408,6 +425,14 @@ private bool MoveWordRight () CurrentRow = newPos.Value.row; } + List currentLine = GetCurrentLine (); + int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _); + + if (CurrentRow >= Viewport.Y + Viewport.Height || cursorColumn >= Viewport.X + Viewport.Width || cursorColumn < Viewport.X) + { + SetNeedsDraw (); + } + DoNeededAction (); return true; diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs index c90f82944f..c09ce04f5d 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs @@ -115,11 +115,16 @@ private void AdjustViewport () } need = true; } - else if (!_wordWrap && (CurrentColumn - Viewport.X + 1 > Viewport.Width || dSize.size + 1 >= Viewport.Width)) + else if (!_wordWrap && (CurrentColumn - Viewport.X + 1 > Viewport.Width || dSize.size - Viewport.X + 1 >= Viewport.Width)) { Viewport = Viewport with { X = TextModel.CalculateLeftColumn (line, Viewport.X, CurrentColumn, Viewport.Width, TabWidth) }; need = true; } + else if (tSize.size - Viewport.X + 1 <= Viewport.Width) + { + Viewport = Viewport with { X = Math.Max (0, tSize.size - Viewport.Width + 1) }; + need = true; + } else if ((_wordWrap && Viewport.X > 0) || (dSize.size < Viewport.Width && tSize.size < Viewport.Width)) { if (Viewport.X > 0) @@ -140,11 +145,6 @@ private void AdjustViewport () Viewport = Viewport with { Y = Math.Min (Math.Max (CurrentRow - Viewport.Height + 1, 0), CurrentRow) }; need = true; } - else if (!WordWrap && Viewport.Y > 0 && CurrentRow - Viewport.Height + 1 < Viewport.Y) - { - Viewport = Viewport with { Y = Math.Max (Viewport.Y - 1, 0) }; - need = true; - } if (need) { diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs index 1a2d9b3a69..d12c8457ff 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs @@ -558,23 +558,19 @@ private void InsertText (Key a, Attribute? attribute = null) return; } - if (Used) - { - Insert (new Cell { Grapheme = grapheme, Attribute = attribute }); - CurrentColumn++; - } - else - { - Insert (new Cell { Grapheme = grapheme, Attribute = attribute }); - CurrentColumn++; - } - UpdateContentSize (); + Insert (new Cell { Grapheme = grapheme, Attribute = attribute }); + CurrentColumn++; + + // Text was inserted, so it's always needed to redraw and update content size if needed + SetNeedsDraw (); + List line = GetCurrentLine (); - (int size, int length) dSize = TextModel.DisplaySize (line, 0, CurrentColumn, true, TabWidth); + (int size, int length) dSize = TextModel.DisplaySize (line, 0, line.Count, true, TabWidth); - if (dSize.size + 1 - Viewport.X >= Viewport.Width) + if (_model.ShouldInvalidateMaxWidthCache (CurrentRow, true, dSize.size)) { - SetNeedsDraw (); + _model.InvalidateMaxWidthCache (); + UpdateContentSize (); } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs index f81623a2b9..e77fec9839 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs @@ -234,6 +234,45 @@ public void TransparentView_ExcludesBorderAndPadding () Assert.False (driver.Clip.Contains (paddingFrame.X + 1, paddingFrame.Y), "Padding area should be excluded from clip for transparent view"); } + /// + /// Verifies that the opaque viewport exclusion in the per-layer DoDrawComplete path uses + /// an empty viewport location. A scrolled viewport must still exclude the on-screen viewport + /// area rather than an offset content rectangle. + /// + [Fact] + public void OpaqueView_WithScrolledViewport_ExcludesOnScreenViewportArea () + { + IDriver driver = CreateTestDriver (); + driver.Clip = new Region (driver.Screen); + + View view = new () + { + X = 5, + Y = 5, + Width = 12, + Height = 12, + BorderStyle = LineStyle.Single, + Driver = driver + }; + view.Border.ViewportSettings |= ViewportSettingsFlags.Transparent; + view.SetContentSize (new Size (100, 100)); + view.ClearingViewport += (_, e) => e.Cancel = true; + view.BeginInit (); + view.EndInit (); + view.LayoutSubViews (); + + view.Viewport = view.Viewport with { Location = new Point (10, 10) }; + + Assert.NotEqual (Point.Empty, view.Viewport.Location); + + view.Draw (); + + Rectangle viewportScreen = view.ViewportToScreen (new Rectangle (Point.Empty, view.Viewport.Size)); + + Assert.False (driver.Clip!.Contains (viewportScreen.X + 1, viewportScreen.Y + 1), + "Scrolled viewport interior should be excluded using an empty viewport location"); + } + /// /// Verifies that Adornment views (Margin, Border, Padding) do NOT modify Driver.Clip /// in their own DoDrawComplete — their parent handles clip exclusion for them. diff --git a/Tests/UnitTestsParallelizable/Views/TextModelTests.cs b/Tests/UnitTestsParallelizable/Views/TextModelTests.cs index 04cf75022b..dc18664755 100644 --- a/Tests/UnitTestsParallelizable/Views/TextModelTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextModelTests.cs @@ -1413,4 +1413,110 @@ public void GetLine_ThreadSafe_MultipleAccess () } #endregion + + #region Additional tests for TextModel.ShouldInvalidateMaxWidthCache logic + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_True_When_Line_Modified_IsGreater_Than_CachedMaxWidth_Line_On_Insert () + { + TextModel model = new (); + model.LoadString ("Short\nMedium line\nThe longest line in the document"); + + // Prime cache + model.GetMaxVisibleLine (0, model.Count, 4); + + // The longest line is line 2 (0-based index) + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, true, 33); + Assert.True (shouldInvalidate, "Cache should be invalidated when inserting into the cached max width line"); + } + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_True_When_Line_Modified_Is_CachedMaxWidth_Line_On_Delete () + { + TextModel model = new (); + model.LoadString ("Short\nMedium line\nThe longest line in the document"); + + // Prime cache + model.GetMaxVisibleLine (0, model.Count, 4); + + // The longest line is line 2 (0-based index) + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, false); + Assert.True (shouldInvalidate, "Cache should be invalidated when deleting from the cached max width line"); + } + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_Is_Less_Than_CachedMaxWidth_Line_On_Insert () + { + TextModel model = new (); + model.LoadString ("Short\nMedium line\nThe longest line in the document"); + + // Prime cache + model.GetMaxVisibleLine (0, model.Count, 4); + + // The longest line is line 2 (0-based index) + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (1, true, 10); + Assert.False (shouldInvalidate, "Cache should NOT be invalidated when inserting into a line shorter than the cached max width line"); + } + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_Is_Not_CachedMaxWidth_Line_On_Delete () + { + TextModel model = new (); + model.LoadString ("Short\nMedium line\nThe longest line in the document"); + + // Prime cache + model.GetMaxVisibleLine (0, model.Count, 4); + + // The longest line is line 2 (0-based index) + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (1, false); + Assert.False (shouldInvalidate, "Cache should NOT be invalidated when deleting from a line that is not the cached max width line"); + } + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_True_When_Cache_Is_Uninitialized () + { + TextModel model = new (); + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (0, true, 10); + Assert.True (shouldInvalidate, "Cache should be invalidated when the cache is uninitialized"); + } + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_True_When_Cache_Is_Uninitialized_On_Delete () + { + TextModel model = new (); + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (0, false); + Assert.True (shouldInvalidate, "Cache should be invalidated when the cache is uninitialized on delete"); + } + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_Equals_CachedMaxWidth_Line_On_Insert () + { + TextModel model = new (); + model.LoadString ("Short\nMedium line\nThe longest line in the document"); + + // Prime cache + model.GetMaxVisibleLine (0, model.Count, 4); + + // The longest line is line 2 (0-based index) + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, true, 32); + Assert.False (shouldInvalidate, "Cache should NOT be invalidated when inserting new width that does not exceed the cached width"); + } + + [Fact] + public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_HasMoreLinesWithSame_CachedMaxWidth_Line_On_Delete () + { + TextModel model = new (); + model.LoadString ("Short\nMedium line\nThe longest line in the document 1\nThe longest line in the document 2"); + + // Prime cache + model.GetMaxVisibleLine (0, model.Count, 4); + + // The longest line are line 2 and line 3 (0-based index) + bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, false); + + Assert.False (shouldInvalidate, + "Cache should NOT be invalidated when deleting from a line that is not the only cached max width line because there are multiple lines with the same max width"); + } + + #endregion } diff --git a/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs index c8da5d9433..e9ab0d8b5f 100644 --- a/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs @@ -282,6 +282,54 @@ public void DeleteCharLeft_With_Selection_Removes_Selected_Text () Assert.False (tv.IsSelecting); } + [Fact] + public void DeleteCharLeft_Only_Raises_ContentsChanged_Once () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 40, Height = 10, Text = "Hello" }; + runnable.Add (tv); + app.Begin (runnable); + tv.InsertionPoint = new Point (5, 0); + + int contentsChangedCount = 0; + tv.ContentsChanged += (_, _) => contentsChangedCount++; + + bool result = tv.DeleteCharLeft (); + + Assert.True (result); + Assert.Equal ("Hell", tv.Text); + Assert.Equal (1, contentsChangedCount); + } + + [Fact] + public void DeleteCharLeft_With_WordWarp_True_And_Cursor_At_Start_Of_SecondLine_Wont_Return_Negative_LineIndex () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 10, Height = 3, Text = "One line test", WordWrap = true }; + runnable.Add (tv); + app.Begin (runnable); + tv.InsertionPoint = new Point (0, 1); + + Assert.True (tv.WordWrap); + Assert.Equal (2, tv.Lines); + string line = tv.GetLine (0).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next); + Assert.Equal ("One line ", line); + line = tv.GetLine (1).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next); + Assert.Equal ("test", line); + + tv.NewKeyDownEvent (Key.Backspace); + Assert.Equal (new Point (9, 0), tv.InsertionPoint); + Assert.Equal (2, tv.Lines); + line = tv.GetLine (0).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next); + Assert.Equal ("One line ", line); + line = tv.GetLine (1).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next); + Assert.Equal ("test", line); + } + #endregion #region DeleteCharRight @@ -381,6 +429,27 @@ public void DeleteCharRight_With_Selection_Removes_Selected_Text () Assert.False (tv.IsSelecting); } + [Fact] + public void DeleteCharRight_Only_Raises_ContentsChanged_Once () + { + using IApplication app = Application.Create (); + using Runnable runnable = new (); + + TextView tv = new () { Width = 40, Height = 10, Text = "Hello" }; + runnable.Add (tv); + app.Begin (runnable); + tv.InsertionPoint = new Point (0, 0); + + int contentsChangedCount = 0; + tv.ContentsChanged += (_, _) => contentsChangedCount++; + + bool result = tv.DeleteCharRight (); + + Assert.True (result); + Assert.Equal ("ello", tv.Text); + Assert.Equal (1, contentsChangedCount); + } + #endregion #region Copy diff --git a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs index 9d49854c2e..d40affed1e 100644 --- a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs @@ -467,6 +467,54 @@ public void Tab_Advances_Correctly_To_Next_Tab_Stop (string text, int tabWidth, Assert.Equal (expectedColumn, visualColumn); } + [Fact] + public void Typing_Tab_At_End_Of_Line_With_Wrap_Disabled_Should_Scroll_Horizontally_And_Update_Content_Size () + { + TextView tv = new () + { + Width = 5, + Height = 2, + Text = "Line1\nLine2\nLine3" + }; + tv.BeginInit (); + tv.EndInit (); + + tv.InsertionPoint = new Point (6, 0); + Assert.Equal (new Point (1, 0), tv.Viewport.Location); + Assert.Equal (new Point (5, 0), tv.InsertionPoint); + Assert.Equal (new Size (6, 3), tv.GetContentSize ()); + + tv.NewKeyDownEvent (Key.Tab); + + Assert.Equal (new Point (4, 0), tv.Viewport.Location); + Assert.Equal (new Point (6, 0), tv.InsertionPoint); + Assert.Equal (new Size (9, 3), tv.GetContentSize ()); + } + + [Fact] + public void Typing_ShiftTab_At_End_Of_Line_With_Wrap_Disabled_Should_Scroll_Horizontally_And_Update_Content_Size () + { + TextView tv = new () + { + Width = 5, + Height = 2, + Text = "Line1\t\nLine2\nLine3" + }; + tv.BeginInit (); + tv.EndInit (); + + tv.InsertionPoint = new Point (9, 0); + Assert.Equal (new Point (4, 0), tv.Viewport.Location); + Assert.Equal (new Point (6, 0), tv.InsertionPoint); + Assert.Equal (new Size (9, 3), tv.GetContentSize ()); + + tv.NewKeyDownEvent (Key.Tab.WithShift); + + Assert.Equal (new Point (1, 0), tv.Viewport.Location); + Assert.Equal (new Point (5, 0), tv.InsertionPoint); + Assert.Equal (new Size (6, 3), tv.GetContentSize ()); + } + [Fact] public void EnterKeyAddsLine_Setter_Should_Not_Scroll_View () { diff --git a/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs index 2175cb119b..8cd3572866 100644 --- a/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs @@ -14,12 +14,7 @@ public void PageUp_Navigates_Up_One_Page () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line.first" - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line.first" }; runnable.Add (tv); app.Begin (runnable); @@ -47,12 +42,7 @@ public void PageDown_Navigates_Down_One_Page () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line.first" - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line.first" }; runnable.Add (tv); app.Begin (runnable); @@ -79,12 +69,7 @@ public void CtrlHome_Navigates_To_Start_Of_Document () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line." - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." }; runnable.Add (tv); app.Begin (runnable); @@ -107,12 +92,7 @@ public void CtrlN_Navigates_To_Next_Line () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line." - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." }; runnable.Add (tv); app.Begin (runnable); @@ -134,12 +114,7 @@ public void CtrlP_Navigates_To_Previous_Line () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line." - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." }; runnable.Add (tv); app.Begin (runnable); @@ -162,12 +137,7 @@ public void CursorDown_And_CursorUp_Navigate_Lines () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line." - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." }; runnable.Add (tv); app.Begin (runnable); @@ -193,12 +163,7 @@ public void End_Key_Navigates_To_End_Of_Line () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "is is the first line\nThis is the second line.\nThis is the third line.first" - }; + TextView tv = new () { Width = 10, Height = 2, Text = "is is the first line\nThis is the second line.\nThis is the third line.first" }; runnable.Add (tv); app.Begin (runnable); @@ -219,12 +184,7 @@ public void Home_Key_Navigates_To_Start_Of_Line () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "is is the first lin\nThis is the second line.\nThis is the third line.first" - }; + TextView tv = new () { Width = 10, Height = 2, Text = "is is the first lin\nThis is the second line.\nThis is the third line.first" }; runnable.Add (tv); app.Begin (runnable); @@ -247,12 +207,7 @@ public void CtrlE_Navigates_To_End_Of_Line () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "is is the first lin\nThis is the second line.\nThis is the third line.first" - }; + TextView tv = new () { Width = 10, Height = 2, Text = "is is the first lin\nThis is the second line.\nThis is the third line.first" }; runnable.Add (tv); app.Begin (runnable); @@ -273,12 +228,7 @@ public void CtrlF_Moves_Forward_One_Character () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line." - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." }; runnable.Add (tv); app.Begin (runnable); @@ -299,12 +249,7 @@ public void CtrlB_Moves_Backward_One_Character () using IApplication app = Application.Create (); using Runnable runnable = new (); - TextView tv = new () - { - Width = 10, - Height = 2, - Text = "This is the first line.\nThis is the second line.\nThis is the third line." - }; + TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." }; runnable.Add (tv); app.Begin (runnable); @@ -318,4 +263,388 @@ public void CtrlB_Moves_Backward_One_Character () Assert.Equal (Point.Empty, tv.InsertionPoint); Assert.False (tv.IsSelecting); } -} + + [Fact] + public void CursorRight_At_NearTheEndOfLine_With_ViewportY_Greater_Than_Zero_Does_Not_Scroll_Up () + { + // Test that pressing CursorRight at near the end of line does not scroll up if Viewport.Y > 0 + TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3.\nLine4.\nLine5." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to second line and set insertion point at near the end of line + tv.Viewport = tv.Viewport with { Y = 1 }; + tv.InsertionPoint = new Point (5, 1); + Assert.Equal (new Point (0, 1), tv.Viewport.Location); + Assert.Equal (new Point (5, 1), tv.InsertionPoint); + Assert.False (tv.WordWrap); + + // Press CursorRight - should not scroll up since we aren't already at first line + Assert.True (tv.NewKeyDownEvent (Key.CursorRight)); + Assert.Equal (new Point (0, 1), tv.Viewport.Location); + Assert.Equal (new Point (6, 1), tv.InsertionPoint); + } + + [Fact] + public void CursorRight_At_BeforeNearTheEndOfLine_With_ViewportX_Greater_Than_Zero_Does_Not_Scroll_Left () + { + // Test that pressing CursorRight at near the end of line does not scroll left if Viewport.X > 0 + TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to the column 10 and set insertion point at before near the end of line + tv.Viewport = tv.Viewport with { X = 10 }; + tv.InsertionPoint = new Point (17, 0); + Assert.Equal (new Point (10, 0), tv.Viewport.Location); + Assert.Equal (new Point (17, 0), tv.InsertionPoint); + Assert.False (tv.WordWrap); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorRight key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorRight - should not scroll left since we aren't already at the end of the line + Assert.True (tv.NewKeyDownEvent (Key.CursorRight)); + Assert.Equal (new Point (10, 0), tv.Viewport.Location); + Assert.Equal (new Point (18, 0), tv.InsertionPoint); + Assert.False (tv.NeedsDraw); + } + + [Fact] + public void CursorRight_With_CtrlKey_Pressed_At_BeforeNearTheEndOfLine_Scrolls_Right_Next_Word () + { + // Test that pressing Ctrl+CursorRight at neat the end of line scrolls right to next word + TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to the column 10 and set insertion point at before near the end of line + tv.Viewport = tv.Viewport with { X = 10 }; + tv.InsertionPoint = new Point (17, 0); + Assert.Equal (new Point (10, 0), tv.Viewport.Location); + Assert.Equal (new Point (17, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of Ctrl+CursorRight key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press Ctrl+CursorRight - should scroll right to next word + Assert.True (tv.NewKeyDownEvent (Key.CursorRight.WithCtrl)); + Assert.Equal (new Point (12, 0), tv.Viewport.Location); + Assert.Equal (new Point (21, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorRight_With_CtrlKey_Pressed_At_TheEndOfLine_Scrolls_Left_To_StartOfNextLine () + { + // Test that pressing Ctrl+CursorRight at the end of line scrolls right to start of next line + TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to the column 17 and set insertion point at the end of line + tv.Viewport = tv.Viewport with { X = 17 }; + tv.InsertionPoint = new Point (26, 0); + Assert.Equal (new Point (17, 0), tv.Viewport.Location); + Assert.Equal (new Point (26, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of Ctrl+CursorRight key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press Ctrl+CursorRight - should scroll right to start of next line + Assert.True (tv.NewKeyDownEvent (Key.CursorRight.WithCtrl)); + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (0, 1), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorRight_With_CtrlKey_Pressed_With_Mixed_Graphemes_Should_Scrolls_Right_To_Make_Cursor_Visible () + { + // Test that pressing Ctrl+CursorRight with mixed graphemes scrolls right to make cursor visible + TextView tv = new () { Width = 10, Height = 3, Text = "Line1\t with more long 🍎 text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Set insertion point at column 12 and then scroll to the column 9 so that the insertion point is near at the end of the Viewport + tv.InsertionPoint = new Point (12, 0); + tv.Viewport = tv.Viewport with { X = 9 }; + Assert.Equal (new Point (9, 0), tv.Viewport.Location); + Assert.Equal (new Point (12, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of Ctrl+CursorRight key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press Ctrl+CursorRight - should move right and scroll since we are already at the end of the viewport + Assert.True (tv.NewKeyDownEvent (Key.CursorRight.WithCtrl)); + Assert.Equal (new Point (10, 0), tv.Viewport.Location); + Assert.Equal (new Point (17, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorLeft_With_CtrlKey_Pressed_At_BeforeNearTheStartOfLine_Scrolls_Left_Previous_Word () + { + // Test that pressing Ctrl+CursorLeft at neat the start of line scrolls left to previous word + TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to the column 2 and set insertion point at before near the start of line + tv.Viewport = tv.Viewport with { X = 2 }; + tv.InsertionPoint = new Point (4, 0); + Assert.Equal (new Point (2, 0), tv.Viewport.Location); + Assert.Equal (new Point (4, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of Ctrl+CursorLeft key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press Ctrl+CursorLeft - should scroll left to previous word + Assert.True (tv.NewKeyDownEvent (Key.CursorLeft.WithCtrl)); + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (0, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorLeft_With_CtrlKey_Pressed_At_TheStartOfLine_Scrolls_Right_To_EndOfPreviousLine () + { + // Test that pressing Ctrl+CursorLeft at the start of line scrolls left to end of previous line + TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to the column 0 and set insertion point at the start of line + tv.Viewport = tv.Viewport with { X = 0, Y = 1 }; + tv.InsertionPoint = new Point (0, 1); + Assert.Equal (new Point (0, 1), tv.Viewport.Location); + Assert.Equal (new Point (0, 1), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of Ctrl+CursorLeft key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press Ctrl+CursorLeft - should scroll left to end of previous line + Assert.True (tv.NewKeyDownEvent (Key.CursorLeft.WithCtrl)); + Assert.Equal (new Point (17, 0), tv.Viewport.Location); + Assert.Equal (new Point (26, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorLeft_With_CtrlKey_Pressed_With_Mixed_Graphemes_Only_Scrolls_Left_When_Needed () + { + // Test that pressing Ctrl+CursorLeft with mixed graphemes only scrolls left when needed + TextView tv = new () { Width = 10, Height = 3, Text = "Line1\t with more long 🍎 text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Set insertion point at column 10 and then scroll to the column 8 so that the insertion point is near at the start of the Viewport + tv.InsertionPoint = new Point (10, 0); + tv.Viewport = tv.Viewport with { X = 8 }; + Assert.Equal (new Point (8, 0), tv.Viewport.Location); + Assert.Equal (new Point (10, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of Ctrl+CursorLeft key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press Ctrl+CursorLeft - should move left but not scroll since we aren't already near at the start of the Viewport.X + Assert.True (tv.NewKeyDownEvent (Key.CursorLeft.WithCtrl)); + Assert.Equal (new Point (8, 0), tv.Viewport.Location); + Assert.Equal (new Point (7, 0), tv.InsertionPoint); + Assert.False (tv.NeedsDraw); + } + + [Fact] + public void CursorLeft_With_Mixed_Graphemes_Only_Scrolls_Left_When_Needed () + { + // Test that pressing CursorLeft with mixed graphemes only scrolls left when needed + TextView tv = new () { Width = 10, Height = 3, Text = "Line1\t with more long 🍎 text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Set insertion point at column 10 and then scroll to the column 10 so that the insertion point is near at the start of the Viewport + tv.InsertionPoint = new Point (10, 0); + tv.Viewport = tv.Viewport with { X = 10 }; + Assert.Equal (new Point (10, 0), tv.Viewport.Location); + Assert.Equal (new Point (10, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorLeft key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorLeft - should move left but not scroll since we aren't already near at the start of the Viewport.X + Assert.True (tv.NewKeyDownEvent (Key.CursorLeft)); + Assert.Equal (new Point (10, 0), tv.Viewport.Location); + Assert.Equal (new Point (9, 0), tv.InsertionPoint); + Assert.False (tv.NeedsDraw); + } + + [Fact] + public void CursorRight_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible () + { + // Test that pressing CursorRight at text hidden by scroll moves cursor and adjusts scroll to make cursor visible + TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to the column 10 and insertion point stays at the column 0 + tv.Viewport = tv.Viewport with { X = 10 }; + Assert.Equal (new Point (10, 0), tv.Viewport.Location); + Assert.Equal (new Point (0, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorRight key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorRight - should move cursor right and scroll to make it visible + Assert.True (tv.NewKeyDownEvent (Key.CursorRight)); + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (1, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorLeft_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible () + { + // Test that pressing CursorLeft at text hidden by scroll moves cursor and adjusts scroll to make cursor visible + TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." }; + tv.BeginInit (); + tv.EndInit (); + + // Set insertion point at the last column and then scroll to the column 0 + tv.InsertionPoint = new Point (26, 0); + tv.Viewport = tv.Viewport with { X = 0 }; + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (26, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorLeft key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorLeft - should move cursor left and scroll to make it visible + Assert.True (tv.NewKeyDownEvent (Key.CursorLeft)); + Assert.Equal (new Point (16, 0), tv.Viewport.Location); + Assert.Equal (new Point (25, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorDown_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible () + { + // Test that pressing CursorDown at text hidden by scroll moves cursor and adjusts scroll to make cursor visible + TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3.\nLine4.\nLine5." }; + tv.BeginInit (); + tv.EndInit (); + + // Scroll to the line 2 and insertion point stays at the line 0 + tv.Viewport = tv.Viewport with { Y = 2 }; + Assert.Equal (new Point (0, 2), tv.Viewport.Location); + Assert.Equal (new Point (0, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorDown key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorDown - should move cursor down and scroll to make it visible + Assert.True (tv.NewKeyDownEvent (Key.CursorDown)); + Assert.Equal (new Point (0, 1), tv.Viewport.Location); + Assert.Equal (new Point (0, 1), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorUp_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible () + { + // Test that pressing CursorUp at text hidden by scroll moves cursor and adjusts scroll to make cursor visible + TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3.\nLine4.\nLine5." }; + tv.BeginInit (); + tv.EndInit (); + + // Set insertion point at the line 4 and then scroll to the line 0 + tv.InsertionPoint = new Point (0, 4); + tv.Viewport = tv.Viewport with { Y = 0 }; + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (0, 4), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorUp key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorUp - should move cursor up and scroll to make it visible + Assert.True (tv.NewKeyDownEvent (Key.CursorUp)); + Assert.Equal (new Point (0, 1), tv.Viewport.Location); + Assert.Equal (new Point (0, 3), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } + + [Fact] + public void CursorUp_At_Text_Hidden_By_Scroll_OnlyOneLineBelow_Move_Cursor_ButDoesNotNeeded_Adjusts_Scroll_To_Make_Cursor_Visible () + { + // Test that pressing CursorUp at text hidden by scroll moves cursor but does not adjust scroll if the text is only one line below the scroll + TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3." }; + tv.BeginInit (); + tv.EndInit (); + + // Set insertion point at the line 2 and then scroll to the line 0 + tv.InsertionPoint = new Point (0, 2); + tv.Viewport = tv.Viewport with { Y = 0 }; + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (0, 2), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorUp key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorUp - should move cursor up but does not adjust scroll since the line 1 is still visible + Assert.True (tv.NewKeyDownEvent (Key.CursorUp)); + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (0, 1), tv.InsertionPoint); + Assert.False (tv.NeedsDraw); + } + + [Fact] + public void CursorUp_At_Text_Hidden_By_Scroll_OnTheFirstLineAndColumn_DoesNotMove_Cursor_And_Adjusts_Scroll_To_Make_Cursor_Visible () + { + // Test that pressing CursorUp at text hidden by scroll on the first line and column does not move cursor but adjusts scroll to make it visible + TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3." }; + tv.BeginInit (); + tv.EndInit (); + + // Set insertion point at the line 0 and column 0 and then scroll to the line 1 + tv.InsertionPoint = new Point (0, 0); + tv.Viewport = tv.Viewport with { Y = 1 }; + Assert.Equal (new Point (0, 1), tv.Viewport.Location); + Assert.Equal (new Point (0, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + + // Clear NeedsDraw to isolate the effect of CursorUp key press + tv.ClearNeedsDraw (); + Assert.False (tv.NeedsDraw); + + // Press CursorUp - should not move cursor since it's already at the top but should adjust scroll to make it visible + Assert.True (tv.NewKeyDownEvent (Key.CursorUp)); + Assert.Equal (new Point (0, 0), tv.Viewport.Location); + Assert.Equal (new Point (0, 0), tv.InsertionPoint); + Assert.True (tv.NeedsDraw); + } +} \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs index f7ca40b707..fd8fa857e2 100644 --- a/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs @@ -354,5 +354,29 @@ public void ContentSize_Width_Updates_After_Content_Change_Then_Caches () Assert.Equal (size3.Width, size4.Width); } + [Fact] + public void ContentSize_Width_Updates_Correctly_When_Text_Is_Inserted_At_Middle_Of_Longest_Line_Before_AdjustScroll () + { + TextView tv = new () { Width = 80, Height = 10, Text = "Short line\nThis is the longest line in the document\nMedium line" }; + tv.BeginInit (); + tv.EndInit (); + tv.LayoutSubViews (); + + Size initialSize = tv.GetContentSize (); + + tv.ContentsChanged += (_, _) => + { + Size newSize = tv.GetContentSize (); + Assert.True (newSize.Width > initialSize.Width, $"Content width should not decrease after mutation. Initial: {initialSize.Width}, New: {newSize.Width}"); + }; + + // Insert text in the middle of the longest line to make it even longer + tv.InsertionPoint = new Point (10, 1); // Position cursor in the middle of the longest line + tv.NewKeyDownEvent (Key.A); // Simulate typing 'A' to increase line length + + Size afterInsertSize = tv.GetContentSize (); + Assert.True (afterInsertSize.Width > initialSize.Width, $"Width should increase after inserting longer text. Was {initialSize.Width}, now {afterInsertSize.Width}"); + } + #endregion } diff --git a/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs b/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs index 90cd82abb1..a52bdc6e85 100644 --- a/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs @@ -203,6 +203,34 @@ public void Viewport_Change_Updates_ScrollBar_Position () Assert.Equal (3, tv.VerticalScrollBar.Value); } + /// + /// Tests that setting ReadOnly to true does not change viewport position but does set NeedsDraw. + /// > + [Fact] + public void ReadOnly_Set_True_Keeps_ViewportX_And_Sets_NeedDraw () + { + TextView tv = new () + { + Width = 20, + Height = 5, + ScrollBars = true, + WordWrap = false, + Text = "Short line" + }; + tv.BeginInit (); + tv.EndInit (); + tv.LayoutSubViews (); + tv.ClearNeedsDraw (); + + Rectangle initialViewport = tv.Viewport; + + tv.ReadOnly = true; + + Assert.Equal (initialViewport.X, tv.Viewport.X); + Assert.Equal (initialViewport.Y, tv.Viewport.Y); + Assert.True (tv.NeedsDraw); + } + /// /// Tests that horizontal scrollbar becomes visible when line length exceeds width (WordWrap=false). /// BUG: Same as vertical - visibility not updated when Text changes.