Browse Source

Merge pull request #7946 from Gillibald/feature/InlineUIContainer

InlineUIContainer
pull/8066/head
Max Katz 4 years ago
committed by GitHub
parent
commit
cba8054304
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 79
      src/Avalonia.Base/Media/GlyphRun.cs
  2. 4
      src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs
  3. 65
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  4. 119
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  5. 118
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  6. 5
      src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs
  7. 11
      src/Avalonia.Controls/Documents/IInlineHost.cs
  8. 9
      src/Avalonia.Controls/Documents/Inline.cs
  9. 46
      src/Avalonia.Controls/Documents/InlineCollection.cs
  10. 125
      src/Avalonia.Controls/Documents/InlineUIContainer.cs
  11. 20
      src/Avalonia.Controls/Documents/LineBreak.cs
  12. 19
      src/Avalonia.Controls/Documents/Run.cs
  13. 46
      src/Avalonia.Controls/Documents/Span.cs
  14. 15
      src/Avalonia.Controls/Documents/TextElement.cs
  15. 2
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  16. 139
      src/Avalonia.Controls/TextBlock.cs
  17. 3
      src/Skia/Avalonia.Skia/TextShaperImpl.cs
  18. 3
      tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs
  19. 119
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

79
src/Avalonia.Base/Media/GlyphRun.cs

