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()));
+ }
+ }
+}