From 370cacc5373c61b244069219710c543bf8d01e88 Mon Sep 17 00:00:00 2001 From: Ollie Blanks Date: Fri, 7 Aug 2026 10:27:12 +0100 Subject: [PATCH] Cap rendering and editing of extremely long lines A single very long line (e.g. a minified blob or a Unity YAML asset with an inline binary payload serialized as hex) froze the UI thread as it scrolled into view: TextMate tokenized and colorized the whole line, and TextFormatter shaped every character. - VisualLine shapes only the first LENGTH_LIMIT characters and collapses the remainder into a short "..." marker (a FormattedTextElement, the same mechanism used for collapsed fold regions). Document offsets are unchanged, so caret, selection and highlighting keep working. - TextMateColoringTransformer skips tokenizing/colorizing lines past LENGTH_LIMIT. - New LongLineEditProtection disallows edits inside the collapsed tail, and TextArea routes CanInsert/GetDeletableSegments through it so callers cannot insert into or delete the hidden region (deleting the delimiter would merge the next line into the hidden text). Co-authored-by: Daniel Penalba --- .../TextMateColoringTransformer.cs | 4 + .../Editing/EditingCommandHandler.cs | 4 +- src/AvaloniaEdit/Editing/EmptySelection.cs | 2 +- .../Editing/LongLineEditProtection.cs | 74 +++++++++++++++++++ .../Editing/RectangleSelection.cs | 2 +- .../Editing/SelectionMouseHandler.cs | 2 +- src/AvaloniaEdit/Editing/TextArea.cs | 12 ++- src/AvaloniaEdit/Rendering/VisualLine.cs | 6 +- 8 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 src/AvaloniaEdit/Editing/LongLineEditProtection.cs diff --git a/src/AvaloniaEdit.TextMate/TextMateColoringTransformer.cs b/src/AvaloniaEdit.TextMate/TextMateColoringTransformer.cs index 3370efd8..5b3ead1a 100644 --- a/src/AvaloniaEdit.TextMate/TextMateColoringTransformer.cs +++ b/src/AvaloniaEdit.TextMate/TextMateColoringTransformer.cs @@ -285,6 +285,10 @@ protected override void TransformLine(DocumentLine line, ITextRunConstructionCon if (model == null || document == null || theme == null || brushes == null) return; + // for long lines, do not tokenize and colorize for performance reasons + if (line.Length > VisualLine.LENGTH_LIMIT) + return; + int lineNumber = line.LineNumber; var tokens = model.GetLineTokens(lineNumber - 1); diff --git a/src/AvaloniaEdit/Editing/EditingCommandHandler.cs b/src/AvaloniaEdit/Editing/EditingCommandHandler.cs index edd70a07..348f2f70 100644 --- a/src/AvaloniaEdit/Editing/EditingCommandHandler.cs +++ b/src/AvaloniaEdit/Editing/EditingCommandHandler.cs @@ -248,7 +248,7 @@ public static void OnTab(object target, RoutedEventArgs args) while (true) { var offset = current.Offset; - if (textArea.ReadOnlySectionProvider.CanInsert(offset)) + if (textArea.CanInsert(offset)) textArea.Document.Replace(offset, 0, textArea.Options.IndentationString, OffsetChangeMappingType.KeepAnchorBeforeInsertion); if (current == end) @@ -499,7 +499,7 @@ private static void CanPaste(object target, CanExecuteRoutedEventArgs args) var textArea = GetTextArea(target); if (textArea is { Document: not null }) { - args.CanExecute = textArea.ReadOnlySectionProvider.CanInsert(textArea.Caret.Offset); + args.CanExecute = textArea.CanInsert(textArea.Caret.Offset); args.Handled = true; } } diff --git a/src/AvaloniaEdit/Editing/EmptySelection.cs b/src/AvaloniaEdit/Editing/EmptySelection.cs index ad569833..aa3ba210 100644 --- a/src/AvaloniaEdit/Editing/EmptySelection.cs +++ b/src/AvaloniaEdit/Editing/EmptySelection.cs @@ -68,7 +68,7 @@ public override void ReplaceSelectionWithText(string newText) newText = AddSpacesIfRequired(newText, TextArea.Caret.Position, TextArea.Caret.Position); if (newText.Length > 0) { - if (TextArea.ReadOnlySectionProvider.CanInsert(TextArea.Caret.Offset)) + if (TextArea.CanInsert(TextArea.Caret.Offset)) { TextArea.Document.Insert(TextArea.Caret.Offset, newText); } diff --git a/src/AvaloniaEdit/Editing/LongLineEditProtection.cs b/src/AvaloniaEdit/Editing/LongLineEditProtection.cs new file mode 100644 index 00000000..9e27875c --- /dev/null +++ b/src/AvaloniaEdit/Editing/LongLineEditProtection.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; + +namespace AvaloniaEdit.Editing +{ + /// + /// Disallow edits in the collapsed tail of long lines (see ): + /// + internal static class LongLineEditProtection + { + internal static bool CanInsert(TextDocument document, int offset) + { + if (document == null) + return true; + + DocumentLine line = document.GetLineByOffset(offset); + return offset - line.Offset < VisualLine.LENGTH_LIMIT; + } + + internal static IEnumerable GetDeletableSegments( + TextDocument document, ISegment segment) + { + if (segment == null) + throw new ArgumentNullException(nameof(segment)); + + if (document == null) + { + yield return segment; + yield break; + } + + if (segment.Length == 0) + { + if (CanInsert(document, segment.Offset)) + yield return segment; + yield break; + } + + int deletableStart = segment.Offset; + int segmentEnd = segment.EndOffset; + + DocumentLine line = document.GetLineByOffset(segment.Offset); + while (line != null && line.Offset < segmentEnd) + { + if (line.Length > VisualLine.LENGTH_LIMIT) + { + // protect the hidden text plus the line delimiter; deleting the + // delimiter would merge the next line into the hidden region + int protectedStart = line.Offset + VisualLine.LENGTH_LIMIT; + int protectedEnd = line.Offset + line.TotalLength; + + if (protectedStart < segmentEnd && protectedEnd > deletableStart) + { + if (protectedStart > deletableStart) + { + yield return new SimpleSegment( + deletableStart, protectedStart - deletableStart); + } + + deletableStart = Math.Min(protectedEnd, segmentEnd); + } + } + + line = line.NextLine; + } + + if (deletableStart < segmentEnd) + yield return new SimpleSegment(deletableStart, segmentEnd - deletableStart); + } + } +} diff --git a/src/AvaloniaEdit/Editing/RectangleSelection.cs b/src/AvaloniaEdit/Editing/RectangleSelection.cs index 06593c21..0944731a 100644 --- a/src/AvaloniaEdit/Editing/RectangleSelection.cs +++ b/src/AvaloniaEdit/Editing/RectangleSelection.cs @@ -328,7 +328,7 @@ private void ReplaceSingleLineText(TextArea textArea, SelectionSegment lineSegme { if (lineSegment.Length == 0) { - if (newText.Length > 0 && textArea.ReadOnlySectionProvider.CanInsert(lineSegment.StartOffset)) + if (newText.Length > 0 && textArea.CanInsert(lineSegment.StartOffset)) { newText = AddSpacesIfRequired(newText, new TextViewPosition(_document.GetLocation(lineSegment.StartOffset), lineSegment.StartVisualColumn), new TextViewPosition(_document.GetLocation(lineSegment.EndOffset), lineSegment.EndVisualColumn)); textArea.Document.Insert(lineSegment.StartOffset, newText); diff --git a/src/AvaloniaEdit/Editing/SelectionMouseHandler.cs b/src/AvaloniaEdit/Editing/SelectionMouseHandler.cs index ef022d32..9e526607 100644 --- a/src/AvaloniaEdit/Editing/SelectionMouseHandler.cs +++ b/src/AvaloniaEdit/Editing/SelectionMouseHandler.cs @@ -188,7 +188,7 @@ DragDropEffects GetEffect(DragEventArgs e) { TextArea.Caret.Position = new TextViewPosition(TextArea.Document.GetLocation(offset), visualColumn) { IsAtEndOfLine = isAtEndOfLine }; TextArea.Caret.DesiredXPos = double.NaN; - if (TextArea.ReadOnlySectionProvider.CanInsert(offset)) + if (TextArea.CanInsert(offset)) { if ((e.DragEffects & DragDropEffects.Move) == DragDropEffects.Move && (e.KeyModifiers & KeyModifiers.Control) != KeyModifiers.Control) diff --git a/src/AvaloniaEdit/Editing/TextArea.cs b/src/AvaloniaEdit/Editing/TextArea.cs index 286c9485..71dcde41 100644 --- a/src/AvaloniaEdit/Editing/TextArea.cs +++ b/src/AvaloniaEdit/Editing/TextArea.cs @@ -925,7 +925,7 @@ internal void RemoveSelectedText() { foreach (var s in _selection.Segments) { - Debug.Assert(!ReadOnlySectionProvider.GetDeletableSegments(s).Any()); + Debug.Assert(GetDeletableSegments(s).Length == 0); } } #endif @@ -940,12 +940,20 @@ internal void ReplaceSelectionWithText(string newText) _selection.ReplaceSelectionWithText(newText); } + internal bool CanInsert(int offset) + { + return ReadOnlySectionProvider.CanInsert(offset) && + LongLineEditProtection.CanInsert(Document, offset); + } + internal ISegment[] GetDeletableSegments(ISegment segment) { var deletableSegments = ReadOnlySectionProvider.GetDeletableSegments(segment); if (deletableSegments == null) throw new InvalidOperationException("ReadOnlySectionProvider.GetDeletableSegments returned null"); - var array = deletableSegments.ToArray(); + var array = deletableSegments + .SelectMany(s => LongLineEditProtection.GetDeletableSegments(Document, s)) + .ToArray(); var lastIndex = segment.Offset; foreach (var t in array) { diff --git a/src/AvaloniaEdit/Rendering/VisualLine.cs b/src/AvaloniaEdit/Rendering/VisualLine.cs index 4257a844..fbe290bb 100644 --- a/src/AvaloniaEdit/Rendering/VisualLine.cs +++ b/src/AvaloniaEdit/Rendering/VisualLine.cs @@ -41,6 +41,7 @@ namespace AvaloniaEdit.Rendering /// public sealed class VisualLine { + public const string LENGTH_LIMIT_MARKER = "..."; public const int LENGTH_LIMIT = 3000; private enum LifetimePhase : byte @@ -172,7 +173,10 @@ void PerformVisualElementConstruction(IReadOnlyList // for long lines, do not run the element generators for performance reasons if (lineLength > LENGTH_LIMIT) { - _elements.Add(new VisualLineText(this, lineLength)); + // shape only the first LENGTH_LIMIT characters and collapse the remainder + // into a marker. Otherwise stalls the UI thread. + _elements.Add(new VisualLineText(this, LENGTH_LIMIT)); + _elements.Add(new FormattedTextElement(LENGTH_LIMIT_MARKER, lineLength - LENGTH_LIMIT)); return; }