@ -28,6 +28,8 @@ namespace Avalonia.Media
private IReadOnlyList<Vector>? _glyphOffsets; private IReadOnlyList<Vector>? _glyphOffsets;
private IReadOnlyList<int>? _glyphClusters; private IReadOnlyList<int>? _glyphClusters;
private int _offsetToFirstCharacter;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="GlyphRun"/> class by specifying properties of the class. /// Initializes a new instance of the <see cref="GlyphRun"/> class by specifying properties of the class.
/// </summary> /// </summary>
@ -49,7 +51,7 @@ namespace Avalonia.Media
IReadOnlyList<int>? glyphClusters = null, IReadOnlyList<int>? glyphClusters = null,
int biDiLevel = 0) int biDiLevel = 0)
{ {
_glyphTypeface = glyphTypeface; _glyphTypeface = glyphTypeface;
FontRenderingEmSize = fontRenderingEmSize; FontRenderingEmSize = fontRenderingEmSize;
@ -203,8 +205,8 @@ namespace Avalonia.Media
/// </returns> /// </returns>
public double GetDistanceFromCharacterHit(CharacterHit characterHit) public double GetDistanceFromCharacterHit(CharacterHit characterHit)
{ {
var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength - _offsetToFirstCharacter;
var distance = 0.0; var distance = 0.0;
if (IsLeftToRight) if (IsLeftToRight)
@ -223,7 +225,7 @@ namespace Avalonia.Media
} }
var glyphIndex = FindGlyphIndex(characterIndex); var glyphIndex = FindGlyphIndex(characterIndex);
if (GlyphClusters != null) if (GlyphClusters != null)
{ {
var currentCluster = GlyphClusters[glyphIndex]; var currentCluster = GlyphClusters[glyphIndex];
@ -249,7 +251,7 @@ namespace Avalonia.Media
{ {
//RightToLeft //RightToLeft
var glyphIndex = FindGlyphIndex(characterIndex); var glyphIndex = FindGlyphIndex(characterIndex);
if (GlyphClusters != null) if (GlyphClusters != null)
{ {
if (characterIndex > GlyphClusters[0]) if (characterIndex > GlyphClusters[0])
@ -284,13 +286,13 @@ namespace Avalonia.Media
public CharacterHit GetCharacterHitFromDistance(double distance, out bool isInside) public CharacterHit GetCharacterHitFromDistance(double distance, out bool isInside)
{ {
var characterIndex = 0; var characterIndex = 0;
// Before // Before
if (distance <= 0) if (distance <= 0)
{ {
isInside = false; isInside = false;
if(GlyphClusters != null) if (GlyphClusters != null)
{ {
characterIndex = GlyphClusters[characterIndex]; characterIndex = GlyphClusters[characterIndex];
} }
@ -307,11 +309,11 @@ namespace Avalonia.Media
characterIndex = GlyphIndices.Count - 1; characterIndex = GlyphIndices.Count - 1;
if(GlyphClusters != null) if (GlyphClusters != null)
{ {
characterIndex = GlyphClusters[characterIndex]; characterIndex = GlyphClusters[characterIndex];
} }
var lastCharacterHit = FindNearestCharacterHit(characterIndex, out _); var lastCharacterHit = FindNearestCharacterHit(characterIndex, out _);
return IsLeftToRight ? lastCharacterHit : new CharacterHit(lastCharacterHit.FirstCharacterIndex); return IsLeftToRight ? lastCharacterHit : new CharacterHit(lastCharacterHit.FirstCharacterIndex);
@ -327,7 +329,7 @@ namespace Avalonia.Media
var advance = GetGlyphAdvance(index, out var cluster); var advance = GetGlyphAdvance(index, out var cluster);
characterIndex = cluster; characterIndex = cluster;
if (distance > currentX && distance <= currentX + advance) if (distance > currentX && distance <= currentX + advance)
{ {
break; break;
@ -345,7 +347,7 @@ namespace Avalonia.Media
var advance = GetGlyphAdvance(index, out var cluster); var advance = GetGlyphAdvance(index, out var cluster);
characterIndex = cluster; characterIndex = cluster;
if (currentX - advance < distance) if (currentX - advance < distance)
{ {
break; break;
@ -552,20 +554,20 @@ namespace Avalonia.Media
} }
nextCluster = GlyphClusters[currentIndex]; nextCluster = GlyphClusters[currentIndex];
} }
int trailingLength; int trailingLength;
if (nextCluster == cluster) if (nextCluster == cluster)
{ {
trailingLength = Characters.Start + Characters.Length - cluster; trailingLength = Characters.Start + Characters.Length - _offsetToFirstCharacter - cluster;
} }
else else
{ {
trailingLength = nextCluster - cluster; trailingLength = nextCluster - cluster;
} }
return new CharacterHit(cluster, trailingLength); return new CharacterHit(_offsetToFirstCharacter + cluster, trailingLength);
} }
/// <summary> /// <summary>
@ -577,7 +579,7 @@ namespace Avalonia.Media
private double GetGlyphAdvance(int index, out int cluster) private double GetGlyphAdvance(int index, out int cluster)
{ {
cluster = GlyphClusters != null ? GlyphClusters[index] : index; cluster = GlyphClusters != null ? GlyphClusters[index] : index;
if (GlyphAdvances != null) if (GlyphAdvances != null)
{ {
return GlyphAdvances[index]; return GlyphAdvances[index];
@ -599,11 +601,18 @@ namespace Avalonia.Media
private GlyphRunMetrics CreateGlyphRunMetrics() private GlyphRunMetrics CreateGlyphRunMetrics()
{ {
if (GlyphClusters != null && GlyphClusters.Count > 0)
{
var firstCluster = GlyphClusters[0];
_offsetToFirstCharacter = Math.Max(0, Characters.Start - firstCluster);
}
var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale; var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale;
var widthIncludingTrailingWhitespace = 0d; var widthIncludingTrailingWhitespace = 0d;
var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength, out var glyphCount); var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength, out var glyphCount);
for (var index = 0; index < GlyphIndices.Count; index++) for (var index = 0; index < GlyphIndices.Count; index++)
{ {
var advance = GetGlyphAdvance(index, out _); var advance = GetGlyphAdvance(index, out _);
@ -615,7 +624,7 @@ namespace Avalonia.Media
if (IsLeftToRight) if (IsLeftToRight)
{ {
for (var index = GlyphIndices.Count - glyphCount; index <GlyphIndices.Count; index++) for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++)
{ {
width -= GetGlyphAdvance(index, out _); width -= GetGlyphAdvance(index, out _);
} }
@ -670,34 +679,40 @@ namespace Avalonia.Media
{ {
for (var i = GlyphClusters.Count - 1; i >= 0; i--) for (var i = GlyphClusters.Count - 1; i >= 0; i--)
{ {
var cluster = GlyphClusters[i]; var currentCluster = GlyphClusters[i];
var characterIndex = Math.Max(0, currentCluster - _characters.BufferOffset);
var codepoint = Codepoint.ReadAt(_characters, characterIndex, out _);
var codepointIndex = IsLeftToRight ? cluster - _characters.Start : _characters.End - cluster; if (!codepoint.IsWhiteSpace)
if (codepointIndex < 0)
{ {
trailingWhitespaceLength = _characters.Length;
glyphCount = GlyphClusters.Count;
break; break;
} }
var codepoint = Codepoint.ReadAt(_characters, codepointIndex, out _);
if (!codepoint.IsWhiteSpace) var clusterLength = 1;
while(i - 1 >= 0)
{ {
var nextCluster = GlyphClusters[i - 1];
if(currentCluster == nextCluster)
{
clusterLength++;
i--;
continue;
}
break; break;
} }
if (codepoint.IsBreakChar) if (codepoint.IsBreakChar)
{ {
newLineLength++; newLineLength += clusterLength;
} }
trailingWhitespaceLength++; trailingWhitespaceLength += clusterLength;
glyphCount++; glyphCount++;
} }
} }

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

@ -1,6 +1,4 @@
using Avalonia.Media.TextFormatting.Unicode; namespace Avalonia.Media.TextFormatting
namespace Avalonia.Media.TextFormatting
{ {
/// <summary> /// <summary>
/// Represents a base class for text formatting. /// Represents a base class for text formatting.

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

@ -8,6 +8,8 @@ namespace Avalonia.Media.TextFormatting
{ {
internal class TextFormatterImpl : TextFormatter internal class TextFormatterImpl : TextFormatter
{ {
private static readonly char[] s_empty = { ' ' };
/// <inheritdoc cref="TextFormatter.FormatLine"/> /// <inheritdoc cref="TextFormatter.FormatLine"/>
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null) TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null)
@ -77,14 +79,14 @@ namespace Avalonia.Media.TextFormatting
{ {
var currentRun = textRuns[i]; var currentRun = textRuns[i];
if (currentLength + currentRun.Text.Length < length) if (currentLength + currentRun.TextSourceLength < length)
{ {
currentLength += currentRun.TextSourceLength; currentLength += currentRun.TextSourceLength;
continue; continue;
} }
var firstCount = currentRun.Text.Length >= 1 ? i + 1 : i; var firstCount = currentRun.TextSourceLength >= 1 ? i + 1 : i;
var first = new List<DrawableTextRun>(firstCount); var first = new List<DrawableTextRun>(firstCount);
@ -98,13 +100,13 @@ namespace Avalonia.Media.TextFormatting
var secondCount = textRuns.Count - firstCount; var secondCount = textRuns.Count - firstCount;
if (currentLength + currentRun.Text.Length == length) if (currentLength + currentRun.TextSourceLength == length)
{ {
var second = secondCount > 0 ? new List<DrawableTextRun>(secondCount) : null; var second = secondCount > 0 ? new List<DrawableTextRun>(secondCount) : null;
if (second != null) if (second != null)
{ {
var offset = currentRun.Text.Length >= 1 ? 1 : 0; var offset = currentRun.TextSourceLength >= 1 ? 1 : 0;
for (var j = 0; j < secondCount; j++) for (var j = 0; j < secondCount; j++)
{ {
@ -122,16 +124,14 @@ namespace Avalonia.Media.TextFormatting
var second = new List<DrawableTextRun>(secondCount); var second = new List<DrawableTextRun>(secondCount);
if (currentRun is not ShapedTextCharacters shapedTextCharacters) if (currentRun is ShapedTextCharacters shapedTextCharacters)
{ {
throw new NotSupportedException("Only shaped runs can be split in between."); var split = shapedTextCharacters.Split(length - currentLength);
}
var split = shapedTextCharacters.Split(length - currentLength);
first.Add(split.First); first.Add(split.First);
second.Add(split.Second!); second.Add(split.Second!);
}
for (var j = 1; j < secondCount; j++) for (var j = 1; j < secondCount; j++)
{ {
@ -267,7 +267,6 @@ namespace Avalonia.Media.TextFormatting
IReadOnlyList<ShapeableTextCharacters> textRuns, ReadOnlySlice<char> text, TextShaperOptions options) IReadOnlyList<ShapeableTextCharacters> textRuns, ReadOnlySlice<char> text, TextShaperOptions options)
{ {
var shapedRuns = new List<ShapedTextCharacters>(textRuns.Count); var shapedRuns = new List<ShapedTextCharacters>(textRuns.Count);
var firstRun = textRuns[0];
var shapedBuffer = TextShaper.Current.ShapeText(text, options); var shapedBuffer = TextShaper.Current.ShapeText(text, options);
@ -471,11 +470,10 @@ namespace Avalonia.Media.TextFormatting
return false; return false;
} }
private static bool TryMeasureLength(IReadOnlyList<DrawableTextRun> textRuns, int firstTextSourceIndex, double paragraphWidth, out int measuredLength) private static bool TryMeasureLength(IReadOnlyList<DrawableTextRun> textRuns, double paragraphWidth, out int measuredLength)
{ {
measuredLength = 0; measuredLength = 0;
var currentWidth = 0.0; var currentWidth = 0.0;
var lastCluster = firstTextSourceIndex;
foreach (var currentRun in textRuns) foreach (var currentRun in textRuns)
{ {
@ -483,12 +481,17 @@ namespace Avalonia.Media.TextFormatting
{ {
case ShapedTextCharacters shapedTextCharacters: case ShapedTextCharacters shapedTextCharacters:
{ {
var firstCluster = shapedTextCharacters.ShapedBuffer.GlyphClusters[0];
var lastCluster = firstCluster;
for (var i = 0; i < shapedTextCharacters.ShapedBuffer.Length; i++) for (var i = 0; i < shapedTextCharacters.ShapedBuffer.Length; i++)
{ {
var glyphInfo = shapedTextCharacters.ShapedBuffer[i]; var glyphInfo = shapedTextCharacters.ShapedBuffer[i];
if (currentWidth + glyphInfo.GlyphAdvance > paragraphWidth) if (currentWidth + glyphInfo.GlyphAdvance > paragraphWidth)
{ {
measuredLength += Math.Max(0, lastCluster - firstCluster);
goto found; goto found;
} }
@ -496,6 +499,8 @@ namespace Avalonia.Media.TextFormatting
currentWidth += glyphInfo.GlyphAdvance; currentWidth += glyphInfo.GlyphAdvance;
} }
measuredLength += currentRun.TextSourceLength;
break; break;
} }
@ -506,7 +511,7 @@ namespace Avalonia.Media.TextFormatting
goto found; goto found;
} }
lastCluster += currentRun.TextSourceLength; measuredLength += currentRun.TextSourceLength;
currentWidth += currentRun.Size.Width; currentWidth += currentRun.Size.Width;
break; break;
@ -516,11 +521,30 @@ namespace Avalonia.Media.TextFormatting
found: found:
measuredLength = Math.Max(0, lastCluster - firstTextSourceIndex + 1);
return measuredLength != 0; return measuredLength != 0;
} }
/// <summary>
/// Creates an empty text line.
/// </summary>
/// <returns>The empty text line.</returns>
public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, TextParagraphProperties paragraphProperties)
{
var flowDirection = paragraphProperties.FlowDirection;
var properties = paragraphProperties.DefaultTextRunProperties;
var glyphTypeface = properties.Typeface.GlyphTypeface;
var text = new ReadOnlySlice<char>(s_empty, firstTextSourceIndex, 1);
var glyph = glyphTypeface.GetGlyph(s_empty[0]);
var glyphInfos = new[] { new GlyphInfo(glyph, firstTextSourceIndex) };
var shapedBuffer = new ShapedBuffer(text, glyphInfos, glyphTypeface, properties.FontRenderingEmSize,
(sbyte)flowDirection);
var textRuns = new List<DrawableTextRun> { new ShapedTextCharacters(shapedBuffer, properties) };
return new TextLineImpl(textRuns, firstTextSourceIndex, 0, double.PositiveInfinity, paragraphProperties, flowDirection).FinalizeLine();
}
/// <summary> /// <summary>
/// Performs text wrapping returns a list of text lines. /// Performs text wrapping returns a list of text lines.
/// </summary> /// </summary>
@ -535,7 +559,12 @@ namespace Avalonia.Media.TextFormatting
double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection flowDirection, double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection flowDirection,
TextLineBreak? currentLineBreak) TextLineBreak? currentLineBreak)
{ {
if (!TryMeasureLength(textRuns, firstTextSourceIndex, paragraphWidth, out var measuredLength)) if(textRuns.Count == 0)
{
return CreateEmptyTextLine(firstTextSourceIndex, paragraphProperties);
}
if (!TryMeasureLength(textRuns, paragraphWidth, out var measuredLength))
{ {
measuredLength = 1; measuredLength = 1;
} }

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

@ -10,13 +10,12 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
public class TextLayout public class TextLayout
{ {
private static readonly char[] s_empty = { ' ' }; private readonly ITextSource _textSource;
private readonly ReadOnlySlice<char> _text;
private readonly TextParagraphProperties _paragraphProperties; private readonly TextParagraphProperties _paragraphProperties;
private readonly IReadOnlyList<ValueSpan<TextRunProperties>>? _textStyleOverrides;
private readonly TextTrimming _textTrimming; private readonly TextTrimming _textTrimming;
private int _textSourceLength;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class. /// Initializes a new instance of the <see cref="TextLayout" /> class.
/// </summary> /// </summary>
@ -50,17 +49,49 @@ namespace Avalonia.Media.TextFormatting
int maxLines = 0, int maxLines = 0,
IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides = null) IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides = null)
{ {
_text = string.IsNullOrEmpty(text) ?
new ReadOnlySlice<char>() :
new ReadOnlySlice<char>(text.AsMemory());
_paragraphProperties = _paragraphProperties =
CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping,
textDecorations, flowDirection, lineHeight); textDecorations, flowDirection, lineHeight);
_textSource = new FormattedTextSource(text.AsMemory(), _paragraphProperties.DefaultTextRunProperties, textStyleOverrides);
_textTrimming = textTrimming ?? TextTrimming.None; _textTrimming = textTrimming ?? TextTrimming.None;
_textStyleOverrides = textStyleOverrides; LineHeight = lineHeight;
MaxWidth = maxWidth;
MaxHeight = maxHeight;
MaxLines = maxLines;
TextLines = CreateTextLines();
}
/// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="paragraphProperties">The default text paragraph properties.</param>
/// <param name="textTrimming">The text trimming.</param>
/// <param name="maxWidth">The maximum width.</param>
/// <param name="maxHeight">The maximum height.</param>
/// <param name="lineHeight">The height of each line of text.</param>
/// <param name="maxLines">The maximum number of text lines.</param>
public TextLayout(
ITextSource textSource,
TextParagraphProperties paragraphProperties,
TextTrimming? textTrimming = null,
double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity,
double lineHeight = double.NaN,
int maxLines = 0)
{
_textSource = textSource;
_paragraphProperties = paragraphProperties;
_textTrimming = textTrimming ?? TextTrimming.None;
LineHeight = lineHeight; LineHeight = lineHeight;
@ -147,7 +178,7 @@ namespace Avalonia.Media.TextFormatting
return new Rect(); return new Rect();
} }
if (textPosition < 0 || textPosition >= _text.Length) if (textPosition < 0 || textPosition >= _textSourceLength)
{ {
var lastLine = TextLines[TextLines.Count - 1]; var lastLine = TextLines[TextLines.Count - 1];
@ -273,7 +304,7 @@ namespace Avalonia.Media.TextFormatting
return 0; return 0;
} }
if (charIndex > _text.Length) if (charIndex > _textSourceLength)
{ {
return TextLines.Count - 1; return TextLines.Count - 1;
} }
@ -375,32 +406,11 @@ namespace Avalonia.Media.TextFormatting
height += textLine.Height; height += textLine.Height;
} }
/// <summary>
/// Creates an empty text line.
/// </summary>
/// <returns>The empty text line.</returns>
private TextLine CreateEmptyTextLine(int firstTextSourceIndex)
{
var flowDirection = _paragraphProperties.FlowDirection;
var properties = _paragraphProperties.DefaultTextRunProperties;
var glyphTypeface = properties.Typeface.GlyphTypeface;
var text = new ReadOnlySlice<char>(s_empty, firstTextSourceIndex, 1);
var glyph = glyphTypeface.GetGlyph(s_empty[0]);
var glyphInfos = new[] { new GlyphInfo(glyph, firstTextSourceIndex) };
var shapedBuffer = new ShapedBuffer(text, glyphInfos, glyphTypeface, properties.FontRenderingEmSize,
(sbyte)flowDirection);
var textRuns = new List<DrawableTextRun> { new ShapedTextCharacters(shapedBuffer, properties) };
return new TextLineImpl(textRuns, firstTextSourceIndex, 1, MaxWidth, _paragraphProperties, flowDirection).FinalizeLine();
}
private IReadOnlyList<TextLine> CreateTextLines() private IReadOnlyList<TextLine> CreateTextLines()
{ {
if (_text.IsEmpty || MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight)) if (MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight))
{ {
var textLine = CreateEmptyTextLine(0); var textLine = TextFormatterImpl.CreateEmptyTextLine(0, _paragraphProperties);
Bounds = new Rect(0,0,0, textLine.Height); Bounds = new Rect(0,0,0, textLine.Height);
@ -411,26 +421,30 @@ namespace Avalonia.Media.TextFormatting
double left = double.PositiveInfinity, width = 0.0, height = 0.0; double left = double.PositiveInfinity, width = 0.0, height = 0.0;
var currentPosition = 0; _textSourceLength = 0;
var textSource = new FormattedTextSource(_text,
_paragraphProperties.DefaultTextRunProperties, _textStyleOverrides);
TextLine? previousLine = null; TextLine? previousLine = null;
while (currentPosition < _text.Length) while (true)
{ {
var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth, var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth,
_paragraphProperties, previousLine?.TextLineBreak); _paragraphProperties, previousLine?.TextLineBreak);
#if DEBUG if(textLine == null || textLine.Length == 0)
if (textLine.Length == 0)
{ {
throw new InvalidOperationException($"{nameof(textLine)} should not be empty."); if(previousLine != null && previousLine.NewLineLength > 0)
{
var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, _paragraphProperties);
textLines.Add(emptyTextLine);
UpdateBounds(emptyTextLine, ref left, ref width, ref height);
}
break;
} }
#endif
currentPosition += textLine.Length; _textSourceLength += textLine.Length;
//Fulfill max height constraint //Fulfill max height constraint
if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight) if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight)
@ -464,17 +478,16 @@ namespace Avalonia.Media.TextFormatting
{ {
break; break;
} }
}
if (currentPosition != _text.Length || textLine.NewLineLength <= 0)
{
continue;
}
var emptyTextLine = CreateEmptyTextLine(currentPosition); //Make sure the TextLayout always contains at least on empty line
if(textLines.Count == 0)
{
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, _paragraphProperties);
textLines.Add(emptyTextLine); textLines.Add(textLine);
UpdateBounds(emptyTextLine,ref left, ref width, ref height); UpdateBounds(textLine, ref left, ref width, ref height);
} }
Bounds = new Rect(left, 0, width, height); Bounds = new Rect(left, 0, width, height);

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

@ -183,6 +183,7 @@ namespace Avalonia.Media.TextFormatting
case ShapedTextCharacters shapedRun: case ShapedTextCharacters shapedRun:
{ {
characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _); characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _);
break; break;
} }
default: default:
@ -403,7 +404,7 @@ namespace Avalonia.Media.TextFormatting
var result = new List<TextBounds>(TextRuns.Count); var result = new List<TextBounds>(TextRuns.Count);
var lastDirection = _flowDirection; var lastDirection = _flowDirection;
var currentDirection = lastDirection; var currentDirection = lastDirection;
var currentPosition = 0; var currentPosition = FirstTextSourceIndex;
var currentRect = Rect.Empty; var currentRect = Rect.Empty;
var startX = Start; var startX = Start;
@ -417,6 +418,11 @@ namespace Avalonia.Media.TextFormatting
continue; continue;
} }
if(currentPosition + currentRun.TextSourceLength <= firstTextSourceCharacterIndex)
{
continue;
}
TextRun? nextRun = null; TextRun? nextRun = null;
if (index + 1 < TextRuns.Count) if (index + 1 < TextRuns.Count)
@ -426,31 +432,42 @@ namespace Avalonia.Media.TextFormatting
if (nextRun != null) if (nextRun != null)
{ {
if (nextRun.Text.Start < currentRun.Text.Start && firstTextSourceCharacterIndex + textLength < currentRun.Text.End) switch (nextRun)
{ {
goto skip; case ShapedTextCharacters when currentRun is ShapedTextCharacters:
} {
if (nextRun.Text.Start < currentRun.Text.Start && firstTextSourceCharacterIndex + textLength < currentRun.Text.End)
{
goto skip;
}
if (currentRun.Text.Start >= firstTextSourceCharacterIndex + textLength) if (currentRun.Text.Start >= firstTextSourceCharacterIndex + textLength)
{ {
goto skip; goto skip;
} }
if (currentRun.Text.Start > nextRun.Text.Start && currentRun.Text.Start < firstTextSourceCharacterIndex) if (currentRun.Text.Start > nextRun.Text.Start && currentRun.Text.Start < firstTextSourceCharacterIndex)
{ {
goto skip; goto skip;
} }
if (currentRun.Text.End < firstTextSourceCharacterIndex) if (currentRun.Text.End < firstTextSourceCharacterIndex)
{ {
goto skip; goto skip;
} }
goto noop; goto noop;
}
default:
{
goto noop;
}
}
skip: skip:
{ {
startX += currentRun.Size.Width; startX += currentRun.Size.Width;
currentPosition += currentRun.TextSourceLength;
} }
continue; continue;
@ -460,7 +477,6 @@ namespace Avalonia.Media.TextFormatting
} }
} }
var endX = startX; var endX = startX;
var endOffset = 0d; var endOffset = 0d;
@ -520,11 +536,13 @@ namespace Avalonia.Media.TextFormatting
} }
default: default:
{ {
if (firstTextSourceCharacterIndex + textLength >= currentRun.Text.Start + currentRun.Text.Length) if (currentPosition + currentRun.TextSourceLength <= firstTextSourceCharacterIndex + textLength)
{ {
endX += currentRun.Size.Width; endX += currentRun.Size.Width;
} }
currentPosition += currentRun.TextSourceLength;
break; break;
} }
} }
@ -538,7 +556,9 @@ namespace Avalonia.Media.TextFormatting
if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX)) if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX))
{ {
var textBounds = new TextBounds(currentRect.WithWidth(currentRect.Width + width), currentDirection); currentRect = currentRect.WithWidth(currentRect.Width + width);
var textBounds = new TextBounds(currentRect, currentDirection);
result[result.Count - 1] = textBounds; result[result.Count - 1] = textBounds;
} }
@ -551,21 +571,9 @@ namespace Avalonia.Media.TextFormatting
if (currentDirection == FlowDirection.LeftToRight) if (currentDirection == FlowDirection.LeftToRight)
{ {
if (nextRun != null) if (currentPosition >= firstTextSourceCharacterIndex + textLength)
{
if (nextRun.Text.Start > currentRun.Text.Start && nextRun.Text.Start >= firstTextSourceCharacterIndex + textLength)
{
break;
}
currentPosition = nextRun.Text.End;
}
else
{ {
if (currentPosition >= firstTextSourceCharacterIndex + textLength) break;
{
break;
}
} }
} }
else else
@ -575,10 +583,7 @@ namespace Avalonia.Media.TextFormatting
break; break;
} }
if (currentPosition != currentRun.Text.Start) endX += currentRun.Size.Width - endOffset;
{
endX += currentRun.Size.Width - endOffset;
}
} }
lastDirection = currentDirection; lastDirection = currentDirection;
@ -1018,31 +1023,21 @@ namespace Avalonia.Media.TextFormatting
private TextLineMetrics CreateLineMetrics() private TextLineMetrics CreateLineMetrics()
{ {
var start = 0d; var glyphTypeface = _paragraphProperties.DefaultTextRunProperties.Typeface.GlyphTypeface;
var height = 0d; var fontRenderingEmSize = _paragraphProperties.DefaultTextRunProperties.FontRenderingEmSize;
var scale = fontRenderingEmSize / glyphTypeface.DesignEmHeight;
var width = 0d; var width = 0d;
var widthIncludingWhitespace = 0d; var widthIncludingWhitespace = 0d;
var trailingWhitespaceLength = 0; var trailingWhitespaceLength = 0;
var newLineLength = 0; var newLineLength = 0;
var ascent = 0d; var ascent = glyphTypeface.Ascent * scale;
var descent = 0d; var descent = glyphTypeface.Descent * scale;
var lineGap = 0d; var lineGap = glyphTypeface.LineGap * scale;
var fontRenderingEmSize = 0d;
var lineHeight = _paragraphProperties.LineHeight; var height = descent - ascent + lineGap;
if (_textRuns.Count == 0) var lineHeight = _paragraphProperties.LineHeight;
{
var glyphTypeface = _paragraphProperties.DefaultTextRunProperties.Typeface.GlyphTypeface;
fontRenderingEmSize = _paragraphProperties.DefaultTextRunProperties.FontRenderingEmSize;
var scale = fontRenderingEmSize / glyphTypeface.DesignEmHeight;
ascent = glyphTypeface.Ascent * scale;
height = double.IsNaN(lineHeight) || MathUtilities.IsZero(lineHeight) ?
descent - ascent + lineGap :
lineHeight;
return new TextLineMetrics(false, height, 0, start, -ascent, 0, 0, 0);
}
for (var index = 0; index < _textRuns.Count; index++) for (var index = 0; index < _textRuns.Count; index++)
{ {
@ -1166,12 +1161,15 @@ namespace Avalonia.Media.TextFormatting
} }
} }
start = GetParagraphOffsetX(width, widthIncludingWhitespace, _paragraphWidth, var start = GetParagraphOffsetX(width, widthIncludingWhitespace, _paragraphWidth,
_paragraphProperties.TextAlignment, _paragraphProperties.FlowDirection); _paragraphProperties.TextAlignment, _paragraphProperties.FlowDirection);
if (!double.IsNaN(lineHeight) && !MathUtilities.IsZero(lineHeight)) if (!double.IsNaN(lineHeight) && !MathUtilities.IsZero(lineHeight))
{ {
height = lineHeight; if(lineHeight > height)
{
height = lineHeight;
}
} }
return new TextLineMetrics(widthIncludingWhitespace > _paragraphWidth, height, newLineLength, start, return new TextLineMetrics(widthIncludingWhitespace > _paragraphWidth, height, newLineLength, start,

5
src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs

@ -238,6 +238,11 @@ namespace Avalonia.Media.TextFormatting.Unicode
_levelRuns.Clear(); _levelRuns.Clear();
_resolvedLevelsBuffer.Clear(); _resolvedLevelsBuffer.Clear();
if (types.IsEmpty)
{
return;
}
// Setup original types and working types // Setup original types and working types
_originalClasses = types; _originalClasses = types;
_workingClasses = _workingClassesBuffer.Add(types); _workingClasses = _workingClassesBuffer.Add(types);

11
src/Avalonia.Controls/Documents/IInlineHost.cs

@ -0,0 +1,11 @@
using Avalonia.LogicalTree;
namespace Avalonia.Controls.Documents
{
internal interface IInlineHost : ILogical
{
void AddVisualChild(IControl child);
void Invalidate();
}
}

9
src/Avalonia.Controls/Documents/Inline.cs

@ -2,9 +2,8 @@ using System.Collections.Generic;
using System.Text; using System.Text;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Controls.Documents namespace Avalonia.Controls.Documents
{ {
/// <summary> /// <summary>
/// Inline element. /// Inline element.
@ -45,9 +44,9 @@ namespace Avalonia.Controls.Documents
set { SetValue(BaselineAlignmentProperty, value); } set { SetValue(BaselineAlignmentProperty, value); }
} }
internal abstract int BuildRun(StringBuilder stringBuilder, IList<ValueSpan<TextRunProperties>> textStyleOverrides, int firstCharacterIndex); internal abstract void BuildTextRun(IList<TextRun> textRuns);
internal abstract int AppendText(StringBuilder stringBuilder); internal abstract void AppendText(StringBuilder stringBuilder);
protected TextRunProperties CreateTextRunProperties() protected TextRunProperties CreateTextRunProperties()
{ {
@ -63,7 +62,7 @@ namespace Avalonia.Controls.Documents
{ {
case nameof(TextDecorations): case nameof(TextDecorations):
case nameof(BaselineAlignment): case nameof(BaselineAlignment):
Invalidate(); InlineHost?.Invalidate();
break; break;
} }
} }

46
src/Avalonia.Controls/Documents/InlineCollection.cs

@ -12,29 +12,37 @@ namespace Avalonia.Controls.Documents
[WhitespaceSignificantCollection] [WhitespaceSignificantCollection]
public class InlineCollection : AvaloniaList<Inline> public class InlineCollection : AvaloniaList<Inline>
{ {
private readonly IInlineHost? _host;
private string? _text = string.Empty; private string? _text = string.Empty;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="InlineCollection"/> class. /// Initializes a new instance of the <see cref="InlineCollection"/> class.
/// </summary> /// </summary>
public InlineCollection(ILogical parent) : base(0) public InlineCollection(ILogical parent) : this(parent, null) { }
/// <summary>
/// Initializes a new instance of the <see cref="InlineCollection"/> class.
/// </summary>
internal InlineCollection(ILogical parent, IInlineHost? host = null) : base(0)
{ {
_host = host;
ResetBehavior = ResetBehavior.Remove; ResetBehavior = ResetBehavior.Remove;
this.ForEachItem( this.ForEachItem(
x => x =>
{ {
((ISetLogicalParent)x).SetParent(parent); ((ISetLogicalParent)x).SetParent(parent);
x.Invalidated += Invalidate; x.InlineHost = host;
Invalidate(); host?.Invalidate();
}, },
x => x =>
{ {
((ISetLogicalParent)x).SetParent(null); ((ISetLogicalParent)x).SetParent(null);
x.Invalidated -= Invalidate; x.InlineHost = host;
Invalidate(); host?.Invalidate();
}, },
() => throw new NotSupportedException()); () => throw new NotSupportedException());
} }
public bool HasComplexContent => Count > 0; public bool HasComplexContent => Count > 0;
@ -96,12 +104,22 @@ namespace Avalonia.Controls.Documents
} }
} }
public void Add(IControl child)
{
var implicitRun = new InlineUIContainer(child);
Add(implicitRun);
}
public override void Add(Inline item) public override void Add(Inline item)
{ {
if (!HasComplexContent) if (!HasComplexContent)
{ {
base.Add(new Run(_text)); if (!string.IsNullOrEmpty(_text))
{
base.Add(new Run(_text));
}
_text = string.Empty; _text = string.Empty;
} }
@ -112,11 +130,19 @@ namespace Avalonia.Controls.Documents
/// Raised when an inline in the collection changes. /// Raised when an inline in the collection changes.
/// </summary> /// </summary>
public event EventHandler? Invalidated; public event EventHandler? Invalidated;
/// <summary> /// <summary>
/// Raises the <see cref="Invalidated"/> event. /// Raises the <see cref="Invalidated"/> event.
/// </summary> /// </summary>
protected void Invalidate() => Invalidated?.Invoke(this, EventArgs.Empty); protected void Invalidate()
{
if(_host != null)
{
_host.Invalidate();
}
Invalidated?.Invoke(this, EventArgs.Empty);
}
private void Invalidate(object? sender, EventArgs e) => Invalidate(); private void Invalidate(object? sender, EventArgs e) => Invalidate();
} }

125
src/Avalonia.Controls/Documents/InlineUIContainer.cs

@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.Text;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Metadata;
using Avalonia.Utilities;
namespace Avalonia.Controls.Documents
{
/// <summary>
/// InlineUIContainer - a wrapper for embedded UIElements in text
/// flow content inline collections
/// </summary>
public class InlineUIContainer : Inline
{
/// <summary>
/// Defines the <see cref="Child"/> property.
/// </summary>
public static readonly StyledProperty<IControl> ChildProperty =
AvaloniaProperty.Register<InlineUIContainer, IControl>(nameof(Child));
static InlineUIContainer()
{
BaselineAlignmentProperty.OverrideDefaultValue<InlineUIContainer>(BaselineAlignment.Top);
}
/// <summary>
/// Initializes a new instance of InlineUIContainer element.
/// </summary>
/// <remarks>
/// The purpose of this element is to be a wrapper for UIElements
/// when they are embedded into text flow - as items of
/// InlineCollections.
/// </remarks>
public InlineUIContainer()
{
}
/// <summary>
/// Initializes an InlineBox specifying its child UIElement
/// </summary>
/// <param name="child">
/// UIElement set as a child of this inline item
/// </param>
public InlineUIContainer(IControl child)
{
Child = child;
}
/// <summary>
/// The content spanned by this TextElement.
/// </summary>
[Content]
public IControl Child
{
get => GetValue(ChildProperty);
set => SetValue(ChildProperty, value);
}
internal override void BuildTextRun(IList<TextRun> textRuns)
{
if(InlineHost == null)
{
return;
}
((ISetLogicalParent)Child).SetParent(InlineHost);
InlineHost.AddVisualChild(Child);
textRuns.Add(new InlineRun(Child, CreateTextRunProperties()));
}
internal override void AppendText(StringBuilder stringBuilder)
{
}
private class InlineRun : DrawableTextRun
{
public InlineRun(IControl control, TextRunProperties properties)
{
Control = control;
Properties = properties;
}
public IControl Control { get; }
public override TextRunProperties? Properties { get; }
public override Size Size
{
get
{
if (!Control.IsMeasureValid)
{
Control.Measure(Size.Infinity);
}
return Control.DesiredSize;
}
}
public override double Baseline
{
get
{
double baseline = Size.Height;
double baselineOffsetValue = Control.GetValue<double>(TextBlock.BaselineOffsetProperty);
if (!MathUtilities.IsZero(baselineOffsetValue))
{
baseline = baselineOffsetValue;
}
return -baseline;
}
}
public override void Draw(DrawingContext drawingContext, Point origin)
{
Control.Arrange(new Rect(origin, Size));
}
}
}
}

20
src/Avalonia.Controls/Documents/LineBreak.cs

@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
using Avalonia.LogicalTree;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Metadata; using Avalonia.Metadata;
using Avalonia.Utilities;
namespace Avalonia.Controls.Documents namespace Avalonia.Controls.Documents
{ {
@ -20,24 +20,14 @@ namespace Avalonia.Controls.Documents
{ {
} }
internal override int BuildRun(StringBuilder stringBuilder, internal override void BuildTextRun(IList<TextRun> textRuns)
IList<ValueSpan<TextRunProperties>> textStyleOverrides, int firstCharacterIndex)
{ {
var length = AppendText(stringBuilder); textRuns.Add(new TextEndOfLine());
textStyleOverrides.Add(new ValueSpan<TextRunProperties>(firstCharacterIndex, length,
CreateTextRunProperties()));
return length;
} }
internal override int AppendText(StringBuilder stringBuilder) internal override void AppendText(StringBuilder stringBuilder)
{ {
var text = Environment.NewLine; stringBuilder.Append(Environment.NewLine);
stringBuilder.Append(text);
return text.Length;
} }
} }
} }

19
src/Avalonia.Controls/Documents/Run.cs

@ -4,7 +4,6 @@ using System.Text;
using Avalonia.Data; using Avalonia.Data;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Metadata; using Avalonia.Metadata;
using Avalonia.Utilities;
namespace Avalonia.Controls.Documents namespace Avalonia.Controls.Documents
{ {
@ -51,24 +50,22 @@ namespace Avalonia.Controls.Documents
set { SetValue (TextProperty, value); } set { SetValue (TextProperty, value); }
} }
internal override int BuildRun(StringBuilder stringBuilder, internal override void BuildTextRun(IList<TextRun> textRuns)
IList<ValueSpan<TextRunProperties>> textStyleOverrides, int firstCharacterIndex)
{ {
var length = AppendText(stringBuilder); var text = (Text ?? "").AsMemory();
textStyleOverrides.Add(new ValueSpan<TextRunProperties>(firstCharacterIndex, length, var textRunProperties = CreateTextRunProperties();
CreateTextRunProperties()));
return length; var textCharacters = new TextCharacters(text, textRunProperties);
textRuns.Add(textCharacters);
} }
internal override int AppendText(StringBuilder stringBuilder) internal override void AppendText(StringBuilder stringBuilder)
{ {
var text = Text ?? ""; var text = Text ?? "";
stringBuilder.Append(text); stringBuilder.Append(text);
return text.Length;
} }
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
@ -78,7 +75,7 @@ namespace Avalonia.Controls.Documents
switch (change.Property.Name) switch (change.Property.Name)
{ {
case nameof(Text): case nameof(Text):
Invalidate(); InlineHost?.Invalidate();
break; break;
} }
} }

