Browse Source

Implement TextRunCache (#21030)

pull/21207/head
Benedikt Stebner 6 months ago
committed by GitHub
parent
commit
f41f97ee26
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
  2. 21
      src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs
  3. 144
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  4. 61
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  5. 220
      src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs
  6. 34
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  7. 45
      src/Avalonia.Controls/TextBlock.cs
  8. 144
      tests/Avalonia.Benchmarks/Text/TextRunCacheBenchmark.cs
  9. 364
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextRunCacheTests.cs

5
src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs

@ -37,6 +37,11 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
public int Length => _glyphInfos.Length;
/// <summary>
/// The buffer's glyph infos.
/// </summary>
internal ArraySlice<GlyphInfo> GlyphInfos => _glyphInfos;
/// <summary>
/// The buffer's glyph typeface.
/// </summary>

21
src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs

@ -41,6 +41,27 @@
public abstract TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null);
/// <summary>
/// Formats a text line with an optional <see cref="TextRunCache"/> to avoid redundant shaping
/// when only the paragraph width changes.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="firstTextSourceIndex">The first character index to start the text line from.</param>
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</param>
/// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
/// such as TextWrapping, TextAlignment, or TextStyle.</param>
/// <param name="previousLineBreak">A <see cref="TextLineBreak"/> value that specifies the text formatter state,
/// in terms of where the previous line in the paragraph was broken by the text formatting process.</param>
/// <param name="textRunCache">A <see cref="TextRunCache"/> that caches shaped text runs.</param>
/// <returns>The formatted line.</returns>
public virtual TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak,
TextRunCache? textRunCache)
{
return FormatLine(textSource, firstTextSourceIndex, paragraphWidth,
paragraphProperties, previousLineBreak);
}
/// <summary>
/// Creates a shaped symbol.
/// </summary>

144
src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs

