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
11 changes: 11 additions & 0 deletions src/PrettyPrompt/Documents/Document.cs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,17 @@ public event Action? Changed
remove => stringBuilder.Changed -= value;
}

/// <summary>
/// Fires only when the document text actually changes, not on caret-only moves (unlike <see cref="Changed"/>).
/// Used to trigger a full re-wrap; caret-only moves instead recompute the cursor cheaply from the existing
/// wrapped lines via <see cref="WordWrappedText.GetCursorForCaret"/> (see PERFORMANCE_PLAN.md Tier B1).
/// </summary>
public event Action? TextChanged
{
add => stringBuilder.TextChanged += value;
remove => stringBuilder.TextChanged -= value;
}

/*
* The following methods are forwarding along the StringBuilder APIs.
*/
Expand Down
38 changes: 28 additions & 10 deletions src/PrettyPrompt/Documents/Grapheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,20 @@ namespace PrettyPrompt.Documents;
/// e.g. an emoji such as "🤦🏼‍♂️" (one cluster, seven <see cref="char"/>s) or a base letter plus
/// combining marks - rather than splitting them into halves of a surrogate pair or orphaned
/// combining marks. See https://github.com/waf/PrettyPrompt/issues/270.
///
/// <para>
/// Characters below U+0300 (the first combining mark) are never surrogates, combining marks, joiners,
/// or any other cluster continuation, so they always form their own single-char cluster. The methods
/// here use that as an O(1) fast path (matching the fast path in <see cref="Rendering.UnicodeWidth"/>)
/// to avoid scanning - important because the caret can be deep into a long document.
/// </para>
/// </summary>
internal static class Grapheme
{
private const char FirstCombiningMark = '\u0300';

private static bool IsSimple(char c) => c < FirstCombiningMark;

/// <summary>
/// The smallest cluster boundary strictly greater than <paramref name="index"/>
/// (i.e. the caret position one grapheme to the right), clamped to the text length.
Expand All @@ -28,6 +39,9 @@ public static int NextBoundary(string text, int index)
{
if (index < 0) index = 0;
if (index >= text.Length) return text.Length;
// a simple character not followed by a continuation is a single-char cluster
if (IsSimple(text[index]) && (index + 1 == text.Length || IsSimple(text[index + 1])))
return index + 1;
return index + StringInfo.GetNextTextElementLength(text, index);
}

Expand All @@ -39,14 +53,9 @@ public static int PreviousBoundary(string text, int index)
{
if (index <= 0) return 0;
if (index > text.Length) index = text.Length;
int i = 0;
while (i < text.Length)
{
int next = i + StringInfo.GetNextTextElementLength(text, i);
if (next >= index) return i;
i = next;
}
return i;
// if the character ending the previous cluster is simple, that cluster is exactly one char
if (IsSimple(text[index - 1])) return index - 1;
return ScanToBoundary(text, index, inclusive: false);
}

/// <summary>
Expand All @@ -58,12 +67,21 @@ public static int RoundDownToBoundary(string text, int index)
{
if (index <= 0) return 0;
if (index >= text.Length) return text.Length;
// a simple character at 'index' always starts a new cluster, so 'index' is already a boundary
if (IsSimple(text[index])) return index;
return ScanToBoundary(text, index, inclusive: true);
}

// Walks clusters from the start of the text to find the boundary at (inclusive) or just below
// (exclusive) 'index'. Only reached for text containing surrogates/combining marks near the caret.
private static int ScanToBoundary(string text, int index, bool inclusive)
{
int i = 0;
while (i < text.Length)
{
int next = i + StringInfo.GetNextTextElementLength(text, i);
if (next == index) return index; // already on a boundary
if (next > index) return i; // index is inside [i, next): snap back to the cluster start
if (inclusive && next == index) return index;
if (next >= index) return i;
i = next;
}
return i;
Expand Down
24 changes: 19 additions & 5 deletions src/PrettyPrompt/Documents/StringBuilderWithCaret.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,37 +58,51 @@ public void Clear()
{
if (sb.Length > 0)
{
Caret = 0;
sb.Clear();
// Assign the caret field directly rather than via the Caret setter: the setter raises a transient
// caret-only Changed event mid-mutation (text already changed, but no TextChanged announced yet),
// which would drive consumers to recompute the cursor against now-stale state. The single
// InvokeChangedEvent below notifies once, with text and caret already consistent.
caret = 0;
InvokeChangedEvent();
}
}

public void SetContents(string contents, int? caret = null)
{
sb.SetContents(contents);
Caret = caret ?? sb.Length;
// Assign the caret field directly (not via the Caret setter) to avoid a transient caret-only Changed
// event before the text change is announced - see the note in Clear().
this.caret = caret ?? sb.Length;
Debug.Assert(this.caret >= 0 && this.caret <= sb.Length);
InvokeChangedEvent();
}

public void Insert(int index, char c)
{
sb.Insert(index, c);
++Caret;
// Advance the caret via the field, not the Caret setter, so we don't raise a transient caret-only
// Changed event before the text change is announced (see the note in Clear()). The single
// InvokeChangedEvent below fires once, with text and caret already consistent - which matters for
// callers that update live without suspending events (e.g. streaming via InsertAtCaretAsync).
caret++;
Debug.Assert(caret >= 0 && caret <= sb.Length);
InvokeChangedEvent();
}

public void Insert(int index, ReadOnlySpan<char> text)
{
sb.Insert(index, text);
Caret += text.Length;
caret += text.Length; // assign the field directly, not the Caret setter - see the note in Insert(int, char).
Debug.Assert(caret >= 0 && caret <= sb.Length);
InvokeChangedEvent();
}

public void Remove(int startIndex, int length)
{
sb.Remove(startIndex, length);
Caret = startIndex;
caret = startIndex; // assign the field directly, not the Caret setter - see the note in Insert(int, char).
Debug.Assert(caret >= 0 && caret <= sb.Length);
InvokeChangedEvent();
}

Expand Down
47 changes: 47 additions & 0 deletions src/PrettyPrompt/Documents/WordWrapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,53 @@ public WordWrappedText(IReadOnlyList<WrappedLine> wrappedLines, ConsoleCoordinat
this.cursor = default;
Cursor = cursor;
}

/// <summary>
/// Recomputes the 2-D cursor coordinate for a 1-D <paramref name="caret"/> index from the already-wrapped
/// lines, WITHOUT re-wrapping. Used on caret-only moves (see PERFORMANCE_PLAN.md Tier B1). This must produce
/// the identical coordinate that <see cref="WordWrapping.WrapEditableCharacters"/> would compute for the same
/// caret, text and width - a DEBUG assertion in <c>CodePane</c> verifies this on every caret move.
/// </summary>
public ConsoleCoordinate GetCursorForCaret(int caret)
{
Debug.Assert(caret >= 0);

// Find the wrapped line containing the caret: the last line whose StartIndex is &lt;= caret. The boundary
// rule (a caret exactly at a line's StartIndex belongs to THAT line at column 0) mirrors the wrap's
// `isCursorPastCharacter = caret > textIndex` semantics at a line break (WordWrapping.cs).
int lo = 0, hi = WrappedLines.Count - 1, row = 0;
while (lo <= hi)
{
int mid = (lo + hi) / 2;
if (WrappedLines[mid].StartIndex <= caret)
{
row = mid;
lo = mid + 1;
}
else
{
hi = mid - 1;
}
}

// Column = count of non-control chars on the line before the caret. The only control char in wrapped
// content is the line-terminating '\n', which is always the last char of its line and so never appears
// strictly before the caret within the caret's own line - so this loop matches the wrap's per-char
// `cursorColumn++` exactly (surrogate halves and combining marks each count as one, as they do there).
var content = WrappedLines[row].Content;
int offset = caret - WrappedLines[row].StartIndex;
Debug.Assert(offset >= 0 && offset <= content.Length);
int column = 0;
for (int i = 0; i < offset; i++)
{
if (!char.IsControl(content[i]))
{
column++;
}
}

return new ConsoleCoordinate(row, column);
}
}

[DebuggerDisplay("{Content}")]
Expand Down
100 changes: 70 additions & 30 deletions src/PrettyPrompt/Highlighting/CellRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using PrettyPrompt.Consoles;
using PrettyPrompt.Documents;
Expand All @@ -19,7 +20,23 @@ namespace PrettyPrompt.Highlighting;
internal static class CellRenderer
{
public static Row[] ApplyColorToCharacters(IReadOnlyCollection<FormatSpan> highlights, IReadOnlyList<WrappedLine> lines, SelectionSpan? selection, AnsiColor? selectedTextBackground)
=> ApplyColorToCharacters(highlights, lines, selection, selectedTextBackground, startLine: 0, endLine: lines.Count);

/// <summary>
/// Builds the <see cref="Row"/>/<see cref="Cell"/>s for the wrapped lines in the half-open range
/// [<paramref name="startLine"/>, <paramref name="endLine"/>). Building only the visible range (instead
/// of the whole document and then discarding off-screen rows) keeps per-keystroke cost and allocation
/// bounded by the viewport rather than the document size (see PERFORMANCE_PLAN.md Tier C).
///
/// When <paramref name="startLine"/> &gt; 0, the two pieces of state a full top-down pass would have
/// carried across the skipped lines are seeded explicitly:
/// - <c>currentHighlight</c>: a multi-line highlight span that began above the viewport and is still open.
/// - <c>selectionHighlight</c>: whether the text selection is already "open" at the top of the viewport.
/// </summary>
public static Row[] ApplyColorToCharacters(IReadOnlyCollection<FormatSpan> highlights, IReadOnlyList<WrappedLine> lines, SelectionSpan? selection, AnsiColor? selectedTextBackground, int startLine, int endLine)
{
Debug.Assert(startLine >= 0 && startLine <= endLine && endLine <= lines.Count);

var selectionStart = new ConsoleCoordinate(int.MaxValue, int.MaxValue); //invalid
var selectionEnd = new ConsoleCoordinate(int.MaxValue, int.MaxValue); //invalid
if (selection.TryGet(out var selectionValue))
Expand All @@ -28,12 +45,13 @@ public static Row[] ApplyColorToCharacters(IReadOnlyCollection<FormatSpan> highl
selectionEnd = selectionValue.End;
}

bool selectionHighlight = false;
// 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);
var highlightedRows = new Row[lines.Count];
FormatSpan? currentHighlight = null;
for (int lineIndex = 0; lineIndex < lines.Count; lineIndex++)
var highlightedRows = new Row[endLine - startLine];
FormatSpan? currentHighlight = SeedCurrentHighlight(highlights, lines, startLine);
for (int lineIndex = startLine; lineIndex < endLine; lineIndex++)
{
WrappedLine line = lines[lineIndex];
int lineFullWidthCharacterOffset = 0;
Expand Down Expand Up @@ -83,11 +101,49 @@ public static Row[] ApplyColorToCharacters(IReadOnlyCollection<FormatSpan> highl
}
}
}
highlightedRows[lineIndex] = row;
highlightedRows[lineIndex - startLine] = row;
}

// Return the lookup to the pool. The dict is local and its values (FormatSpan/ConsoleFormat) are value
// types copied into the cells, so nothing outlives this call. Without this Put the pool stayed empty and
// every render allocated a fresh dictionary sized to ALL highlight spans (large in highlight-heavy docs).
HighlightsGroupingPool.Shared.Put(highlightsLookup);
return highlightedRows;
}

