diff --git a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs index 4657eb1..a07c6e6 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.TextViewer/TextViewerPanel.cs @@ -44,6 +44,12 @@ public partial class TextViewerPanel : TextEditor, IDisposable { private bool _disposed; + /// Maximum number of characters allowed on a single line before it is truncated. + private const int MAX_LINE_LENGTH = 10000; + + /// Marker appended at the end of a truncated line to indicate omitted content. + private const string ELLIPSIS = "⁞⁞[TRUNCATED]⁞⁞"; + static TextViewerPanel() { // Implementation of the Search Panel Styled with Fluent Theme @@ -176,17 +182,20 @@ public partial class TextViewerPanel : TextEditor, IDisposable } } + /// + /// Fallback visual-layer guard: if a line somehow reaches the renderer still over the limit, + /// replace the tail with so AvalonEdit never tries to measure the full run. + /// private class TruncateLongLines : VisualLineElementGenerator { - private const int MAX_LENGTH = 10000; - private const string ELLIPSIS = "⁞⁞[TRUNCATED]⁞⁞"; - public override int GetFirstInterestedOffset(int startOffset) { var line = CurrentContext.VisualLine.LastDocumentLine; - if (line.Length > MAX_LENGTH) + if (line.Length > MAX_LINE_LENGTH) { - int ellipsisOffset = line.Offset + MAX_LENGTH - ELLIPSIS.Length; + // Position the insertion point so that the visible prefix + ELLIPSIS + // exactly fills MAX_LINE_LENGTH characters. + int ellipsisOffset = line.Offset + MAX_LINE_LENGTH - ELLIPSIS.Length; if (startOffset <= ellipsisOffset) return ellipsisOffset; } @@ -195,10 +204,117 @@ public partial class TextViewerPanel : TextEditor, IDisposable public override VisualLineElement ConstructElement(int offset) { + // Consume every character from `offset` to end-of-line and replace + // them with a single non-interactive text run showing ELLIPSIS. return new FormattedTextElement(ELLIPSIS, CurrentContext.VisualLine.LastDocumentLine.EndOffset - offset); } } + /// + /// Pre-processes the raw text on the background thread before handing it to AvalonEdit. + /// Any line longer than is hard-truncated: the excess characters are + /// replaced with directly in the string, so the syntax highlighter and + /// WPF word-wrap logic never see the original long content. + /// + /// The decoded file text. Modified in-place when truncation occurs. + /// + /// when at least one line was shortened; used to decide whether to + /// install . + /// + private static bool TruncateLongLinesInText(ref string text) + { + // Fast-path: if the whole text is shorter than the limit, no line can exceed it. + if (text.Length <= MAX_LINE_LENGTH) + return false; + + bool found = false; + var sb = new System.Text.StringBuilder(text.Length); + int lineStart = 0; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '\r' || c == '\n') + { + int lineLen = i - lineStart; + if (lineLen > MAX_LINE_LENGTH) + { + // Keep only the first (MAX_LINE_LENGTH - ELLIPSIS.Length) chars, + // then append the marker so the total stays at MAX_LINE_LENGTH. + sb.Append(text, lineStart, MAX_LINE_LENGTH - ELLIPSIS.Length); + sb.Append(ELLIPSIS); + found = true; + } + else + { + sb.Append(text, lineStart, lineLen); + } + + sb.Append(c); + // Consume the LF that follows a CR so we don't double-count it. + if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') + { + i++; + sb.Append('\n'); + } + lineStart = i + 1; + } + } + + // Handle the last line when there is no trailing newline. + if (lineStart < text.Length) + { + int lineLen = text.Length - lineStart; + if (lineLen > MAX_LINE_LENGTH) + { + sb.Append(text, lineStart, MAX_LINE_LENGTH - ELLIPSIS.Length); + sb.Append(ELLIPSIS); + found = true; + } + else + { + sb.Append(text, lineStart, lineLen); + } + } + + if (found) + text = sb.ToString(); + + return found; + } + + /// + /// Runs after the syntax highlighter to strip all coloring from truncated lines. + /// A line is considered truncated when its last characters match . + /// Resetting the foreground to the editor's base color effectively removes + /// any syntax-highlight spans, keeping the display clean and readable. + /// + private class TruncatedLineDecolorizer : DocumentColorizingTransformer + { + private readonly TextViewerPanel _owner; + + public TruncatedLineDecolorizer(TextViewerPanel owner) => _owner = owner; + + protected override void ColorizeLine(DocumentLine line) + { + // Skip lines that are too short to contain the marker. + if (line.Length < ELLIPSIS.Length) + return; + + // Check whether the line ends with the truncation marker. + int markerStart = line.EndOffset - ELLIPSIS.Length; + if (CurrentContext.Document.GetText(markerStart, ELLIPSIS.Length) == ELLIPSIS) + { + // Override the entire line's foreground with the editor default, + // which removes any previously applied syntax-highlight colors. + ChangeLinePart(line.Offset, line.EndOffset, element => + { + element.TextRunProperties.SetForegroundBrush(_owner.Foreground); + }); + } + } + } + public void LoadFileAsync(string path, ContextObject context) { _ = Task.Run(() => @@ -240,6 +356,10 @@ public partial class TextViewerPanel : TextEditor, IDisposable var encoding = EncodingDetector.DetectFromBytes(bufferCopy); var text = encoding.GetString(bufferCopy); + + // Truncate overly long lines to prevent crashes and lag + bool hasLongLines = TruncateLongLinesInText(ref text); + var doc = new TextDocument(text); doc.SetOwnerThread(Dispatcher.Thread); @@ -265,6 +385,13 @@ public partial class TextViewerPanel : TextEditor, IDisposable } } + // Only install the decolorizer when the file actually contained long lines, + // to avoid the per-line overhead on normal files. + if (hasLongLines) + { + TextArea.TextView.LineTransformers.Add(new TruncatedLineDecolorizer(this)); + } + if (highlighting.IsDark) { Background = Brushes.Transparent;