46
src/Avalonia.Controls/Documents/Span.cs

@ -1,8 +1,8 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Metadata; using Avalonia.Metadata;
using Avalonia.Utilities;
namespace Avalonia.Controls.Documents namespace Avalonia.Controls.Documents
{ {
@ -25,8 +25,7 @@ namespace Avalonia.Controls.Documents
public Span() public Span()
{ {
Inlines = new InlineCollection(this); Inlines = new InlineCollection(this);
Inlines.Invalidated += (s, e) => InlineHost?.Invalidate();
Inlines.Invalidated += (s, e) => Invalidate();
} }
/// <summary> /// <summary>
@ -35,61 +34,42 @@ namespace Avalonia.Controls.Documents
[Content] [Content]
public InlineCollection Inlines { get; } public InlineCollection Inlines { get; }
internal override int BuildRun(StringBuilder stringBuilder, IList<ValueSpan<TextRunProperties>> textStyleOverrides, int firstCharacterIndex) internal override void BuildTextRun(IList<TextRun> textRuns)
{ {
var length = 0;
if (Inlines.HasComplexContent) if (Inlines.HasComplexContent)
{ {
foreach (var inline in Inlines) foreach (var inline in Inlines)
{ {
var inlineLength = inline.BuildRun(stringBuilder, textStyleOverrides, firstCharacterIndex); inline.BuildTextRun(textRuns);
firstCharacterIndex += inlineLength;
length += inlineLength;
} }
} }
else else
{ {
if (Inlines.Text == null) if (Inlines.Text is string text)
{ {
return length; var textRunProperties = CreateTextRunProperties();
}
stringBuilder.Append(Inlines.Text);
length = Inlines.Text.Length; var textCharacters = new TextCharacters(text.AsMemory(), textRunProperties);
textStyleOverrides.Add(new ValueSpan<TextRunProperties>(firstCharacterIndex, length, textRuns.Add(textCharacters);
CreateTextRunProperties())); }
} }
return length;
} }
internal override int AppendText(StringBuilder stringBuilder) internal override void AppendText(StringBuilder stringBuilder)
{ {
if (Inlines.HasComplexContent) if (Inlines.HasComplexContent)
{ {
var length = 0;
foreach (var inline in Inlines) foreach (var inline in Inlines)
{ {
length += inline.AppendText(stringBuilder); inline.AppendText(stringBuilder);
} }
return length;
} }
if (Inlines.Text == null) if (Inlines.Text is string text)
{ {
return 0; stringBuilder.Append(text);
} }
stringBuilder.Append(Inlines.Text);
return Inlines.Text.Length;
} }
} }
} }