/// <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
/// began strictly before this line's first character and still covers it. Returns null when starting at
/// the top, or when no span straddles the viewport's top boundary.
/// </summary>
private static FormatSpan? SeedCurrentHighlight(IReadOnlyCollection<FormatSpan> highlights, IReadOnlyList<WrappedLine> lines, int startLine)
{
if (startLine == 0)
{
return null;
}

int startCharIndex = lines[startLine].StartIndex;
FormatSpan? seed = null;
foreach (var span in highlights)
{
if (span.Start < startCharIndex && span.Contains(startCharIndex))
{
// Prefer the span that began closest to the boundary (and, on a tie, the longest) so we
// match the single span the top-down carry would be holding. For the usual disjoint
// (non-overlapping) syntax-highlight spans there is at most one candidate.
if (seed is null
|| span.Start > seed.Value.Start
|| (span.Start == seed.Value.Start && span.Length > seed.Value.Length))
{
seed = span;
}
}
}
return seed;
}

private static FormatSpan? HighlightSpan(FormatSpan currentHighlight, Row row, int cellIndex, int endPosition)
{
var highlightedFullWidthOffset = 0;
Expand Down Expand Up @@ -115,30 +171,17 @@ public static Row[] ApplyColorToCharacters(IReadOnlyCollection<FormatSpan> highl
return ApplyColorToCharacters(highlights, wrapped.WrappedLines, selection: null, selectedTextBackground: null);
}

private sealed class HighlightsGroupingPool
private sealed class HighlightsGroupingPool : LockFreePool<Dictionary<int, FormatSpan>>
{
private readonly Stack<Dictionary<int, FormatSpan>> pool = new();

public static readonly HighlightsGroupingPool Shared = new();

// 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)
{
Dictionary<int, FormatSpan>? result = null;
lock (pool)
{
if (pool.Count > 0)
{
result = pool.Pop();
}
}
if (result is null)
{
result = new Dictionary<int, FormatSpan>(highlights.Count);
}
else
{
result.EnsureCapacity(highlights.Count);
}
var result = Rent() ?? new Dictionary<int, FormatSpan>(highlights.Count);
result.EnsureCapacity(highlights.Count);

foreach (var highlight in highlights)
{
Expand All @@ -158,13 +201,10 @@ public Dictionary<int, FormatSpan> Get(IReadOnlyCollection<FormatSpan> highlight
return result;
}

public void Put(Dictionary<int, FormatSpan> list)
public void Put(Dictionary<int, FormatSpan> lookup)
{
list.Clear();
lock (pool)
{
pool.Push(list);
}
lookup.Clear();
ReturnToPool(lookup);
}
}
}
Loading
Loading