Browse Source

Merge pull request #10068 from MrJul/textlayout-wrapping-perf

Improved text wrapping performance
pull/10155/head
Benedikt Stebner 3 years ago
committed by GitHub
parent
commit
d697522bc5
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 16
      src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
  2. 120
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  3. 17
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  4. 15
      src/Avalonia.Base/Media/TextFormatting/TextLineBreak.cs
  5. 8
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  6. 30
      src/Avalonia.Base/Media/TextFormatting/WrappingTextLineBreak.cs
  7. 2
      tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs
  8. 5
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs

16
src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs

@ -15,9 +15,7 @@ namespace Avalonia.Media.TextFormatting
public override void Justify(TextLine textLine)
{
var lineImpl = textLine as TextLineImpl;
if(lineImpl is null)
if (textLine is not TextLineImpl lineImpl)
{
return;
}
@ -34,14 +32,9 @@ namespace Avalonia.Media.TextFormatting
return;
}
var textLineBreak = lineImpl.TextLineBreak;
if (textLineBreak is not null && textLineBreak.TextEndOfLine is not null)
if (lineImpl.TextLineBreak is { TextEndOfLine: not null, IsSplit: false })
{
if (textLineBreak.RemainingRuns is null || textLineBreak.RemainingRuns.Count == 0)
{
return;
}
return;
}
var breakOportunities = new Queue<int>();
@ -107,7 +100,8 @@ namespace Avalonia.Media.TextFormatting
var glyphIndex = glyphRun.FindGlyphIndex(characterIndex);
var glyphInfo = shapedBuffer.GlyphInfos[glyphIndex];
shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex, glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex,
glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
}
glyphRun.GlyphInfos = shapedBuffer.GlyphInfos;

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

