Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 14 additions & 18 deletions src/PrettyPrompt/Documents/Document.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
36 changes: 32 additions & 4 deletions src/PrettyPrompt/Highlighting/CellRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ public static Row[] ApplyColorToCharacters(IReadOnlyCollection<FormatSpan> 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++)
Expand Down Expand Up @@ -121,6 +125,21 @@ public static Row[] ApplyColorToCharacters(IReadOnlyCollection<FormatSpan> highl
return highlightedRows;
}

/// <summary>
/// Half-open range of UTF-16 document offsets covered by lines [<paramref name="startLine"/>,
/// <paramref name="endLine"/>) - every <c>characterPosition</c> the highlight lookup can be asked about.
/// </summary>
private static (int Start, int End) GetViewPortCharRange(IReadOnlyList<WrappedLine> 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);
}

/// <summary>
/// When rendering starts partway down the document (<paramref name="startLine"/> &gt; 0), find the
/// highlight span the top-down pass would have been carrying into <paramref name="startLine"/>: one that
Expand Down Expand Up @@ -196,13 +215,22 @@ private sealed class HighlightsGroupingPool : LockFreePool<Dictionary<int, Forma
// One lookup is in flight per render (occasionally two when panes render), so a small cap is plenty.
private HighlightsGroupingPool() : base(maxRetained: 8) { }

public Dictionary<int, FormatSpan> Get(IReadOnlyCollection<FormatSpan> highlights)
/// <summary>
/// Builds the start-offset -&gt; span lookup from only the spans starting within
/// [<paramref name="viewPortStartChar"/>, <paramref name="viewPortEndChar"/>) - the only ones the
/// caller can look up. The rest cost two int comparisons instead of a hash and a probe.
/// </summary>
public Dictionary<int, FormatSpan> Get(IReadOnlyCollection<FormatSpan> highlights, int viewPortStartChar, int viewPortEndChar)
{
var result = Rent() ?? new Dictionary<int, FormatSpan>(highlights.Count);
result.EnsureCapacity(highlights.Count);
var result = Rent() ?? new Dictionary<int, FormatSpan>();

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)
Expand Down
61 changes: 61 additions & 0 deletions src/PrettyPrompt/Rendering/UnicodeWidth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ public static class UnicodeWidth
/// <see cref="GetGraphemeClusterWidth(ReadOnlySpan{char})"/> instead.
/// </summary>
public static int GetWidth(char character)
=> character < AsciiWidths ? asciiWidths[character] : GetWidthCore(character);

/// <summary>
/// Pure memo of <see cref="GetWidthCore"/> over the ASCII range. <c>UnicodeCalculator.GetWidth</c> costs a
/// <see cref="HashSet{T}"/> 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.
/// </summary>
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.
Expand All @@ -55,13 +77,40 @@ public static int GetWidth(ReadOnlySpan<char> 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);
}
return width;
}

/// <summary>
/// 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".
///
/// <para>
/// 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.
/// </para>
/// </summary>
private static int LeadingSimpleAsciiRunLength(ReadOnlySpan<char> 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")
}

/// <summary>
/// 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
Expand Down Expand Up @@ -109,6 +158,18 @@ public static int GetLengthThatFits(ReadOnlySpan<char> 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;
Expand Down
88 changes: 88 additions & 0 deletions tests/PrettyPrompt.Tests/UnicodeWidthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));

/// <summary>
/// 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.
/// </summary>
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<string> DifferentialCorpus()
{
var data = new TheoryData<string>();
foreach (var first in DifferentialFragments)
{
data.Add(first);
foreach (var second in DifferentialFragments)
{
data.Add(first + second);
}
}
return data;
}

/// <summary>
/// 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.
/// </summary>
[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));
}
}

/// <summary>Reference: always walks clusters, never takes the fast path.</summary>
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;
}

/// <summary>Reference for <see cref="UnicodeWidth.GetLengthThatFits"/>, cluster by cluster.</summary>
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;
}
}
Loading