@ -17,9 +17,18 @@ namespace Avalonia.Media.TextFormatting
[ThreadStatic] private static BidiData? t_bidiData;
[ThreadStatic] private static BidiAlgorithm? t_bidiAlgorithm;
/// <inheritdoc cref="TextFormatter.FormatLine"/>
/// <inheritdoc/>
public override TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null)
{
return FormatLine(textSource, firstTextSourceIndex, paragraphWidth,
paragraphProperties, previousLineBreak, null);
}
/// <inheritdoc/>
public override TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak,
TextRunCache? textRunCache)
{
TextLineBreak? nextLineBreak = null;
var objectPool = FormattingObjectPool.Instance;
@ -34,6 +43,14 @@ namespace Avalonia.Media.TextFormatting
paragraphProperties, previousLineBreak.FlowDirection, previousLineBreak, objectPool);
}
// Try to use cached shaped runs to avoid redundant shaping/bidi processing.
if (textRunCache != null
&& textRunCache.TryGetShapedRuns(firstTextSourceIndex, out var cached))
{
return FormatLineFromCache(cached, firstTextSourceIndex, paragraphWidth,
paragraphProperties, objectPool);
}
RentedList<TextRun>? fetchedRuns = null;
RentedList<TextRun>? shapedTextRuns = null;
try
@ -54,11 +71,32 @@ namespace Avalonia.Media.TextFormatting
nextLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection);
}
// Store shaped runs in cache for reuse.
if (textRunCache != null)
{
textRunCache.Add(firstTextSourceIndex,
new CachedShapingResult(shapedTextRuns.ToArray(), resolvedFlowDirection,
textEndOfLine, textSourceLength));
}
switch (paragraphProperties.TextWrapping)
{
case TextWrapping.NoWrap:
{
var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex,
TextRun[] lineRuns;
if (textRunCache != null)
{
// When caching, the cache owns the shaped runs.
// Create non-owning copies for the line.
lineRuns = CreateNonOwningRuns(shapedTextRuns);
}
else
{
lineRuns = shapedTextRuns.ToArray();
}
var textLine = new TextLineImpl(lineRuns, firstTextSourceIndex,
textSourceLength,
paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak);
@ -69,6 +107,16 @@ namespace Avalonia.Media.TextFormatting
case TextWrapping.WrapWithOverflow:
case TextWrapping.Wrap:
{
if (textRunCache != null)
{
// When caching, create non-owning copies for wrapping.
var nonOwningRuns = CreateNonOwningRunsList(shapedTextRuns);
return PerformTextWrapping(nonOwningRuns, false, firstTextSourceIndex,
paragraphWidth, paragraphProperties, resolvedFlowDirection,
nextLineBreak, objectPool);
}
return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth,
paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool);
}
@ -83,6 +131,98 @@ namespace Avalonia.Media.TextFormatting
}
}
/// <summary>
/// Formats a line from cached shaped runs, skipping shaping and bidi processing.
/// </summary>
private static TextLine FormatLineFromCache(CachedShapingResult cached, int firstTextSourceIndex,
double paragraphWidth, TextParagraphProperties paragraphProperties, FormattingObjectPool objectPool)
{
var resolvedFlowDirection = cached.ResolvedFlowDirection;
TextLineBreak? nextLineBreak = null;
if (cached.TextEndOfLine != null)
{
nextLineBreak = new TextLineBreak(cached.TextEndOfLine, resolvedFlowDirection);
}
switch (paragraphProperties.TextWrapping)
{
case TextWrapping.NoWrap:
{
var lineRuns = CreateNonOwningRuns(cached.ShapedRuns);
var textLine = new TextLineImpl(lineRuns, firstTextSourceIndex,
cached.TextSourceLength,
paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak);
textLine.FinalizeLine();
return textLine;
}
case TextWrapping.WrapWithOverflow:
case TextWrapping.Wrap:
{
var nonOwningRuns = CreateNonOwningRunsList(cached.ShapedRuns);
return PerformTextWrapping(nonOwningRuns, false, firstTextSourceIndex,
paragraphWidth, paragraphProperties, resolvedFlowDirection,
nextLineBreak, objectPool);
}
default:
throw new ArgumentOutOfRangeException(nameof(paragraphProperties.TextWrapping));
}
}
/// <summary>
/// Creates a non-owning copy of a text run. For shaped text runs, this creates
/// a new instance with a separate shaped buffer so that disposing the copy does
/// not dispose the original cached shaped buffer.
/// </summary>
private static TextRun CreateNonOwningRun(TextRun run)
{
if (run is ShapedTextRun shaped)
{
var buf = shaped.ShapedBuffer;
return new ShapedTextRun(
new ShapedBuffer(buf.Text, buf.GlyphInfos, buf.GlyphTypeface, buf.FontRenderingEmSize, buf.BidiLevel),
shaped.Properties);
}
return run;
}
/// <summary>
/// Creates non-owning copies of shaped text runs for use in text lines,
/// so that disposing the line does not dispose the cached shaped buffers.
/// </summary>
private static TextRun[] CreateNonOwningRuns(IReadOnlyList<TextRun> runs)
{
var result = new TextRun[runs.Count];
for (var i = 0; i < runs.Count; i++)
{
result[i] = CreateNonOwningRun(runs[i]);
}
return result;
}
/// <summary>
/// Creates a non-owning list of shaped text runs for use with text wrapping.
/// </summary>
private static List<TextRun> CreateNonOwningRunsList(IReadOnlyList<TextRun> runs)
{
var result = new List<TextRun>(runs.Count);
for (var i = 0; i < runs.Count; i++)
{
result.Add(CreateNonOwningRun(runs[i]));
}
return result;
}
/// <summary>
/// Split a sequence of runs into two segments at specified length.
/// </summary>

61
src/Avalonia.Base/Media/TextFormatting/TextLayout.cs

