committed by
GitHub
9 changed files with 1008 additions and 30 deletions
@ -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; |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue