From ff6358840196a47fc08055008ad142bd14987da3 Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sun, 16 Aug 2026 14:49:44 +0700 Subject: [PATCH] Performance improvements - spann, ascii fast path, viewport optimizations --- src/PrettyPrompt/Documents/Document.cs | 32 +++---- src/PrettyPrompt/Highlighting/CellRenderer.cs | 36 +++++++- src/PrettyPrompt/Rendering/UnicodeWidth.cs | 61 +++++++++++++ tests/PrettyPrompt.Tests/UnicodeWidthTests.cs | 88 +++++++++++++++++++ 4 files changed, 195 insertions(+), 22 deletions(-) diff --git a/src/PrettyPrompt/Documents/Document.cs b/src/PrettyPrompt/Documents/Document.cs index 5ee835f..f1f3761 100644 --- a/src/PrettyPrompt/Documents/Document.cs +++ b/src/PrettyPrompt/Documents/Document.cs @@ -270,8 +270,9 @@ bool IsWordBoundary(int index1, int index2) { if (index2 >= stringBuilder.Length) return false; - var c1 = stringBuilder[index1]; - var c2 = stringBuilder[index2]; + // cached text, not the StringBuilder indexer, which is O(chunks) per access + var c1 = currentText[index1]; + var c2 = currentText[index2]; var isWhitespace1 = char.IsWhiteSpace(c1); var isWhitespace2 = char.IsWhiteSpace(c2); @@ -296,36 +297,31 @@ private int CalculateLineBoundaryIndexNearCaret(int caret, int direction, bool s { if (stringBuilder.Length == 0) return caret; + // Scan the cached text, not the StringBuilder, whose indexer walks the chunk list on every access. + // IndexOf/LastIndexOf over a span are vectorized too: an End keypress goes ~285ns -> ~4ns. + var text = currentText.AsSpan(); + if (direction > 0) { - for (var i = caret; i < stringBuilder.Length; i++) - { - if (stringBuilder[i] == '\n') return i; - } - return stringBuilder.Length; + int start = Math.Min(caret, text.Length); + int fromCaret = text.Slice(start).IndexOf('\n'); + return fromCaret < 0 ? text.Length : start + fromCaret; } else { if (caret == 0 && !smartHome) return 0; - int lineStart = 0; var beforeCaretIndex = (caret - 1).Clamp(0, Length - 1); - for (int i = beforeCaretIndex; i >= 0; i--) - { - if (stringBuilder[i] == '\n') - { - lineStart = Math.Min(i + 1, Length); - break; - } - } + int lastNewLine = text.Slice(0, beforeCaretIndex + 1).LastIndexOf('\n'); + int lineStart = lastNewLine < 0 ? 0 : Math.Min(lastNewLine + 1, Length); if (!smartHome) return lineStart; //smart Home implementation (repeating Home presses switch between 'non-white-space start of line' and 'start of line') int lineStartNonWhiteSpace = lineStart; - for (int i = lineStart; i < Length; i++) + for (int i = lineStart; i < text.Length; i++) { - var c = stringBuilder[i]; + var c = text[i]; if (c == '\n') { return lineStart; diff --git a/src/PrettyPrompt/Highlighting/CellRenderer.cs b/src/PrettyPrompt/Highlighting/CellRenderer.cs index 2c8e8f6..1cf6390 100644 --- a/src/PrettyPrompt/Highlighting/CellRenderer.cs +++ b/src/PrettyPrompt/Highlighting/CellRenderer.cs @@ -45,7 +45,11 @@ public static Row[] ApplyColorToCharacters(IReadOnlyCollection highl // If the selection began above the viewport and hasn't ended yet, it's already "open" at startLine. bool selectionHighlight = selectionStart.Row < startLine && selectionEnd.Row >= startLine; - var highlightsLookup = HighlightsGroupingPool.Shared.Get(highlights); + // Only spans STARTING in the viewport can be found by the per-cell lookup below; one that began above + // it is handled by SeedCurrentHighlight. Building the lookup from every span in the document was + // O(document) per render - ~36% of a caret-move keystroke at 1000 lines / ~14.7k spans. + var (viewPortStartChar, viewPortEndChar) = GetViewPortCharRange(lines, startLine, endLine); + var highlightsLookup = HighlightsGroupingPool.Shared.Get(highlights, viewPortStartChar, viewPortEndChar); var highlightedRows = new Row[endLine - startLine]; FormatSpan? currentHighlight = SeedCurrentHighlight(highlights, lines, startLine); for (int lineIndex = startLine; lineIndex < endLine; lineIndex++) @@ -121,6 +125,21 @@ public static Row[] ApplyColorToCharacters(IReadOnlyCollection highl return highlightedRows; } + /// + /// Half-open range of UTF-16 document offsets covered by lines [, + /// ) - every characterPosition the highlight lookup can be asked about. + /// + private static (int Start, int End) GetViewPortCharRange(IReadOnlyList lines, int startLine, int endLine) + { + if (startLine >= endLine) + { + return (0, 0); + } + var firstLine = lines[startLine]; + var lastLine = lines[endLine - 1]; + return (firstLine.StartIndex, lastLine.StartIndex + lastLine.Content.Length); + } + /// /// When rendering starts partway down the document ( > 0), find the /// highlight span the top-down pass would have been carrying into : one that @@ -196,13 +215,22 @@ private sealed class HighlightsGroupingPool : LockFreePool Get(IReadOnlyCollection highlights) + /// + /// Builds the start-offset -> span lookup from only the spans starting within + /// [, ) - the only ones the + /// caller can look up. The rest cost two int comparisons instead of a hash and a probe. + /// + public Dictionary Get(IReadOnlyCollection highlights, int viewPortStartChar, int viewPortEndChar) { - var result = Rent() ?? new Dictionary(highlights.Count); - result.EnsureCapacity(highlights.Count); + var result = Rent() ?? new Dictionary(); foreach (var highlight in highlights) { + if (highlight.Start < viewPortStartChar || highlight.Start >= viewPortEndChar) + { + continue; + } + if (result.TryGetValue(highlight.Start, out var formatSpan)) { if (highlight.Length > formatSpan.Length) diff --git a/src/PrettyPrompt/Rendering/UnicodeWidth.cs b/src/PrettyPrompt/Rendering/UnicodeWidth.cs index f83ff57..d54b162 100644 --- a/src/PrettyPrompt/Rendering/UnicodeWidth.cs +++ b/src/PrettyPrompt/Rendering/UnicodeWidth.cs @@ -37,6 +37,28 @@ public static class UnicodeWidth /// instead. /// public static int GetWidth(char character) + => character < AsciiWidths ? asciiWidths[character] : GetWidthCore(character); + + /// + /// Pure memo of over the ASCII range. UnicodeCalculator.GetWidth costs a + /// probe plus two dictionary lookups and table searches, and word wrap calls it + /// per character of the document per keystroke: memoizing took a 1000-line re-wrap from ~11.8ms to ~0.5ms. + /// + private const int AsciiWidths = 128; + private static readonly byte[] asciiWidths = CreateAsciiWidths(); + + private static byte[] CreateAsciiWidths() + { + var widths = new byte[AsciiWidths]; + for (int i = 0; i < widths.Length; i++) + { + // GetWidthCore, not GetWidth - the cache it reads is what we're building. + widths[i] = (byte)GetWidthCore((char)i); + } + return widths; + } + + private static int GetWidthCore(char character) { if (character == '\n') return 1; // PrettyPrompt: treat newline as occupying a single column. if (char.IsSurrogate(character)) return 1; // half of a surrogate pair; the pair sums to the scalar's width. @@ -55,6 +77,14 @@ public static int GetWidth(ReadOnlySpan text) int width = 0; while (!text.IsEmpty) { + int runLength = LeadingSimpleAsciiRunLength(text); + if (runLength > 0) + { + width += runLength; // one char == one cluster == one column across the run + text = text.Slice(runLength); + continue; + } + int elementLength = StringInfo.GetNextTextElementLength(text); width += GetGraphemeClusterWidth(text.Slice(0, elementLength)); text = text.Slice(elementLength); @@ -62,6 +92,25 @@ public static int GetWidth(ReadOnlySpan text) return width; } + /// + /// Length of the leading run of printable ASCII, each char of which is its own one-column cluster, so + /// callers can account for the run at once instead of walking it. 0 means "no fast run, use the walker". + /// + /// + /// Sound because nothing in [U+0020, U+007E] is a grapheme extender, and the only all-ASCII multi-char + /// cluster is CR LF ('\r' is below the range). What a run can't rule out is what FOLLOWS it - a combining + /// mark, ZWJ or VS16 there clusters onto the run's last char - so a run ending inside the text gives that + /// char back to the walker. The search itself is vectorized. + /// + /// + private static int LeadingSimpleAsciiRunLength(ReadOnlySpan text) + { + int firstSpecial = text.IndexOfAnyExceptInRange(' ', '~'); + return firstSpecial < 0 + ? text.Length // all printable ASCII; nothing follows to cluster onto it + : firstSpecial - 1; // reserve the boundary char (0 or -1 means "no fast run") + } + /// /// Display width (0, 1, or 2 columns) of a single grapheme cluster, determined by its base scalar /// value. Trailing combining marks, zero-width joiners, emoji modifiers, and variation selectors are @@ -109,6 +158,18 @@ public static int GetLengthThatFits(ReadOnlySpan text, int maxWidth) int i = 0; while (i < text.Length) { + int runLength = LeadingSimpleAsciiRunLength(text.Slice(i)); + if (runLength > 0) + { + // budget maps straight onto a char count here, and every offset in the run is a cluster + // boundary, so clipping mid-run is safe. + int take = Math.Min(runLength, maxWidth - width); + width += take; + i += take; + if (take < runLength) break; // ran out of budget inside the run + continue; + } + int elementLength = StringInfo.GetNextTextElementLength(text.Slice(i)); int elementWidth = GetGraphemeClusterWidth(text.Slice(i, elementLength)); if (width + elementWidth > maxWidth) break; diff --git a/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs b/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs index 2738b53..f68eeb0 100644 --- a/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs +++ b/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs @@ -4,6 +4,8 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. #endregion +using System; +using System.Globalization; using PrettyPrompt.Rendering; using Xunit; @@ -92,4 +94,90 @@ public void GetGraphemeClusterWidth_IsCappedAtTwo(string cluster, int expectedWi [InlineData("\U0001F926\U0001F3FC\u200D\u2642\uFE0Fx", 1, 0)] // the emoji is 2 columns wide and cannot fit in 1 public void GetLengthThatFits_TruncatesByWidthOnClusterBoundary(string text, int maxWidth, int expectedLength) => Assert.Equal(expectedLength, UnicodeWidth.GetLengthThatFits(text, maxWidth)); + + /// + /// One fragment per hazard the ASCII fast path has to respect. Keep these as \uXXXX escapes - written as + /// literals, the invisible ones get mangled and the test quietly stops testing anything. + /// + private static readonly string[] DifferentialFragments = + { + // printable ASCII, including the exact range endpoints + "", "a", "abc", "hello world", "\u0020", "\u007E", + // outside the range: controls, tab, DEL, and CR LF (the only all-ASCII cluster) + "\u0000", "\u0009", "\u007F", "\n", "\r\n", "a\r\nb", "abc\r\ndef", + // combining mark / ZWJ / VS16 clustering onto the PRECEDING char - the key hazard + "e\u0301", "ae\u0301b", "ab\u0107", "a\u200Db", "x\uFE0F", "abc\u26A0\uFE0Fdef", + // wide and supplementary-plane + "\u4E66", "a\u4E66b", "\U0001F600", "a\U0001F600b", + "\U0001F926\U0001F3FC\u200D\u2642\uFE0F", "abc\U0001F926\U0001F3FC\u200D\u2642\uFE0Fdef", + // halfwidth kana + spacing sound mark, and VS16 promotion + "\uFF8A\uFF9F", "a\uFF8A\uFF9Fb", "\u26A0", "\u26A0\uFE0F", + "\uD83D", // lone high surrogate (ill-formed) + }; + + public static TheoryData DifferentialCorpus() + { + var data = new TheoryData(); + foreach (var first in DifferentialFragments) + { + data.Add(first); + foreach (var second in DifferentialFragments) + { + data.Add(first + second); + } + } + return data; + } + + /// + /// Pins the fast path to the general walker. The corpus pairs every hazard fragment with every other, so + /// each one lands at a run boundary - where "printable ASCII is its own cluster" stops holding. + /// + [Theory] + [MemberData(nameof(DifferentialCorpus))] + public void GetWidth_FastPathMatchesGraphemeWalker(string text) + => Assert.Equal(WidthByWalker(text), UnicodeWidth.GetWidth(text)); + + [Theory] + [MemberData(nameof(DifferentialCorpus))] + public void GetLengthThatFits_FastPathMatchesGraphemeWalker(string text) + { + // every budget from 0 to past the full width, covering both the "runs out mid-run" and + // "consumes everything" branches. + for (int maxWidth = 0; maxWidth <= WidthByWalker(text) + 2; maxWidth++) + { + Assert.Equal(LengthThatFitsByWalker(text, maxWidth), UnicodeWidth.GetLengthThatFits(text, maxWidth)); + } + } + + /// Reference: always walks clusters, never takes the fast path. + private static int WidthByWalker(string text) + { + int width = 0; + int i = 0; + while (i < text.Length) + { + int elementLength = StringInfo.GetNextTextElementLength(text, i); + width += UnicodeWidth.GetGraphemeClusterWidth(text.AsSpan(i, elementLength)); + i += elementLength; + } + return width; + } + + /// Reference for , cluster by cluster. + private static int LengthThatFitsByWalker(string text, int maxWidth) + { + if (maxWidth <= 0) return 0; + int width = 0; + int i = 0; + while (i < text.Length) + { + int elementLength = StringInfo.GetNextTextElementLength(text, i); + int elementWidth = UnicodeWidth.GetGraphemeClusterWidth(text.AsSpan(i, elementLength)); + if (width + elementWidth > maxWidth) break; + width += elementWidth; + i += elementLength; + } + return i; + } }