@ -2,7 +2,6 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
@ -22,68 +21,55 @@ namespace Avalonia.Media.TextFormatting
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null)
{
var textWrapping = paragraphProperties.TextWrapping;
FlowDirection resolvedFlowDirection;
TextLineBreak? nextLineBreak = null;
IReadOnlyList<TextRun>? textRuns;
var objectPool = FormattingObjectPool.Instance;
var fontManager = FontManager.Current;
var fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex, objectPool,
out var textEndOfLine, out var textSourceLength);
// we've wrapped the previous line and need to continue wrapping: ignore the textSource and do that instead
if (previousLineBreak is WrappingTextLineBreak wrappingTextLineBreak
&& wrappingTextLineBreak.AcquireRemainingRuns() is { } remainingRuns
&& paragraphProperties.TextWrapping != TextWrapping.NoWrap)
{
return PerformTextWrapping(remainingRuns, true, firstTextSourceIndex, paragraphWidth,
paragraphProperties, previousLineBreak.FlowDirection, previousLineBreak, objectPool);
}
RentedList<TextRun>? fetchedRuns = null;
RentedList<TextRun>? shapedTextRuns = null;
try
{
if (previousLineBreak?.RemainingRuns is { } remainingRuns)
{
resolvedFlowDirection = previousLineBreak.FlowDirection;
textRuns = remainingRuns;
nextLineBreak = previousLineBreak;
shapedTextRuns = null;
}
else
{
shapedTextRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, objectPool, fontManager,
out resolvedFlowDirection);
textRuns = shapedTextRuns;
fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex, objectPool, out var textEndOfLine,
out var textSourceLength);
if (nextLineBreak == null && textEndOfLine != null)
{
nextLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection);
}
}
shapedTextRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, objectPool, fontManager,
out var resolvedFlowDirection);
TextLineImpl textLine;
if (nextLineBreak == null && textEndOfLine != null)
{
nextLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection);
}
switch (textWrapping)
switch (paragraphProperties.TextWrapping)
{
case TextWrapping.NoWrap:
{
// perf note: if textRuns comes from remainingRuns above, it's very likely coming from this class
// which already uses an array: ToArray() won't ever be called in this case
var textRunArray = textRuns as TextRun[] ?? textRuns.ToArray();
textLine = new TextLineImpl(textRunArray, firstTextSourceIndex, textSourceLength,
var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex,
textSourceLength,
paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak);
textLine.FinalizeLine();
break;
return textLine;
}
case TextWrapping.WrapWithOverflow:
case TextWrapping.Wrap:
{
textLine = PerformTextWrapping(textRuns, firstTextSourceIndex, paragraphWidth,
paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool, fontManager);
break;
return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth,
paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool);
}
default:
throw new ArgumentOutOfRangeException(nameof(textWrapping));
throw new ArgumentOutOfRangeException(nameof(paragraphProperties.TextWrapping));
}
return textLine;
}
finally
{
@ -108,15 +94,16 @@ namespace Avalonia.Media.TextFormatting
for (var i = 0; i < textRuns.Count; i++)
{
var currentRun = textRuns[i];
var currentRunLength = currentRun.Length;
if (currentLength + currentRun.Length < length)
if (currentLength + currentRunLength < length)
{
currentLength += currentRun.Length;
currentLength += currentRunLength;
continue;
}
var firstCount = currentRun.Length >= 1 ? i + 1 : i;
var firstCount = currentRunLength >= 1 ? i + 1 : i;
if (firstCount > 1)
{
@ -128,13 +115,13 @@ namespace Avalonia.Media.TextFormatting
var secondCount = textRuns.Count - firstCount;
if (currentLength + currentRun.Length == length)
if (currentLength + currentRunLength == length)
{
var second = secondCount > 0 ? objectPool.TextRunLists.Rent() : null;
if (second != null)
{
var offset = currentRun.Length >= 1 ? 1 : 0;
var offset = currentRunLength >= 1 ? 1 : 0;
for (var j = 0; j < secondCount; j++)
{
@ -653,7 +640,7 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
/// <returns>The empty text line.</returns>
public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, FontManager fontManager)
TextParagraphProperties paragraphProperties)
{
var flowDirection = paragraphProperties.FlowDirection;
var properties = paragraphProperties.DefaultTextRunProperties;
@ -675,21 +662,21 @@ namespace Avalonia.Media.TextFormatting
/// Performs text wrapping returns a list of text lines.
/// </summary>
/// <param name="textRuns"></param>
/// <param name="canReuseTextRunList">Whether <see cref="textRuns"/> can be reused to store the split runs.</param>
/// <param name="firstTextSourceIndex">The first text source index.</param>
/// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="paragraphProperties">The text paragraph properties.</param>
/// <param name="resolvedFlowDirection"></param>
/// <param name="currentLineBreak">The current line break if the line was explicitly broken.</param>
/// <param name="objectPool">A pool used to get reusable formatting objects.</param>
/// <param name="fontManager">The font manager to use.</param>
/// <returns>The wrapped text line.</returns>
private static TextLineImpl PerformTextWrapping(IReadOnlyList<TextRun> textRuns, int firstTextSourceIndex,
double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection resolvedFlowDirection,
TextLineBreak? currentLineBreak, FormattingObjectPool objectPool, FontManager fontManager)
private static TextLineImpl PerformTextWrapping(List<TextRun> textRuns, bool canReuseTextRunList,
int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties,
FlowDirection resolvedFlowDirection, TextLineBreak? currentLineBreak, FormattingObjectPool objectPool)
{
if (textRuns.Count == 0)
{
return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties, fontManager);
return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties);
}
if (!TryMeasureLength(textRuns, paragraphWidth, out var measuredLength))
@ -819,13 +806,37 @@ namespace Avalonia.Media.TextFormatting
try
{
var textLineBreak = postSplitRuns?.Count > 0 ?
new TextLineBreak(null, resolvedFlowDirection, postSplitRuns.ToArray()) :
null;
TextLineBreak? textLineBreak;
if (postSplitRuns?.Count > 0)
{
List<TextRun> remainingRuns;
// reuse the list as much as possible:
// if canReuseTextRunList == true it's coming from previous remaining runs
if (canReuseTextRunList)
{
remainingRuns = textRuns;
remainingRuns.Clear();
}
else
{
remainingRuns = new List<TextRun>();
}
if (textLineBreak is null && currentLineBreak?.TextEndOfLine != null)
for (var i = 0; i < postSplitRuns.Count; ++i)
{
remainingRuns.Add(postSplitRuns[i]);
}
textLineBreak = new WrappingTextLineBreak(null, resolvedFlowDirection, remainingRuns);
}
else if (currentLineBreak?.TextEndOfLine is { } textEndOfLine)
{
textLineBreak = new TextLineBreak(currentLineBreak.TextEndOfLine, resolvedFlowDirection);
textLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection);
}
else
{
textLineBreak = null;
}
var textLine = new TextLineImpl(preSplitRuns.ToArray(), firstTextSourceIndex, measuredLength,
@ -833,6 +844,7 @@ namespace Avalonia.Media.TextFormatting
textLineBreak);
textLine.FinalizeLine();
return textLine;
}
finally

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

@ -416,9 +416,11 @@ namespace Avalonia.Media.TextFormatting
width = lineWidth;
}
if (left > textLine.Start)
var start = textLine.Start;
if (left > start)
{
left = textLine.Start;
left = start;
}
height += textLine.Height;
@ -427,12 +429,10 @@ namespace Avalonia.Media.TextFormatting
private TextLine[] CreateTextLines()
{
var objectPool = FormattingObjectPool.Instance;
var fontManager = FontManager.Current;
if (MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight))
{
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties,
fontManager);
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties);
Bounds = new Rect(0, 0, 0, textLine.Height);
@ -461,7 +461,7 @@ namespace Avalonia.Media.TextFormatting
if (previousLine != null && previousLine.NewLineLength > 0)
{
var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth,
_paragraphProperties, fontManager);
_paragraphProperties);
textLines.Add(emptyTextLine);
@ -504,7 +504,7 @@ namespace Avalonia.Media.TextFormatting
//Fulfill max lines constraint
if (MaxLines > 0 && textLines.Count >= MaxLines)
{
if (textLine.TextLineBreak?.RemainingRuns is not null)
if (textLine.TextLineBreak is { IsSplit: true })
{
textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(width));
}
@ -521,8 +521,7 @@ namespace Avalonia.Media.TextFormatting
//Make sure the TextLayout always contains at least on empty line
if (textLines.Count == 0)
{
var textLine =
TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties, fontManager);
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties);
textLines.Add(textLine);