@ -14,6 +14,7 @@ namespace Avalonia.Media.TextFormatting
private readonly TextTrimming _textTrimming;
private readonly TextLine[] _textLines;
private readonly CachedMetrics _metrics = new();
private readonly TextRunCache? _textRunCache;
private int _textSourceLength;
@ -36,6 +37,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="maxLines">The maximum number of text lines.</param>
/// <param name="fontFeatures">Optional list of turned on/off features.</param>
/// <param name="textStyleOverrides">The text style overrides.</param>
/// <param name="textRunCache">An optional cache for shaped text runs to avoid redundant shaping.</param>
public TextLayout(
string? text,
Typeface typeface,
@ -52,7 +54,8 @@ namespace Avalonia.Media.TextFormatting
double letterSpacing = 0,
int maxLines = 0,
FontFeatureCollection? fontFeatures = null,
IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides = null)
IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides = null,
TextRunCache? textRunCache = null)
{
_paragraphProperties =
CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping,
@ -68,9 +71,40 @@ namespace Avalonia.Media.TextFormatting
MaxLines = maxLines;
_textRunCache = textRunCache;
_textLines = CreateTextLines();
}
/// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class.
/// </summary>
/// <remarks>
/// This overload is provided for binary compatibility. New code should use the overload that accepts a <see cref="TextRunCache"/>.
/// </remarks>
public TextLayout(
string? text,
Typeface typeface,
double fontSize,
IBrush? foreground,
TextAlignment textAlignment,
TextWrapping textWrapping,
TextTrimming? textTrimming,
TextDecorationCollection? textDecorations,
FlowDirection flowDirection,
double maxWidth,
double maxHeight,
double lineHeight,
double letterSpacing,
int maxLines,
FontFeatureCollection? fontFeatures,
IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides)
: this(text, typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming,
textDecorations, flowDirection, maxWidth, maxHeight, lineHeight, letterSpacing,
maxLines, fontFeatures, textStyleOverrides, null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class.
/// </summary>
@ -80,13 +114,15 @@ namespace Avalonia.Media.TextFormatting
/// <param name="maxWidth">The maximum width.</param>
/// <param name="maxHeight">The maximum height.</param>
/// <param name="maxLines">The maximum number of text lines.</param>
/// <param name="textRunCache">An optional cache for shaped text runs to avoid redundant shaping.</param>
public TextLayout(
ITextSource textSource,
TextParagraphProperties paragraphProperties,
TextTrimming? textTrimming = null,
double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity,
int maxLines = 0)
int maxLines = 0,
TextRunCache? textRunCache = null)
{
_textSource = textSource;
@ -100,9 +136,28 @@ namespace Avalonia.Media.TextFormatting
MaxLines = maxLines;
_textRunCache = textRunCache;
_textLines = CreateTextLines();
}
/// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class.
/// </summary>
/// <remarks>
/// This overload is provided for binary compatibility. New code should use the overload that accepts a <see cref="TextRunCache"/>.
/// </remarks>
public TextLayout(
ITextSource textSource,
TextParagraphProperties paragraphProperties,
TextTrimming? textTrimming,
double maxWidth,
double maxHeight,
int maxLines)
: this(textSource, paragraphProperties, textTrimming, maxWidth, maxHeight, maxLines, null)
{
}
/// <summary>
/// Gets or sets the height of each line of text.
/// </summary>
@ -527,7 +582,7 @@ namespace Avalonia.Media.TextFormatting
while (true)
{
var textLine = textFormatter.FormatLine(_textSource, _textSourceLength, MaxWidth,
_paragraphProperties, previousLine?.TextLineBreak) as TextLineImpl;
_paragraphProperties, previousLine?.TextLineBreak, _textRunCache) as TextLineImpl;
if (textLine is null)
{

220
src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs

@ -0,0 +1,220 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using Avalonia.Metadata;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Caches shaped text runs and bidi processing results to avoid redundant shaping
/// when only the paragraph width constraint changes (e.g., between Measure and Arrange).
/// </summary>
/// <remarks>
/// Uses an inline single-entry store for the common case of a single paragraph,
/// and only promotes to a dictionary when multiple entries are added.
/// </remarks>
[Unstable("This API is in preview and subject to change without deprecation.")]
public class TextRunCache : IDisposable
{
// Single-entry inline store (avoids Dictionary allocation for the common single-paragraph case).
private bool _hasSingleEntry;
private int _singleKey;
private CachedShapingResult _singleValue;
// Multi-entry store (only allocated when 2+ distinct keys are added).
private Dictionary<int, CachedShapingResult>? _entries;
/// <summary>
/// Invalidates all cached entries and disposes their shaped buffers.
/// </summary>
public void Invalidate()
{
if (_hasSingleEntry)
{
DisposeCachedRuns(_singleValue);
_hasSingleEntry = false;
_singleValue = default;
return;
}
if (_entries == null)
{
return;
}
foreach (var entry in _entries.Values)
{
DisposeCachedRuns(entry);
}
_entries.Clear();
}
/// <summary>
/// Invalidates all cached entries at or after the specified text source index.
/// </summary>
/// <param name="textSourceIndex">The text source index from which to invalidate.</param>
public void InvalidateFrom(int textSourceIndex)
{
if (_hasSingleEntry)
{
if (_singleKey >= textSourceIndex)
{
DisposeCachedRuns(_singleValue);
_hasSingleEntry = false;
_singleValue = default;
}
return;
}
if (_entries == null || _entries.Count == 0)
{
return;
}
var count = _entries.Count;
int[]? rented = null;
Span<int> keysToRemove = count <= 16
? stackalloc int[16]
: (rented = ArrayPool<int>.Shared.Rent(count));
var removeCount = 0;
foreach (var key in _entries.Keys)
{
if (key >= textSourceIndex)
{
keysToRemove[removeCount++] = key;
}
}
for (var i = 0; i < removeCount; i++)
{
if (_entries.Remove(keysToRemove[i], out var result))
{
DisposeCachedRuns(result);
}
}
if (rented != null)
{
ArrayPool<int>.Shared.Return(rented);
}
}
/// <summary>
/// Tries to retrieve cached shaped runs for the given text source index.
/// </summary>
internal bool TryGetShapedRuns(int firstTextSourceIndex, out CachedShapingResult result)
{
if (_hasSingleEntry && _singleKey == firstTextSourceIndex)
{
result = _singleValue;
return true;
}
if (_entries != null && _entries.TryGetValue(firstTextSourceIndex, out result))
{
return true;
}
result = default;
return false;
}
/// <summary>
/// Adds shaped runs to the cache for the given text source index.
/// </summary>
internal void Add(int firstTextSourceIndex, CachedShapingResult result)
{
if (_entries != null)
{
// Already in multi-entry mode.
_entries[firstTextSourceIndex] = result;
return;
}
if (!_hasSingleEntry)
{
// First entry: store inline.
_singleKey = firstTextSourceIndex;
_singleValue = result;
_hasSingleEntry = true;
return;
}
if (_singleKey == firstTextSourceIndex)
{
// Same key: update inline value.
_singleValue = result;
return;
}
// Second distinct key: promote to dictionary.
_entries = new Dictionary<int, CachedShapingResult>
{
{ _singleKey, _singleValue },
{ firstTextSourceIndex, result }
};
_hasSingleEntry = false;
_singleValue = default;
}
/// <inheritdoc/>
public void Dispose()
{
Invalidate();
_entries = null;
}
private static void DisposeCachedRuns(CachedShapingResult result)
{
var runs = result.ShapedRuns;
for (var i = 0; i < runs.Length; i++)
{
if (runs[i] is ShapedTextRun shaped)
{
shaped.Dispose();
}
}
}
}
/// <summary>
/// Stores the result of text shaping for a paragraph segment starting at a given text source index.
/// </summary>
internal readonly struct CachedShapingResult
{
public CachedShapingResult(TextRun[] shapedRuns, FlowDirection resolvedFlowDirection,
TextEndOfLine? textEndOfLine, int textSourceLength)
{
ShapedRuns = shapedRuns;
ResolvedFlowDirection = resolvedFlowDirection;
TextEndOfLine = textEndOfLine;
TextSourceLength = textSourceLength;
}
/// <summary>
/// The shaped text runs (output of ShapeTextRuns).
/// </summary>
public readonly TextRun[] ShapedRuns;
/// <summary>
/// The resolved flow direction for the paragraph.
/// </summary>
public readonly FlowDirection ResolvedFlowDirection;
/// <summary>
/// The end of line marker, if any.
/// </summary>
public readonly TextEndOfLine? TextEndOfLine;
/// <summary>
/// The total text source length consumed.
/// </summary>
public readonly int TextSourceLength;
}
}

