diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs index 71640fac08..4b5e4cad84 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs @@ -37,6 +37,11 @@ namespace Avalonia.Media.TextFormatting /// public int Length => _glyphInfos.Length; + /// + /// The buffer's glyph infos. + /// + internal ArraySlice GlyphInfos => _glyphInfos; + /// /// The buffer's glyph typeface. /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs index ed60dd44a0..27d1398648 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs +++ b/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); + /// + /// Formats a text line with an optional to avoid redundant shaping + /// when only the paragraph width changes. + /// + /// The text source. + /// The first character index to start the text line from. + /// A value that specifies the width of the paragraph that the line fills. + /// A value that represents paragraph properties, + /// such as TextWrapping, TextAlignment, or TextStyle. + /// A value that specifies the text formatter state, + /// in terms of where the previous line in the paragraph was broken by the text formatting process. + /// A that caches shaped text runs. + /// The formatted line. + public virtual TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, + TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak, + TextRunCache? textRunCache) + { + return FormatLine(textSource, firstTextSourceIndex, paragraphWidth, + paragraphProperties, previousLineBreak); + } + /// /// Creates a shaped symbol. /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index fb6777c659..8af3470dd1 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/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; - /// + /// public override TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null) + { + return FormatLine(textSource, firstTextSourceIndex, paragraphWidth, + paragraphProperties, previousLineBreak, null); + } + + /// + 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? fetchedRuns = null; RentedList? 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 } } + /// + /// Formats a line from cached shaped runs, skipping shaping and bidi processing. + /// + 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)); + } + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + private static TextRun[] CreateNonOwningRuns(IReadOnlyList runs) + { + var result = new TextRun[runs.Count]; + + for (var i = 0; i < runs.Count; i++) + { + result[i] = CreateNonOwningRun(runs[i]); + } + + return result; + } + + /// + /// Creates a non-owning list of shaped text runs for use with text wrapping. + /// + private static List CreateNonOwningRunsList(IReadOnlyList runs) + { + var result = new List(runs.Count); + + for (var i = 0; i < runs.Count; i++) + { + result.Add(CreateNonOwningRun(runs[i])); + } + + return result; + } + /// /// Split a sequence of runs into two segments at specified length. /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index 802d6ebb9d..0e7a624d17 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/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 /// The maximum number of text lines. /// Optional list of turned on/off features. /// The text style overrides. + /// An optional cache for shaped text runs to avoid redundant shaping. public TextLayout( string? text, Typeface typeface, @@ -52,7 +54,8 @@ namespace Avalonia.Media.TextFormatting double letterSpacing = 0, int maxLines = 0, FontFeatureCollection? fontFeatures = null, - IReadOnlyList>? textStyleOverrides = null) + IReadOnlyList>? 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(); } + /// + /// Initializes a new instance of the class. + /// + /// + /// This overload is provided for binary compatibility. New code should use the overload that accepts a . + /// + 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>? textStyleOverrides) + : this(text, typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming, + textDecorations, flowDirection, maxWidth, maxHeight, lineHeight, letterSpacing, + maxLines, fontFeatures, textStyleOverrides, null) + { + } + /// /// Initializes a new instance of the class. /// @@ -80,13 +114,15 @@ namespace Avalonia.Media.TextFormatting /// The maximum width. /// The maximum height. /// The maximum number of text lines. + /// An optional cache for shaped text runs to avoid redundant shaping. 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(); } + /// + /// Initializes a new instance of the class. + /// + /// + /// This overload is provided for binary compatibility. New code should use the overload that accepts a . + /// + public TextLayout( + ITextSource textSource, + TextParagraphProperties paragraphProperties, + TextTrimming? textTrimming, + double maxWidth, + double maxHeight, + int maxLines) + : this(textSource, paragraphProperties, textTrimming, maxWidth, maxHeight, maxLines, null) + { + } + /// /// Gets or sets the height of each line of text. /// @@ -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) { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs b/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs new file mode 100644 index 0000000000..8dc21fa387 --- /dev/null +++ b/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 +{ + /// + /// 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). + /// + /// + /// 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. + /// + [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? _entries; + + /// + /// Invalidates all cached entries and disposes their shaped buffers. + /// + 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(); + } + + /// + /// Invalidates all cached entries at or after the specified text source index. + /// + /// The text source index from which to invalidate. + 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 keysToRemove = count <= 16 + ? stackalloc int[16] + : (rented = ArrayPool.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.Shared.Return(rented); + } + } + + /// + /// Tries to retrieve cached shaped runs for the given text source index. + /// + 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; + } + + /// + /// Adds shaped runs to the cache for the given text source index. + /// + 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 + { + { _singleKey, _singleValue }, + { firstTextSourceIndex, result } + }; + _hasSingleEntry = false; + _singleValue = default; + } + + /// + 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(); + } + } + } + } + + /// + /// Stores the result of text shaping for a paragraph segment starting at a given text source index. + /// + internal readonly struct CachedShapingResult + { + public CachedShapingResult(TextRun[] shapedRuns, FlowDirection resolvedFlowDirection, + TextEndOfLine? textEndOfLine, int textSourceLength) + { + ShapedRuns = shapedRuns; + ResolvedFlowDirection = resolvedFlowDirection; + TextEndOfLine = textEndOfLine; + TextSourceLength = textSourceLength; + } + + /// + /// The shaped text runs (output of ShapeTextRuns). + /// + public readonly TextRun[] ShapedRuns; + + /// + /// The resolved flow direction for the paragraph. + /// + public readonly FlowDirection ResolvedFlowDirection; + + /// + /// The end of line marker, if any. + /// + public readonly TextEndOfLine? TextEndOfLine; + + /// + /// The total text source length consumed. + /// + public readonly int TextSourceLength; + } +} diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index b7d52e9894..598ce4905c 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/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; } } diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 5c798f6a83..4d1153dfa3 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/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? _textRuns; private InlineCollection? _inlines; @@ -690,13 +691,25 @@ namespace Avalonia.Controls TextTrimming, maxSize.Width, maxSize.Height, - MaxLines); + MaxLines, + _textRunCache ??= new TextRunCache()); } /// - /// Invalidates . + /// Invalidates and the . /// protected void InvalidateTextLayout() + { + _textRunCache?.Invalidate(); + InvalidateVisual(); + InvalidateMeasure(); + } + + /// + /// Invalidates while preserving the . + /// Use when only layout-affecting properties change (e.g., TextWrapping, TextAlignment). + /// + 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 IInlineHost.VisualChildren => VisualChildren; diff --git a/tests/Avalonia.Benchmarks/Text/TextRunCacheBenchmark.cs b/tests/Avalonia.Benchmarks/Text/TextRunCacheBenchmark.cs new file mode 100644 index 0000000000..650c919847 --- /dev/null +++ b/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); + } + } + + /// + /// Benchmarks the single-entry fast path: a simple single-paragraph text + /// that results in only one cache entry (the common case for TextBlock). + /// + [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); + } + } + + /// + /// Benchmarks the single-entry fast path with invalidate/re-populate cycle, + /// verifying that the inline store is reused without dictionary allocation. + /// + [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(); + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextRunCacheTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextRunCacheTests.cs new file mode 100644 index 0000000000..e8a2a78fa3 --- /dev/null +++ b/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(); + 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())); + } + } +}