15
src/Avalonia.Base/Media/TextFormatting/TextLineBreak.cs

@ -1,15 +1,13 @@
using System.Collections.Generic;
namespace Avalonia.Media.TextFormatting
namespace Avalonia.Media.TextFormatting
{
public class TextLineBreak
{
public TextLineBreak(TextEndOfLine? textEndOfLine = null, FlowDirection flowDirection = FlowDirection.LeftToRight,
IReadOnlyList<TextRun>? remainingRuns = null)
public TextLineBreak(TextEndOfLine? textEndOfLine = null,
FlowDirection flowDirection = FlowDirection.LeftToRight, bool isSplit = false)
{
TextEndOfLine = textEndOfLine;
FlowDirection = flowDirection;
RemainingRuns = remainingRuns;
IsSplit = isSplit;
}
/// <summary>
@ -23,8 +21,9 @@ namespace Avalonia.Media.TextFormatting
public FlowDirection FlowDirection { get; }
/// <summary>
/// Get the remaining runs that were split up by the <see cref="TextFormatter"/> during the formatting process.
/// Gets whether there were remaining runs after this line break,
/// that were split up by the <see cref="TextFormatter"/> during the formatting process.
/// </summary>
public IReadOnlyList<TextRun>? RemainingRuns { get; }
public bool IsSplit { get; }
}
}

8
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@ -1285,13 +1285,11 @@ namespace Avalonia.Media.TextFormatting
{
case ShapedTextRun textRun:
{
var properties = textRun.Properties;
var textMetrics =
new TextMetrics(properties.CachedGlyphTypeface, properties.FontRenderingEmSize);
var textMetrics = textRun.TextMetrics;
if (fontRenderingEmSize < properties.FontRenderingEmSize)
if (fontRenderingEmSize < textMetrics.FontRenderingEmSize)
{
fontRenderingEmSize = properties.FontRenderingEmSize;
fontRenderingEmSize = textMetrics.FontRenderingEmSize;
if (ascent > textMetrics.Ascent)
{

30
src/Avalonia.Base/Media/TextFormatting/WrappingTextLineBreak.cs

@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Diagnostics;
namespace Avalonia.Media.TextFormatting
{
/// <summary>Represents a line break that occurred due to wrapping.</summary>
internal sealed class WrappingTextLineBreak : TextLineBreak
{
private List<TextRun>? _remainingRuns;
public WrappingTextLineBreak(TextEndOfLine? textEndOfLine, FlowDirection flowDirection,
List<TextRun> remainingRuns)
: base(textEndOfLine, flowDirection, isSplit: true)
{
Debug.Assert(remainingRuns.Count > 0);
_remainingRuns = remainingRuns;
}
/// <summary>
/// Gets the remaining runs from this line break, and clears them from this line break.
/// </summary>
/// <returns>A list of text runs.</returns>
public List<TextRun>? AcquireRemainingRuns()
{
var remainingRuns = _remainingRuns;
_remainingRuns = null;
return remainingRuns;
}
}
}

2
tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs

@ -15,7 +15,7 @@ namespace Avalonia.Benchmarks.Text;
public class HugeTextLayout : IDisposable
{
private static readonly Random s_rand = new();
private static readonly bool s_useSkia = true;
private static readonly bool s_useSkia = false;
private readonly IDisposable _app;
private readonly string[] _manySmallStrings;

5
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs

@ -558,7 +558,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textLine =
formatter.FormatLine(textSource, 0, 33, paragraphProperties);
Assert.NotNull(textLine.TextLineBreak?.RemainingRuns);
var remainingRunsLineBreak = Assert.IsType<WrappingTextLineBreak>(textLine.TextLineBreak);
var remainingRuns = remainingRunsLineBreak.AcquireRemainingRuns();
Assert.NotNull(remainingRuns);
Assert.NotEmpty(remainingRuns);
}
}

Loading…
Cancel
Save