Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
041cfcf
Fixes #4986. Navigating with Viewport.Y greater than zero will cause …
BDisp Apr 17, 2026
ce2ce48
Fixes #4990. Navigating with Viewport.X greater than zero will cause …
BDisp Apr 17, 2026
6ca8e82
Merge branch 'develop' into textview-remaining-issues-fix
tig Apr 17, 2026
e9f3cd8
Merge branch 'develop' into textview-remaining-issues-fix
tig Apr 17, 2026
0f27375
Fixes #4994 - Navigating left and right while holding down the Ctrl k…
BDisp Apr 17, 2026
8074d0f
Merge branch 'develop' into textview-remaining-issues-fix
BDisp Apr 17, 2026
769edb0
Merge branch 'develop' into textview-remaining-issues-fix
BDisp Apr 17, 2026
ce4645e
Merge branch 'develop' into textview-remaining-issues-fix
tig Apr 18, 2026
14afefc
Fixes #4998. TextView.UpdateContentSize isn't working correctly on in…
BDisp Apr 18, 2026
3ca7567
Fixes #4999. TextView with hidden cursor due scrolling pressing any C…
BDisp Apr 18, 2026
2496629
Update Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs
tig Apr 19, 2026
f6f0164
Merge branch 'develop' into textview-remaining-issues-fix
BDisp Apr 19, 2026
40d4147
Merge branch 'develop' into textview-remaining-issues-fix
BDisp Apr 19, 2026
c336758
Fixes #4891. DoDrawComplete should ignore scrolled Viewport.Location …
BDisp Apr 19, 2026
79c079c
Merge branch 'develop' into textview-remaining-issues-fix
tig Apr 19, 2026
2e3a3d9
Merge branch 'develop' into textview-remaining-issues-fix
BDisp Apr 20, 2026
5dc6b6b
Clarify comment related to deleted
BDisp Apr 19, 2026
97d63ff
Fix MoveUp() and add more unit tests
BDisp Apr 19, 2026
2d304d1
Test that proves despite does not change viewport position but does s…
BDisp Apr 19, 2026
daba04a
Fix MoveLeft method and add a test
BDisp Apr 19, 2026
3b57826
Fix MoveWordLeft and add unit test
BDisp Apr 19, 2026
ae4e7c7
Fix MoveWordRight and add unit test
BDisp Apr 19, 2026
0dbccbc
Simplify MoveRight code
BDisp Apr 19, 2026
b552436
Fix DeleteCharLeft invoke ContentChanged twice
BDisp Apr 20, 2026
767020d
Fix ShouldInvalidateMaxWidthCache to use full size
BDisp Apr 20, 2026
ac3dbc4
Remove unnecessary LINQ in _cachedMaxWidthPerLine
BDisp Apr 20, 2026
ab0ece7
Move ShouldInvalidateMaxWidthCache tests
BDisp Apr 20, 2026
cecd5b6
Fix ArgumentOutOfRangeException on DeleteTextLeft
BDisp Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Terminal.Gui/ViewBase/View.Drawing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions Terminal.Gui/Views/TextInput/TextModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, int> _cachedMaxWidthPerLine = [];

/// <summary>
/// Gets the number of times <see cref="GetMaxVisibleLine"/> performed a full line scan.
Expand All @@ -34,6 +35,38 @@ internal class TextModel
/// <summary>Invalidates the cached max line width so the next call to <see cref="GetMaxVisibleLine"/> will rescan.</summary>
internal void InvalidateMaxWidthCache () => _cachedMaxWidth = -1;

/// <summary>
/// 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.
/// </summary>
/// <param name="line">The line number being modified.</param>
/// <param name="isInsert">Indicates whether the operation is an insert. Defaults to true.</param>
/// <param name="columnWidth">The width of the column being modified. Defaults to -1 on delete.</param>
/// <returns><see langword="true"/> if the cache should be invalidated; otherwise, <see langword="false"/>.</returns>
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;
}
Comment on lines +45 to +68
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ShouldInvalidateMaxWidthCache() doesn't invalidate when an edit occurs on a non-max line that becomes wider than the cached max (it only checks lines already in _cachedMaxWidthPerLine). If callers rely on this to keep GetMaxVisibleLine() correct without forcing a full invalidation, content width can become stale. Consider also invalidating when columnWidth > _cachedMaxWidth (and ensure callers pass the full post-edit line width).

Copilot uses AI. Check for mistakes.