34
src/Avalonia.Controls/Presenters/TextPresenter.cs

@ -98,6 +98,7 @@ namespace Avalonia.Controls.Presenters
private DispatcherTimer? _caretTimer;
private bool _caretBlink;
private TextLayout? _textLayout;
private TextRunCache? _textRunCache;
private Size _constraint;
private CharacterHit _lastCharacterHit;
@ -366,7 +367,8 @@ namespace Avalonia.Controls.Presenters
LetterSpacing,
0,
FontFeatures,
textStyleOverrides);
textStyleOverrides,
_textRunCache ??= new TextRunCache());
return textLayout;
}
@ -629,6 +631,16 @@ namespace Avalonia.Controls.Presenters
}
protected virtual void InvalidateTextLayout()
{
_textRunCache?.Invalidate();
_textLayout?.Dispose();
_textLayout = null;
InvalidateVisual();
InvalidateMeasure();
}
private void InvalidateTextLayoutKeepCache()
{
_textLayout?.Dispose();
_textLayout = null;
@ -1038,6 +1050,7 @@ namespace Avalonia.Controls.Presenters
switch (change.Property.Name)
{
// Properties that affect shaping: invalidate the run cache + layout.
case nameof(PreeditText):
case nameof(Foreground):
case nameof(FontSize):
@ -1045,24 +1058,25 @@ namespace Avalonia.Controls.Presenters
case nameof(FontWeight):
case nameof(FontFamily):
case nameof(FontStretch):
case nameof(Text):
case nameof(LetterSpacing):
case nameof(PasswordChar):
case nameof(RevealPassword):
case nameof(FlowDirection):
{
InvalidateTextLayout();
break;
}
// Properties that do not affect shaping: preserve the run cache.
case nameof(TextAlignment):
case nameof(TextWrapping):
case nameof(LineHeight):
case nameof(LetterSpacing):
case nameof(SelectionStart):
case nameof(SelectionEnd):
case nameof(SelectionForegroundBrush):
case nameof(ShowSelectionHighlight):
case nameof(PasswordChar):
case nameof(RevealPassword):
case nameof(FlowDirection):
{
InvalidateTextLayout();
InvalidateTextLayoutKeepCache();
break;
}
}

45
src/Avalonia.Controls/TextBlock.cs

@ -159,6 +159,7 @@ namespace Avalonia.Controls
nameof(Inlines), t => t.Inlines, (t, v) => t.Inlines = v);
private TextLayout? _textLayout;
private TextRunCache? _textRunCache;
protected Size _constraint = new(double.NaN, double.NaN);
protected IReadOnlyList<TextRun>? _textRuns;
private InlineCollection? _inlines;
@ -690,13 +691,25 @@ namespace Avalonia.Controls
TextTrimming,
maxSize.Width,
maxSize.Height,
MaxLines);
MaxLines,
_textRunCache ??= new TextRunCache());
}
/// <summary>
/// Invalidates <see cref="TextLayout"/>.
/// Invalidates <see cref="TextLayout"/> and the <see cref="TextRunCache"/>.
/// </summary>
protected void InvalidateTextLayout()
{
_textRunCache?.Invalidate();
InvalidateVisual();
InvalidateMeasure();
}
/// <summary>
/// Invalidates <see cref="TextLayout"/> while preserving the <see cref="TextRunCache"/>.
/// Use when only layout-affecting properties change (e.g., TextWrapping, TextAlignment).
/// </summary>
private void InvalidateTextLayoutKeepCache()
{
InvalidateVisual();
InvalidateMeasure();
@ -768,7 +781,7 @@ namespace Avalonia.Controls
var availableSize = finalSize.Deflate(padding);
//ToDo: Introduce a text run cache to be able to reuse shaped runs etc.
// Dispose the TextLayout but preserve the TextRunCache so shaped runs are reused.
_textLayout?.Dispose();
_textLayout = null;
_constraint = availableSize;
@ -835,23 +848,14 @@ namespace Avalonia.Controls
switch (change.Property.Name)
{
// Properties that affect shaping: invalidate the run cache + layout.
case nameof(FontSize):
case nameof(FontWeight):
case nameof(FontStyle):
case nameof(FontFamily):
case nameof(FontStretch):
case nameof(TextWrapping):
case nameof(TextTrimming):
case nameof(TextAlignment):
case nameof(FlowDirection):
case nameof(Padding):
case nameof(LineHeight):
case nameof(LetterSpacing):
case nameof(MaxLines):
case nameof(Text):
case nameof(TextDecorations):
case nameof(FontFeatures):
@ -860,6 +864,17 @@ namespace Avalonia.Controls
InvalidateTextLayout();
break;
}
// Properties that do not affect shaping: preserve the run cache.
case nameof(TextWrapping):
case nameof(TextTrimming):
case nameof(TextAlignment):
case nameof(Padding):
case nameof(LineHeight):
case nameof(MaxLines):
{
InvalidateTextLayoutKeepCache();
break;
}
case nameof(Inlines):
{
OnInlinesChanged(change.OldValue as InlineCollection, change.NewValue as InlineCollection);
@ -895,12 +910,12 @@ namespace Avalonia.Controls
return;
void Invalidated(object? sender, EventArgs e) => InvalidateMeasure();
void Invalidated(object? sender, EventArgs e) => InvalidateTextLayout();
}
void IInlineHost.Invalidate()
{
InvalidateMeasure();
InvalidateTextLayout();
}
IAvaloniaList<Visual> IInlineHost.VisualChildren => VisualChildren;

144
tests/Avalonia.Benchmarks/Text/TextRunCacheBenchmark.cs

@ -0,0 +1,144 @@
using System;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using BenchmarkDotNet.Attributes;
namespace Avalonia.Benchmarks.Text;
[MemoryDiagnoser]
[MinIterationTime(150)]
[MaxWarmupCount(15)]
public class TextRunCacheBenchmark : IDisposable
{
private readonly IDisposable _app;
private const string ShortText = "The quick brown fox jumps over the lazy dog.";
private const string LongText =
"Though, the objectives of the development of the prominent landmarks can be neglected in most cases, " +
"it should be realized that after the completion of the strategic decision gives rise to " +
"The Expertise of Regular Program. A number of key issues arise from the belief that the explicit " +
"examination of strategic management should correlate with the conceptual design. " +
"By all means, the unification of the reliably developed techniques indicates the importance of " +
"the ultimate advantage of episodic skill over alternate practices.";
public TextRunCacheBenchmark()
{
_app = UnitTestApplication.Start(TestServices.StyledWindow);
}
[Params(5, 20)]
public int Iterations { get; set; }
[Benchmark(Baseline = true)]
public void LayoutWithoutCache_Short()
{
for (var i = 0; i < Iterations; i++)
{
using var layout = new TextLayout(ShortText, Typeface.Default, 12d, Brushes.Black,
maxWidth: 200, textWrapping: TextWrapping.WrapWithOverflow);
}
}
[Benchmark]
public void LayoutWithCache_Short()
{
using var cache = new TextRunCache();
for (var i = 0; i < Iterations; i++)
{
using var layout = new TextLayout(ShortText, Typeface.Default, 12d, Brushes.Black,
maxWidth: 200, textWrapping: TextWrapping.WrapWithOverflow, textRunCache: cache);
}
}
[Benchmark]
public void LayoutWithoutCache_Long()
{
for (var i = 0; i < Iterations; i++)
{
using var layout = new TextLayout(LongText, Typeface.Default, 12d, Brushes.Black,
maxWidth: 300, textWrapping: TextWrapping.WrapWithOverflow);
}
}
[Benchmark]
public void LayoutWithCache_Long()
{
using var cache = new TextRunCache();
for (var i = 0; i < Iterations; i++)
{
using var layout = new TextLayout(LongText, Typeface.Default, 12d, Brushes.Black,
maxWidth: 300, textWrapping: TextWrapping.WrapWithOverflow, textRunCache: cache);
}
}
[Benchmark]
public void LayoutWithoutCache_VaryingWidth()
{
for (var i = 0; i < Iterations; i++)
{
var width = 200 + i * 10;
using var layout = new TextLayout(LongText, Typeface.Default, 12d, Brushes.Black,
maxWidth: width, textWrapping: TextWrapping.WrapWithOverflow);
}
}
[Benchmark]
public void LayoutWithCache_VaryingWidth()
{
using var cache = new TextRunCache();
for (var i = 0; i < Iterations; i++)
{
var width = 200 + i * 10;
using var layout = new TextLayout(LongText, Typeface.Default, 12d, Brushes.Black,
maxWidth: width, textWrapping: TextWrapping.WrapWithOverflow, textRunCache: cache);
}
}
/// <summary>
/// Benchmarks the single-entry fast path: a simple single-paragraph text
/// that results in only one cache entry (the common case for TextBlock).
/// </summary>
[Benchmark]
public void LayoutWithCache_SingleEntry_Short()
{
using var cache = new TextRunCache();
for (var i = 0; i < Iterations; i++)
{
// NoWrap + single paragraph = single cache entry at index 0.
using var layout = new TextLayout(ShortText, Typeface.Default, 12d, Brushes.Black,
maxWidth: double.PositiveInfinity, textWrapping: TextWrapping.NoWrap, textRunCache: cache);
}
}
/// <summary>
/// Benchmarks the single-entry fast path with invalidate/re-populate cycle,
/// verifying that the inline store is reused without dictionary allocation.
/// </summary>
[Benchmark]
public void LayoutWithCache_SingleEntry_InvalidateRepopulate()
{
using var cache = new TextRunCache();
for (var i = 0; i < Iterations; i++)
{
cache.Invalidate();
using var layout = new TextLayout(ShortText, Typeface.Default, 12d, Brushes.Black,
maxWidth: double.PositiveInfinity, textWrapping: TextWrapping.NoWrap, textRunCache: cache);
}
}
public void Dispose()
{
_app?.Dispose();
}
}

364
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextRunCacheTests.cs

@ -0,0 +1,364 @@
#nullable enable
using System;
using Avalonia.Headless;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
public class TextRunCacheTests
{
[Fact]
public void Cache_Hit_Produces_Identical_Layout()
{
using (Start())
{
var text = "Hello World";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
using var cache = new TextRunCache();
// First call: cache miss, populates cache.
var line1 = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, cache);
Assert.NotNull(line1);
// Second call: cache hit, different paragraph width.
var line2 = formatter.FormatLine(textSource, 0, 200.0,
paragraphProperties, null, cache);
Assert.NotNull(line2);
// Both lines should have the same text length.
Assert.Equal(line1!.Length, line2!.Length);
// Both lines should have the same number of text runs.
Assert.Equal(line1.TextRuns.Count, line2.TextRuns.Count);
}
}
[Fact]
public void Full_Invalidation_Clears_Cache()
{
using (Start())
{
var text = "Hello World";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
using var cache = new TextRunCache();
// Populate cache.
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, cache);
// Invalidate.
cache.Invalidate();
// Verify cache miss: should not throw and should produce a valid line.
var line = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, cache);
Assert.NotNull(line);
Assert.Equal(text.Length, line!.Length);
}
}
[Fact]
public void Partial_Invalidation_Preserves_Earlier_Entries()
{
using (Start())
{
var text = "First paragraph\nSecond paragraph";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
using var cache = new TextRunCache();
// Format first paragraph (populates cache at index 0).
var line1 = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, cache);
Assert.NotNull(line1);
var firstLineLength = line1!.Length;
// Format second paragraph (populates cache at firstLineLength).
var line2 = formatter.FormatLine(textSource, firstLineLength, double.PositiveInfinity,
paragraphProperties, null, cache);
Assert.NotNull(line2);
// Invalidate from the second paragraph index.
cache.InvalidateFrom(firstLineLength);
// First paragraph should still be cached (cache hit).
var line1Again = formatter.FormatLine(textSource, 0, 200.0,
paragraphProperties, null, cache);
Assert.NotNull(line1Again);
Assert.Equal(line1.Length, line1Again!.Length);
// Second paragraph should be re-shaped (cache miss then re-populated).
var line2Again = formatter.FormatLine(textSource, firstLineLength, double.PositiveInfinity,
paragraphProperties, null, cache);
Assert.NotNull(line2Again);
Assert.Equal(line2!.Length, line2Again!.Length);
}
}
[Fact]
public void Text_Wrapping_With_Cache_Produces_Correct_Lines()
{
using (Start())
{
var text = "The quick brown fox jumps over the lazy dog";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var wrappingProperties = new GenericTextParagraphProperties(
FlowDirection.LeftToRight, TextAlignment.Left, true, false,
defaultProperties, TextWrapping.Wrap, 0, 0, 0);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
// Format without cache.
var linesWithout = FormatAllLines(formatter, textSource, 100.0, wrappingProperties, null);
// Format with cache (first pass: cache miss).
using var cache = new TextRunCache();
var linesWith = FormatAllLines(formatter, textSource, 100.0, wrappingProperties, cache);
Assert.Equal(linesWithout.Length, linesWith.Length);
for (int i = 0; i < linesWithout.Length; i++)
{
Assert.Equal(linesWithout[i].Length, linesWith[i].Length);
}
// Format with cache again (second pass: cache hit).
var linesCacheHit = FormatAllLines(formatter, textSource, 100.0, wrappingProperties, cache);
Assert.Equal(linesWithout.Length, linesCacheHit.Length);
for (int i = 0; i < linesWithout.Length; i++)
{
Assert.Equal(linesWithout[i].Length, linesCacheHit[i].Length);
}
}
}
[Fact]
public void Wrapping_With_Different_Width_From_Cache()
{
using (Start())
{
var text = "The quick brown fox jumps over the lazy dog";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var wrappingProperties = new GenericTextParagraphProperties(
FlowDirection.LeftToRight, TextAlignment.Left, true, false,
defaultProperties, TextWrapping.Wrap, 0, 0, 0);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
using var cache = new TextRunCache();
// First format: wide (cache miss, populates).
var wideLines = FormatAllLines(formatter, textSource, 500.0, wrappingProperties, cache);
// Second format: narrow (cache hit, different wrapping).
var narrowLines = FormatAllLines(formatter, textSource, 80.0, wrappingProperties, cache);
// Narrow should produce more lines.
Assert.True(narrowLines.Length >= wideLines.Length);
// Total characters should be the same.
int wideTotal = 0, narrowTotal = 0;
foreach (var l in wideLines) wideTotal += l.Length;
foreach (var l in narrowLines) narrowTotal += l.Length;
Assert.Equal(wideTotal, narrowTotal);
}
}
[Fact]
public void Bidi_Text_With_Cache_Produces_Correct_Results()
{
using (Start())
{
// Mixed LTR/RTL text.
var text = "Hello \u0627\u0644\u0639\u0631\u0628\u064A\u0629 World";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
// Without cache.
var lineWithout = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, null);
// With cache.
using var cache = new TextRunCache();
var lineWith = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, cache);
Assert.NotNull(lineWithout);
Assert.NotNull(lineWith);
Assert.Equal(lineWithout!.Length, lineWith!.Length);
Assert.Equal(lineWithout.TextRuns.Count, lineWith.TextRuns.Count);
// Cache hit should also produce correct results.
var lineCacheHit = formatter.FormatLine(textSource, 0, 200.0,
paragraphProperties, null, cache);
Assert.NotNull(lineCacheHit);
Assert.Equal(lineWithout.Length, lineCacheHit!.Length);
}
}
[Fact]
public void Dispose_Releases_Cache_Entries()
{
using (Start())
{
var text = "Hello World";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var cache = new TextRunCache();
// Populate cache.
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, cache);
// Dispose should not throw.
cache.Dispose();
// After dispose, using the cache should still work (re-creates entries).
using var cache2 = new TextRunCache();
var line = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
paragraphProperties, null, cache2);
Assert.NotNull(line);
}
}
[Fact]
public void Cache_With_Multiple_Paragraphs()
{
using (Start())
{
var text = "First line\nSecond line\nThird line";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
using var cache = new TextRunCache();
// Format all paragraphs.
var lines = FormatAllLines(formatter, textSource, double.PositiveInfinity,
paragraphProperties, cache);
Assert.True(lines.Length >= 3);
// Format again from cache with different width.
var lines2 = FormatAllLines(formatter, textSource, double.PositiveInfinity,
paragraphProperties, cache);
Assert.Equal(lines.Length, lines2.Length);
for (int i = 0; i < lines.Length; i++)
{
Assert.Equal(lines[i].Length, lines2[i].Length);
}
}
}
[Fact]
public void TextLayout_With_Cache_Matches_Without()
{
using (Start())
{
var text = "The quick brown fox jumps over the lazy dog";
// Layout without cache.
var layout1 = new TextLayout(text, Typeface.Default, 12,
textWrapping: TextWrapping.Wrap, maxWidth: 100);
// Layout with cache.
using var cache = new TextRunCache();
var layout2 = new TextLayout(text, Typeface.Default, 12,
textWrapping: TextWrapping.Wrap, maxWidth: 100, textRunCache: cache);
Assert.Equal(layout1.TextLines.Count, layout2.TextLines.Count);
Assert.Equal(layout1.Height, layout2.Height);
Assert.Equal(layout1.WidthIncludingTrailingWhitespace,
layout2.WidthIncludingTrailingWhitespace);
// Second layout from cache with different width.
var layout3 = new TextLayout(text, Typeface.Default, 12,
textWrapping: TextWrapping.Wrap, maxWidth: 80, textRunCache: cache);
// Should still be valid (more lines due to narrower width).
Assert.True(layout3.TextLines.Count >= layout2.TextLines.Count);
Assert.True(layout3.Height > 0);
layout1.Dispose();
layout2.Dispose();
layout3.Dispose();
}
}
private static TextLine[] FormatAllLines(TextFormatterImpl formatter, ITextSource textSource,
double paragraphWidth, TextParagraphProperties paragraphProperties, TextRunCache? cache)
{
var lines = new System.Collections.Generic.List<TextLine>();
var currentIndex = 0;
TextLine? previousLine = null;
while (true)
{
var line = formatter.FormatLine(textSource, currentIndex, paragraphWidth,
paragraphProperties, previousLine?.TextLineBreak, cache);
if (line == null)
{
break;
}
lines.Add(line);
currentIndex += line.Length;
previousLine = line;
if (line.TextLineBreak?.TextEndOfLine is TextEndOfParagraph)
{
break;
}
}
return lines.ToArray();
}
private static IDisposable Start()
{
return UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
fontManagerImpl: new CustomFontManagerImpl()));
}
}
}
Loading…
Cancel
Save