15
src/Avalonia.Controls/Documents/TextElement.cs

@ -1,5 +1,4 @@
using System; using Avalonia.Media;
using Avalonia.Media;
namespace Avalonia.Controls.Documents namespace Avalonia.Controls.Documents
{ {
@ -251,10 +250,7 @@ namespace Avalonia.Controls.Documents
control.SetValue(ForegroundProperty, value); control.SetValue(ForegroundProperty, value);
} }
/// <summary> internal IInlineHost? InlineHost { get; set; }
/// Raised when the visual representation of the text element changes.
/// </summary>
public event EventHandler? Invalidated;
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{ {
@ -269,14 +265,9 @@ namespace Avalonia.Controls.Documents
case nameof(FontWeight): case nameof(FontWeight):
case nameof(FontStretch): case nameof(FontStretch):
case nameof(Foreground): case nameof(Foreground):
Invalidate(); InlineHost?.Invalidate();
break; break;
} }
} }
/// <summary>
/// Raises the <see cref="Invalidate"/> event.
/// </summary>
protected void Invalidate() => Invalidated?.Invoke(this, EventArgs.Empty);
} }
} }

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

@ -662,7 +662,7 @@ namespace Avalonia.Controls.Presenters
caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (textLine.NewLineLength > 0 && caretIndex == textLine.FirstTextSourceIndex + textLine.Length) if (textLine.TrailingWhitespaceLength > 0 && caretIndex == textLine.FirstTextSourceIndex + textLine.Length)
{ {
characterHit = new CharacterHit(caretIndex); characterHit = new CharacterHit(caretIndex);
} }