/// <summary>Adds a line to the model at the specified position.</summary>
/// <param name="pos">Line number where the line will be inserted.</param>
/// <param name="cells">The line of text and color, as a List of Cell.</param>
Expand Down Expand Up @@ -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<Cell> line = GetLine (i);
Expand All @@ -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<int, int> { { i, maxLength } };
}
else if (maxLength == colsWidth)
{
_cachedMaxWidthPerLine [i] = maxLength;
}
}

Expand Down
60 changes: 42 additions & 18 deletions Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ public bool DeleteCharLeft ()

bool retValue = DeleteTextLeft ();

DoNeededAction ();
OnContentsChanged ();
DoNeededAction ();

return retValue;
}
Expand Down Expand Up @@ -453,37 +453,45 @@ private bool DeleteTextLeft ()
}

SetWrapModel ();
int prowIdx = CurrentRow - 1;
List<Cell> prevRow = _model.GetLine (prowIdx);

_historyText.Add ([[.. prevRow]], InsertionPoint);
if (CurrentRow - 1 > -1)
{
int prowIdx = CurrentRow - 1;
List<Cell> prevRow = _model.GetLine (prowIdx);

_historyText.Add ([[.. prevRow]], InsertionPoint);

List<List<Cell>> removedLines = [[.. prevRow], [.. GetCurrentLine ()]];
List<List<Cell>> 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;
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -660,6 +681,7 @@ private bool CutToStartOfLine ()
UpdateWrapModel ();

DeleteTextLeft ();
OnContentsChanged ();

return true;
}
Expand Down Expand Up @@ -750,6 +772,7 @@ private bool KillWordLeft ()
if (CurrentColumn == 0)
{
DeleteTextLeft ();
OnContentsChanged ();

_historyText.ReplaceLast ([[.. GetCurrentLine ()]], InsertionPoint, TextEditingLineStatus.Replaced);

Expand Down Expand Up @@ -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
{
Expand Down
41 changes: 33 additions & 8 deletions Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ private bool MoveDown ()

CurrentRow++;

if (CurrentRow >= Viewport.Y + Viewport.Height)
if (CurrentRow >= Viewport.Y + Viewport.Height || CurrentRow < Viewport.Y)
{
SetNeedsDraw ();
}
Expand Down Expand Up @@ -145,8 +145,9 @@ private bool MoveEndOfLine ()
List<Cell> 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 ();
Expand All @@ -160,7 +161,10 @@ private bool MoveLeft ()
{
CurrentColumn--;

if (Viewport.X > 0 && CurrentColumn <= Viewport.X)
List<Cell> 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 ();
}
Expand Down Expand Up @@ -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 ();
}
Comment on lines 301 to 308
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MoveRight() adds a visibility check (Viewport.X > 0 && CurrentColumn < Viewport.X), but Viewport.X is a display-column offset while CurrentColumn is a cell index. This will break with tabs/wide graphemes and can mark the cursor as "left of viewport" when it isn't. Use the cursor's display column (via TextModel.CursorColumn(...)) for comparisons against Viewport.X.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -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 ();
}
Expand Down Expand Up @@ -393,6 +402,14 @@ private bool MoveWordLeft ()
CurrentRow = newPos.Value.row;
}

List<Cell> 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;
Expand All @@ -408,6 +425,14 @@ private bool MoveWordRight ()
CurrentRow = newPos.Value.row;
}

List<Cell> 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;
Expand Down
12 changes: 6 additions & 6 deletions Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
BDisp marked this conversation as resolved.
}
else if ((_wordWrap && Viewport.X > 0) || (dSize.size < Viewport.Width && tSize.size < Viewport.Width))
{
if (Viewport.X > 0)
Expand All @@ -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)
{
Expand Down
24 changes: 10 additions & 14 deletions Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cell> 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 ();
}
Comment on lines +564 to 574
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional invalidation/update here is effectively bypassed because InsertText() calls OnContentsChanged() at the end, and the base TextView.OnContentsChanged() unconditionally calls _model.InvalidateMaxWidthCache() (TextView.cs:191). As written, the cache will still be invalidated for every insert, negating the goal of ShouldInvalidateMaxWidthCache. Consider moving cache invalidation behind the new ShouldInvalidateMaxWidthCache logic (e.g., into OnContentsChanged) and avoiding unconditional invalidation.

Copilot uses AI. Check for mistakes.
Comment thread
BDisp marked this conversation as resolved.
}

Expand Down
Loading
Loading