139
src/Avalonia.Controls/TextBlock.cs

@ -14,7 +14,7 @@ namespace Avalonia.Controls
/// <summary> /// <summary>
/// A control that displays a block of text. /// A control that displays a block of text.
/// </summary> /// </summary>
public class TextBlock : Control public class TextBlock : Control, IInlineHost
{ {
/// <summary> /// <summary>
/// Defines the <see cref="Background"/> property. /// Defines the <see cref="Background"/> property.
@ -155,9 +155,7 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
public TextBlock() public TextBlock()
{ {
Inlines = new InlineCollection(this); Inlines = new InlineCollection(this, this);
Inlines.Invalidated += InlinesChanged;
} }
/// <summary> /// <summary>
@ -211,7 +209,7 @@ namespace Avalonia.Controls
} }
/// <summary> /// <summary>
/// Gets or sets the inlines. /// Gets the inlines.
/// </summary> /// </summary>
[Content] [Content]
public InlineCollection Inlines { get; } public InlineCollection Inlines { get; }
@ -552,38 +550,41 @@ namespace Avalonia.Controls
/// <returns>A <see cref="TextLayout"/> object.</returns> /// <returns>A <see cref="TextLayout"/> object.</returns>
protected virtual TextLayout CreateTextLayout(Size constraint, string? text) protected virtual TextLayout CreateTextLayout(Size constraint, string? text)
{ {
List<ValueSpan<TextRunProperties>>? textStyleOverrides = null; var defaultProperties = new GenericTextRunProperties(
new Typeface(FontFamily, FontStyle, FontWeight, FontStretch),
FontSize,
TextDecorations,
Foreground);
var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false,
defaultProperties, TextWrapping, LineHeight, 0);
ITextSource textSource;
if (Inlines.HasComplexContent) if (Inlines.HasComplexContent)
{ {
textStyleOverrides = new List<ValueSpan<TextRunProperties>>(Inlines.Count); var textRuns = new List<TextRun>();
var textPosition = 0;
var stringBuilder = new StringBuilder();
foreach (var inline in Inlines) foreach (var inline in Inlines)
{ {
textPosition += inline.BuildRun(stringBuilder, textStyleOverrides, textPosition); inline.BuildTextRun(textRuns);
} }
text = stringBuilder.ToString(); textSource = new InlinesTextSource(textRuns);
}
else
{
textSource = new SimpleTextSource((text ?? "").AsMemory(), defaultProperties);
} }
return new TextLayout( return new TextLayout(
text ?? string.Empty, textSource,
new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), paragraphProperties,
FontSize,
Foreground ?? Brushes.Transparent,
TextAlignment,
TextWrapping,
TextTrimming, TextTrimming,
TextDecorations,
FlowDirection,
constraint.Width, constraint.Width,
constraint.Height, constraint.Height,
maxLines: MaxLines, maxLines: MaxLines,
lineHeight: LineHeight, lineHeight: LineHeight);
textStyleOverrides: textStyleOverrides);
} }
/// <summary> /// <summary>
@ -592,7 +593,7 @@ namespace Avalonia.Controls
protected void InvalidateTextLayout() protected void InvalidateTextLayout()
{ {
_textLayout = null; _textLayout = null;
InvalidateMeasure(); InvalidateMeasure();
} }
@ -604,9 +605,9 @@ namespace Avalonia.Controls
} }
var padding = Padding; var padding = Padding;
_constraint = availableSize.Deflate(padding); _constraint = availableSize.Deflate(padding);
_textLayout = null; _textLayout = null;
InvalidateArrange(); InvalidateArrange();
@ -622,9 +623,13 @@ namespace Avalonia.Controls
{ {
return finalSize; return finalSize;
} }
_constraint = new Size(finalSize.Width, Math.Ceiling(finalSize.Height)); var padding = Padding;
var textSize = finalSize.Deflate(padding);
_constraint = new Size(textSize.Width, Math.Ceiling(textSize.Height));
_textLayout = null; _textLayout = null;
return finalSize; return finalSize;
@ -660,8 +665,6 @@ namespace Avalonia.Controls
case nameof (Padding): case nameof (Padding):
case nameof (LineHeight): case nameof (LineHeight):
case nameof (MaxLines): case nameof (MaxLines):
case nameof (InlinesProperty):
case nameof (Text): case nameof (Text):
case nameof (TextDecorations): case nameof (TextDecorations):
@ -673,9 +676,83 @@ namespace Avalonia.Controls
} }
} }
private void InlinesChanged(object? sender, EventArgs e) private void InlinesChanged(object? sender, EventArgs e)
{
InvalidateTextLayout();
}
void IInlineHost.AddVisualChild(IControl child)
{
if (child.VisualParent == null)
{
VisualChildren.Add(child);
}
}
void IInlineHost.Invalidate()
{ {
InvalidateTextLayout(); InvalidateTextLayout();
} }
private readonly struct InlinesTextSource : ITextSource
{
private readonly IReadOnlyList<TextRun> _textRuns;
public InlinesTextSource(IReadOnlyList<TextRun> textRuns)
{
_textRuns = textRuns;
}
public TextRun? GetTextRun(int textSourceIndex)
{
var currentPosition = 0;
foreach (var textRun in _textRuns)
{
if(textRun.TextSourceLength == 0)
{
continue;
}
if(currentPosition >= textSourceIndex)
{
return textRun;
}
currentPosition += textRun.TextSourceLength;
}
return null;
}
}
private readonly struct SimpleTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextRunProperties _defaultProperties;
public SimpleTextSource(ReadOnlySlice<char> text, TextRunProperties defaultProperties)
{
_text = text;
_defaultProperties = defaultProperties;
}
public TextRun? GetTextRun(int textSourceIndex)
{
if (textSourceIndex > _text.Length)
{
return null;
}
var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty)
{
return null;
}
return new TextCharacters(runText, _defaultProperties);
}
}
} }
} }

3
src/Skia/Avalonia.Skia/TextShaperImpl.cs

@ -1,6 +1,5 @@
using System; using System;
using System.Globalization; using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform; using Avalonia.Platform;
@ -59,7 +58,7 @@ namespace Avalonia.Skia
var glyphIndex = (ushort)sourceInfo.Codepoint; var glyphIndex = (ushort)sourceInfo.Codepoint;
var glyphCluster = (int)sourceInfo.Cluster; var glyphCluster = (int)(sourceInfo.Cluster);
var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale); var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale);

3
tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs

@ -1,11 +1,8 @@
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform;
using System; using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;

119
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@ -70,12 +70,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
} }
} }
[Fact] [Fact]
public void Should_Get_Next_Caret_CharacterHit_Bidi() public void Should_Get_Next_Caret_CharacterHit_Bidi()
{ {
const string text = "אבג 1 ABC"; const string text = "אבג 1 ABC";
using (Start()) using (Start())
{ {
var defaultProperties = new GenericTextRunProperties(Typeface.Default); var defaultProperties = new GenericTextRunProperties(Typeface.Default);
@ -90,7 +90,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var clusters = new List<int>(); var clusters = new List<int>();
foreach (var textRun in textLine.TextRuns.OrderBy(x=> x.Text.Start)) foreach (var textRun in textLine.TextRuns.OrderBy(x => x.Text.Start))
{ {
var shapedRun = (ShapedTextCharacters)textRun; var shapedRun = (ShapedTextCharacters)textRun;
@ -98,7 +98,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
shapedRun.ShapedBuffer.GlyphClusters.Reverse() : shapedRun.ShapedBuffer.GlyphClusters.Reverse() :
shapedRun.ShapedBuffer.GlyphClusters); shapedRun.ShapedBuffer.GlyphClusters);
} }
var nextCharacterHit = new CharacterHit(0, clusters[1] - clusters[0]); var nextCharacterHit = new CharacterHit(0, clusters[1] - clusters[0]);
foreach (var cluster in clusters) foreach (var cluster in clusters)
@ -122,7 +122,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
public void Should_Get_Previous_Caret_CharacterHit_Bidi() public void Should_Get_Previous_Caret_CharacterHit_Bidi()
{ {
const string text = "אבג 1 ABC"; const string text = "אבג 1 ABC";
using (Start()) using (Start())
{ {
var defaultProperties = new GenericTextRunProperties(Typeface.Default); var defaultProperties = new GenericTextRunProperties(Typeface.Default);
@ -137,7 +137,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var clusters = new List<int>(); var clusters = new List<int>();
foreach (var textRun in textLine.TextRuns.OrderBy(x=> x.Text.Start)) foreach (var textRun in textLine.TextRuns.OrderBy(x => x.Text.Start))
{ {
var shapedRun = (ShapedTextCharacters)textRun; var shapedRun = (ShapedTextCharacters)textRun;
@ -147,13 +147,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
clusters.Reverse(); clusters.Reverse();
var nextCharacterHit = new CharacterHit(text.Length - 1); var nextCharacterHit = new CharacterHit(text.Length - 1);
foreach (var cluster in clusters) foreach (var cluster in clusters)
{ {
var currentCaretIndex = nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength; var currentCaretIndex = nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength;
Assert.Equal(cluster, currentCaretIndex); Assert.Equal(cluster, currentCaretIndex);
nextCharacterHit = textLine.GetPreviousCaretCharacterHit(nextCharacterHit); nextCharacterHit = textLine.GetPreviousCaretCharacterHit(nextCharacterHit);
@ -168,7 +168,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(lastCharacterHit.TrailingLength, nextCharacterHit.TrailingLength); Assert.Equal(lastCharacterHit.TrailingLength, nextCharacterHit.TrailingLength);
} }
} }
[InlineData("𐐷𐐷𐐷𐐷𐐷")] [InlineData("𐐷𐐷𐐷𐐷𐐷")]
[InlineData("01234567🎉\n")] [InlineData("01234567🎉\n")]
[InlineData("𐐷1234")] [InlineData("𐐷1234")]
@ -324,7 +324,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
} }
Assert.Equal(currentDistance,textLine.GetDistanceFromCharacterHit(new CharacterHit(s_multiLineText.Length))); Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(s_multiLineText.Length)));
} }
} }
@ -371,7 +371,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
yield return CreateData("01234 01234", 58, TextTrimming.WordEllipsis, "01234\u2026"); yield return CreateData("01234 01234", 58, TextTrimming.WordEllipsis, "01234\u2026");
yield return CreateData("01234", 9, TextTrimming.CharacterEllipsis, "\u2026"); yield return CreateData("01234", 9, TextTrimming.CharacterEllipsis, "\u2026");
yield return CreateData("01234", 2, TextTrimming.CharacterEllipsis, ""); yield return CreateData("01234", 2, TextTrimming.CharacterEllipsis, "");
object[] CreateData(string text, double width, TextTrimming mode, string expected) object[] CreateData(string text, double width, TextTrimming mode, string expected)
{ {
return new object[] return new object[]
@ -424,7 +424,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{ {
var defaultProperties = new GenericTextRunProperties(Typeface.Default); var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new DrawableRunTextSource(); var textSource = new DrawableRunTextSource();
var formatter = new TextFormatterImpl(); var formatter = new TextFormatterImpl();
var textLine = var textLine =
@ -471,7 +471,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(4, textLine.TextRuns.Count); Assert.Equal(4, textLine.TextRuns.Count);
var currentHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(3,1)); var currentHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(3, 1));
Assert.Equal(3, currentHit.FirstCharacterIndex); Assert.Equal(3, currentHit.FirstCharacterIndex);
Assert.Equal(0, currentHit.TrailingLength); Assert.Equal(0, currentHit.TrailingLength);
@ -552,11 +552,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
switch (textSourceIndex) switch (textSourceIndex)
{ {
case 0: case 0:
return new CustomDrawableRun(); return new CustomDrawableRun();
case 1: case 1:
return new TextCharacters(new ReadOnlySlice<char>(Text.AsMemory(), 1, 1, 1), new GenericTextRunProperties(Typeface.Default)); return new TextCharacters(new ReadOnlySlice<char>(Text.AsMemory(), 1, 1, 1), new GenericTextRunProperties(Typeface.Default));
case 2: case 2:
return new CustomDrawableRun(); return new CustomDrawableRun();
case 3: case 3:
return new TextCharacters(new ReadOnlySlice<char>(Text.AsMemory(), 3, 1, 3), new GenericTextRunProperties(Typeface.Default)); return new TextCharacters(new ReadOnlySlice<char>(Text.AsMemory(), 3, 1, 3), new GenericTextRunProperties(Typeface.Default));
default: default:
@ -564,14 +564,14 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
} }
} }
private class CustomDrawableRun : DrawableTextRun private class CustomDrawableRun : DrawableTextRun
{ {
public override Size Size => new(14, 14); public override Size Size => new(14, 14);
public override double Baseline => 14; public override double Baseline => 14;
public override void Draw(DrawingContext drawingContext, Point origin) public override void Draw(DrawingContext drawingContext, Point origin)
{ {
} }
} }
@ -587,29 +587,29 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var shapedTextRuns = textLine.TextRuns.Cast<ShapedTextCharacters>().ToList(); var shapedTextRuns = textLine.TextRuns.Cast<ShapedTextCharacters>().ToList();
var lastCluster = -1; var lastCluster = -1;
foreach (var textRun in shapedTextRuns) foreach (var textRun in shapedTextRuns)
{ {
var shapedBuffer = textRun.ShapedBuffer; var shapedBuffer = textRun.ShapedBuffer;
var currentClusters = shapedBuffer.GlyphClusters.ToList(); var currentClusters = shapedBuffer.GlyphClusters.ToList();
foreach (var currentCluster in currentClusters) foreach (var currentCluster in currentClusters)
{ {
if (lastCluster == currentCluster) if (lastCluster == currentCluster)
{ {
continue; continue;
} }
glyphClusters.Add(currentCluster); glyphClusters.Add(currentCluster);
lastCluster = currentCluster; lastCluster = currentCluster;
} }
} }
return glyphClusters; return glyphClusters;
} }
private static List<Rect> BuildRects(TextLine textLine) private static List<Rect> BuildRects(TextLine textLine)
{ {
var rects = new List<Rect>(); var rects = new List<Rect>();
@ -624,11 +624,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
foreach (var textRun in shapedTextRuns) foreach (var textRun in shapedTextRuns)
{ {
var shapedBuffer = textRun.ShapedBuffer; var shapedBuffer = textRun.ShapedBuffer;
for (var index = 0; index < shapedBuffer.GlyphAdvances.Count; index++) for (var index = 0; index < shapedBuffer.GlyphAdvances.Count; index++)
{ {
var currentCluster = shapedBuffer.GlyphClusters[index]; var currentCluster = shapedBuffer.GlyphClusters[index];
var advance = shapedBuffer.GlyphAdvances[index]; var advance = shapedBuffer.GlyphAdvances[index];
if (lastCluster != currentCluster) if (lastCluster != currentCluster)
@ -642,10 +642,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
rects.Remove(rect); rects.Remove(rect);
rect = rect.WithWidth(rect.Width + advance); rect = rect.WithWidth(rect.Width + advance);
rects.Add(rect); rects.Add(rect);
} }
currentX += advance; currentX += advance;
lastCluster = currentCluster; lastCluster = currentCluster;
@ -655,8 +655,65 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
return rects; return rects;
} }
[Fact]
public void Should_Get_TextBounds_Mixed()
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var text = "0123".AsMemory();
var shaperOption = new TextShaperOptions(Typeface.Default.GlyphTypeface, 10, 0, CultureInfo.CurrentCulture);
var firstRun = new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice<char>(text, 1, text.Length), shaperOption), defaultProperties);
var textRuns = new List<TextRun>
{
new CustomDrawableRun(),
firstRun,
new CustomDrawableRun(),
new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice<char>(text, text.Length + 2, text.Length), shaperOption), defaultProperties),
new CustomDrawableRun(),
new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice<char>(text, text.Length * 2 + 3, text.Length), shaperOption), defaultProperties)
};
var textSource = new FixedRunsTextSource(textRuns);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var textBounds = textLine.GetTextBounds(0, text.Length * 3 + 3);
Assert.Equal(1, textBounds.Count);
Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(0, 1);
Assert.Equal(1, textBounds.Count);
Assert.Equal(14, textBounds[0].Rectangle.Width);
textBounds = textLine.GetTextBounds(0, firstRun.Text.Length + 1);
Assert.Equal(1, textBounds.Count);
Assert.Equal(firstRun.Size.Width + 14, textBounds[0].Rectangle.Width);
textBounds = textLine.GetTextBounds(1, firstRun.Text.Length);
Assert.Equal(1, textBounds.Count);
Assert.Equal(firstRun.Size.Width, textBounds[0].Rectangle.Width);
textBounds = textLine.GetTextBounds(1, firstRun.Text.Length + 1);
Assert.Equal(1, textBounds.Count);
Assert.Equal(firstRun.Size.Width + 14, textBounds[0].Rectangle.Width);
}
}
[Fact] [Fact]
public void Should_Get_TextBounds() public void Should_Get_TextBounds_BiDi()
{ {
using (Start()) using (Start())
{ {
@ -673,7 +730,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice<char>(text, text.Length * 3, text.Length), ltrOptions), defaultProperties) new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice<char>(text, text.Length * 3, text.Length), ltrOptions), defaultProperties)
}; };
var textSource = new FixedRunsTextSource(textRuns); var textSource = new FixedRunsTextSource(textRuns);
var formatter = new TextFormatterImpl(); var formatter = new TextFormatterImpl();
@ -700,12 +757,16 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
public TextRun? GetTextRun(int textSourceIndex) public TextRun? GetTextRun(int textSourceIndex)
{ {
var currentPosition = 0;
foreach (var textRun in _textRuns) foreach (var textRun in _textRuns)
{ {
if(textRun.Text.Start == textSourceIndex) if (currentPosition == textSourceIndex)
{ {
return textRun; return textRun;
} }
currentPosition += textRun.TextSourceLength;
} }
return null; return null;

Loading…
Cancel
Save