Browse Source

Rework HitTestTextRange

pull/10009/head
Benedikt Stebner 3 years ago
parent
commit
14fba2e53a
  1. 32
      src/Avalonia.Base/Media/FormattedText.cs
  2. 4
      src/Avalonia.Base/Media/TextFormatting/ITextSource.cs
  3. 2
      src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs
  4. 18
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  5. 37
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  6. 684
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  7. 51
      src/Avalonia.Controls/TextBlock.cs
  8. 84
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  9. 60
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  10. 13
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

32
src/Avalonia.Base/Media/FormattedText.cs

@ -741,6 +741,11 @@ namespace Avalonia.Media
null // no previous line break
);
if(Current is null)
{
return false;
}
// check if this line fits the text height
if (_totalHeight + Current.Height > _that._maxTextHeight)
{
@ -779,7 +784,7 @@ namespace Avalonia.Media
// maybe there is no next line at all
if (Position + Current.Length < _that._text.Length)
{
bool nextLineFits;
bool nextLineFits = false;
if (_lineCount + 1 >= _that._maxLineCount)
{
@ -795,7 +800,10 @@ namespace Avalonia.Media
currentLineBreak
);
nextLineFits = (_totalHeight + Current.Height + _nextLine.Height <= _that._maxTextHeight);
if(_nextLine != null)
{
nextLineFits = (_totalHeight + Current.Height + _nextLine.Height <= _that._maxTextHeight);
}
}
if (!nextLineFits)
@ -819,16 +827,22 @@ namespace Avalonia.Media
_previousLineBreak
);
currentLineBreak = Current.TextLineBreak;
if(Current != null)
{
currentLineBreak = Current.TextLineBreak;
}
_that._defaultParaProps.SetTextWrapping(currentWrap);
}
}
}
_previousHeight = Current.Height;
if(Current != null)
{
_previousHeight = Current.Height;
Length = Current.Length;
Length = Current.Length;
}
_previousLineBreak = currentLineBreak;
@ -838,7 +852,7 @@ namespace Avalonia.Media
/// <summary>
/// Wrapper of TextFormatter.FormatLine that auto-collapses the line if needed.
/// </summary>
private TextLine FormatLine(ITextSource textSource, int textSourcePosition, double maxLineLength, TextParagraphProperties paraProps, TextLineBreak? lineBreak)
private TextLine? FormatLine(ITextSource textSource, int textSourcePosition, double maxLineLength, TextParagraphProperties paraProps, TextLineBreak? lineBreak)
{
var line = _formatter.FormatLine(
textSource,
@ -848,7 +862,7 @@ namespace Avalonia.Media
lineBreak
);
if (_that._trimming != TextTrimming.None && line.HasOverflowed && line.Length > 0)
if (line != null && _that._trimming != TextTrimming.None && line.HasOverflowed && line.Length > 0)
{
// what I really need here is the last displayed text run of the line
// textSourcePosition + line.Length - 1 works except the end of paragraph case,
@ -1601,11 +1615,11 @@ namespace Avalonia.Media
}
/// <inheritdoc/>
public TextRun? GetTextRun(int textSourceCharacterIndex)
public TextRun GetTextRun(int textSourceCharacterIndex)
{
if (textSourceCharacterIndex >= _that._text.Length)
{
return null;
return new TextEndOfParagraph();
}
var thatFormatRider = new SpanRider(_that._formatRuns, _that._latestPosition, textSourceCharacterIndex);

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

@ -1,6 +1,4 @@
using Avalonia.Metadata;
namespace Avalonia.Media.TextFormatting
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Produces <see cref="TextRun"/> objects that are used by the <see cref="TextFormatter"/>.

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

@ -38,7 +38,7 @@
/// <param name="previousLineBreak">A <see cref="TextLineBreak"/> value that specifies the text formatter state,
/// in terms of where the previous line in the paragraph was broken by the text formatting process.</param>
/// <returns>The formatted line.</returns>
public abstract TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
public abstract TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null);
}
}

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

@ -18,7 +18,7 @@ namespace Avalonia.Media.TextFormatting
[ThreadStatic] private static BidiAlgorithm? t_bidiAlgorithm;
/// <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)
{
TextLineBreak? nextLineBreak = null;
@ -41,6 +41,11 @@ namespace Avalonia.Media.TextFormatting
fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex, objectPool, out var textEndOfLine,
out var textSourceLength);
if (fetchedRuns.Count == 0)
{
return null;
}
shapedTextRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, objectPool, fontManager,
out var resolvedFlowDirection);
@ -491,16 +496,7 @@ namespace Avalonia.Media.TextFormatting
while (textRunEnumerator.MoveNext())
{
var textRun = textRunEnumerator.Current;
if (textRun == null)
{
textRuns.Add(new TextEndOfParagraph());
textSourceLength += TextRun.DefaultTextSourceLength;
break;
}
TextRun textRun = textRunEnumerator.Current!;
if (textRun is TextEndOfLine textEndOfLine)
{

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

@ -238,7 +238,7 @@ namespace Avalonia.Media.TextFormatting
foreach (var textLine in _textLines)
{
//Current line isn't covered.
if (textLine.FirstTextSourceIndex + textLine.Length < start)
if (textLine.FirstTextSourceIndex + textLine.Length <= start)
{
currentY += textLine.Height;
@ -348,14 +348,36 @@ namespace Avalonia.Media.TextFormatting
{
var (x, y) = point;
var lastTrailingIndex = textLine.FirstTextSourceIndex + textLine.Length;
var isInside = x >= 0 && x <= textLine.Width && y >= 0 && y <= textLine.Height;
if (x >= textLine.Width && textLine.Length > 0 && textLine.NewLineLength > 0)
var lastTrailingIndex = 0;
if(_paragraphProperties.FlowDirection== FlowDirection.LeftToRight)
{
lastTrailingIndex -= textLine.NewLineLength;
lastTrailingIndex = textLine.FirstTextSourceIndex + textLine.Length;
if (x >= textLine.Width && textLine.Length > 0 && textLine.NewLineLength > 0)
{
lastTrailingIndex -= textLine.NewLineLength;
}
if (textLine.TextLineBreak?.TextEndOfLine is TextEndOfLine textEndOfLine)
{
lastTrailingIndex -= textEndOfLine.Length;
}
}
else
{
if (x <= textLine.WidthIncludingTrailingWhitespace - textLine.Width && textLine.Length > 0 && textLine.NewLineLength > 0)
{
lastTrailingIndex += textLine.NewLineLength;
}
if (textLine.TextLineBreak?.TextEndOfLine is TextEndOfLine textEndOfLine)
{
lastTrailingIndex += textEndOfLine.Length;
}
}
var textPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
@ -391,7 +413,7 @@ namespace Avalonia.Media.TextFormatting
/// <returns></returns>
private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize,
IBrush? foreground, TextAlignment textAlignment, TextWrapping textWrapping,
TextDecorationCollection? textDecorations, FlowDirection flowDirection, double lineHeight,
TextDecorationCollection? textDecorations, FlowDirection flowDirection, double lineHeight,
double letterSpacing)
{
var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground);
@ -456,7 +478,7 @@ namespace Avalonia.Media.TextFormatting
var textLine = textFormatter.FormatLine(_textSource, _textSourceLength, MaxWidth,
_paragraphProperties, previousLine?.TextLineBreak);
if (textLine.Length == 0)
if (textLine is null)
{
if (previousLine != null && previousLine.NewLineLength > 0)
{
@ -518,7 +540,6 @@ 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);

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

@ -10,6 +10,7 @@ namespace Avalonia.Media.TextFormatting
private readonly double _paragraphWidth;
private readonly TextParagraphProperties _paragraphProperties;
private TextLineMetrics _textLineMetrics;
private TextLineBreak? _textLineBreak;
private readonly FlowDirection _resolvedFlowDirection;
public TextLineImpl(TextRun[] textRuns, int firstTextSourceIndex, int length, double paragraphWidth,
@ -18,7 +19,7 @@ namespace Avalonia.Media.TextFormatting
{
FirstTextSourceIndex = firstTextSourceIndex;
Length = length;
TextLineBreak = lineBreak;
_textLineBreak = lineBreak;
HasCollapsed = hasCollapsed;
_textRuns = textRuns;
@ -38,7 +39,7 @@ namespace Avalonia.Media.TextFormatting
public override int Length { get; }
/// <inheritdoc/>
public override TextLineBreak? TextLineBreak { get; }
public override TextLineBreak? TextLineBreak => _textLineBreak;
/// <inheritdoc/>
public override bool HasCollapsed { get; }
@ -167,50 +168,54 @@ namespace Avalonia.Media.TextFormatting
{
if (_textRuns.Length == 0)
{
return new CharacterHit();
return new CharacterHit(FirstTextSourceIndex);
}
distance -= Start;
var firstRunIndex = 0;
var lastIndex = _textRuns.Length - 1;
if (_textRuns[firstRunIndex] is TextEndOfLine)
if (_textRuns[lastIndex] is TextEndOfLine)
{
firstRunIndex++;
lastIndex--;
}
if(firstRunIndex >= _textRuns.Length)
var currentPosition = FirstTextSourceIndex;
if (lastIndex < 0)
{
return new CharacterHit(FirstTextSourceIndex);
return new CharacterHit(currentPosition);
}
if (distance <= 0)
{
var firstRun = _textRuns[firstRunIndex];
var firstRun = _textRuns[0];
return GetRunCharacterHit(firstRun, FirstTextSourceIndex, 0);
if (_paragraphProperties.FlowDirection == FlowDirection.RightToLeft)
{
currentPosition = Length - firstRun.Length;
}
return GetRunCharacterHit(firstRun, currentPosition, 0);
}
if (distance >= WidthIncludingTrailingWhitespace)
{
var lastRun = _textRuns[_textRuns.Length - 1];
var size = 0.0;
var lastRun = _textRuns[lastIndex];
if (lastRun is DrawableTextRun drawableTextRun)
if (_paragraphProperties.FlowDirection == FlowDirection.LeftToRight)
{
size = drawableTextRun.Size.Width;
currentPosition = Length - lastRun.Length;
}
return GetRunCharacterHit(lastRun, FirstTextSourceIndex + Length - lastRun.Length, size);
return GetRunCharacterHit(lastRun, currentPosition, distance);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
var currentPosition = FirstTextSourceIndex;
var currentDistance = 0.0;
for (var i = 0; i < _textRuns.Length; i++)
for (var i = 0; i <= lastIndex; i++)
{
var currentRun = _textRuns[i];
@ -242,7 +247,7 @@ namespace Avalonia.Media.TextFormatting
currentRun = _textRuns[j];
if(currentRun is not ShapedTextRun)
if (currentRun is not ShapedTextRun)
{
continue;
}
@ -274,10 +279,6 @@ namespace Avalonia.Media.TextFormatting
continue;
}
}
else
{
continue;
}
break;
}
@ -422,10 +423,10 @@ namespace Avalonia.Media.TextFormatting
{
if (currentGlyphRun != null)
{
distance = currentGlyphRun.Size.Width - distance;
currentDistance -= currentGlyphRun.Size.Width;
}
return Math.Max(0, currentDistance - distance);
return currentDistance + distance;
}
if (currentRun is DrawableTextRun drawableTextRun)
@ -575,386 +576,505 @@ namespace Avalonia.Media.TextFormatting
return GetPreviousCaretCharacterHit(characterHit);
}
private IReadOnlyList<TextBounds> GetTextBoundsLeftToRight(int firstTextSourceIndex, int textLength)
public override IReadOnlyList<TextBounds> GetTextBounds(int firstTextSourceIndex, int textLength)
{
var characterIndex = firstTextSourceIndex + textLength;
if (_textRuns.Length == 0)
{
return Array.Empty<TextBounds>();
}
var result = new List<TextBounds>(_textRuns.Length);
var lastDirection = FlowDirection.LeftToRight;
var currentDirection = lastDirection;
var result = new List<TextBounds>();
var currentPosition = FirstTextSourceIndex;
var remainingLength = textLength;
var startX = Start;
double currentWidth = 0;
var currentRect = default(Rect);
TextRunBounds lastRunBounds = default;
for (var index = 0; index < _textRuns.Length; index++)
static FlowDirection GetDirection(TextRun textRun, FlowDirection currentDirection)
{
if (_textRuns[index] is not DrawableTextRun currentRun)
if (textRun is ShapedTextRun shapedTextRun)
{
continue;
return shapedTextRun.ShapedBuffer.IsLeftToRight ?
FlowDirection.LeftToRight :
FlowDirection.RightToLeft;
}
var characterLength = 0;
var endX = startX;
TextRunBounds currentRunBounds;
return currentDirection;
}
double combinedWidth;
if (_paragraphProperties.FlowDirection == FlowDirection.LeftToRight)
{
var currentX = Start;
if (currentRun is ShapedTextRun currentShapedRun)
for (int i = 0; i < _textRuns.Length; i++)
{
var firstCluster = currentShapedRun.GlyphRun.Metrics.FirstCluster;
var currentRun = _textRuns[i];
var firstRunIndex = i;
var lastRunIndex = firstRunIndex;
var currentDirection = GetDirection(currentRun, FlowDirection.LeftToRight);
var directionalWidth = 0.0;
if (currentPosition + currentRun.Length <= firstTextSourceIndex)
if (currentRun is DrawableTextRun currentDrawable)
{
startX += currentRun.Size.Width;
directionalWidth = currentDrawable.Size.Width;
}
currentPosition += currentRun.Length;
// Find consecutive runs of same direction
for (; lastRunIndex + 1 < _textRuns.Length; lastRunIndex++)
{
var nextRun = _textRuns[lastRunIndex + 1];
continue;
var nextDirection = GetDirection(nextRun, currentDirection);
if (currentDirection != nextDirection)
{
break;
}
if (nextRun is DrawableTextRun nextDrawable)
{
directionalWidth += nextDrawable.Size.Width;
}
}
if (currentShapedRun.ShapedBuffer.IsLeftToRight)
//Skip runs that are not part of the hit test range
switch (currentDirection)
{
var startIndex = firstCluster + Math.Max(0, firstTextSourceIndex - currentPosition);
case FlowDirection.RightToLeft:
{
for (; lastRunIndex >= firstRunIndex; lastRunIndex--)
{
currentRun = _textRuns[lastRunIndex];
if (currentPosition + currentRun.Length > firstTextSourceIndex)
{
break;
}
currentPosition += currentRun.Length;
double startOffset;
if (currentRun is DrawableTextRun drawableTextRun)
{
directionalWidth -= drawableTextRun.Size.Width;
currentX += drawableTextRun.Size.Width;
}
double endOffset;
if(lastRunIndex - 1 < 0)
{
break;
}
}
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
break;
}
default:
{
for (; firstRunIndex <= lastRunIndex; firstRunIndex++)
{
currentRun = _textRuns[firstRunIndex];
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
if (currentPosition + currentRun.Length > firstTextSourceIndex)
{
break;
}
startX += startOffset;
currentPosition += currentRun.Length;
endX += endOffset;
if (currentRun is DrawableTextRun drawableTextRun)
{
currentX += drawableTextRun.Size.Width;
directionalWidth -= drawableTextRun.Size.Width;
}
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
if(firstRunIndex + 1 == _textRuns.Length)
{
break;
}
}
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
break;
}
}
characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength);
i = lastRunIndex;
currentDirection = FlowDirection.LeftToRight;
if (directionalWidth == 0)
{
continue;
}
else
var coveredLength = 0;
TextBounds? textBounds = null;
switch (currentDirection)
{
var rightToLeftIndex = index;
var rightToLeftWidth = currentShapedRun.Size.Width;
while (rightToLeftIndex + 1 <= _textRuns.Length - 1 && _textRuns[rightToLeftIndex + 1] is ShapedTextRun nextShapedRun)
{
if (nextShapedRun == null || nextShapedRun.ShapedBuffer.IsLeftToRight)
case FlowDirection.RightToLeft:
{
textBounds = GetTextRunBoundsRightToLeft(firstRunIndex, lastRunIndex, currentX + directionalWidth, firstTextSourceIndex,
currentPosition, remainingLength, out coveredLength, out currentPosition);
currentX += directionalWidth;
break;
}
default:
{
textBounds = GetTextBoundsLeftToRight(firstRunIndex, lastRunIndex, currentX, firstTextSourceIndex,
currentPosition, remainingLength, out coveredLength, out currentPosition);
rightToLeftIndex++;
rightToLeftWidth += nextShapedRun.Size.Width;
currentX = textBounds.Rectangle.Right;
if (currentPosition + nextShapedRun.Length > firstTextSourceIndex + textLength)
{
break;
}
}
currentShapedRun = nextShapedRun;
}
if (coveredLength > 0)
{
result.Add(textBounds);
remainingLength -= coveredLength;
}
if (remainingLength <= 0)
{
break;
}
}
}
else
{
var currentX = Start + WidthIncludingTrailingWhitespace;
startX += rightToLeftWidth;
for (int i = _textRuns.Length - 1; i >= 0; i--)
{
var currentRun = _textRuns[i];
var firstRunIndex = i;
var lastRunIndex = firstRunIndex;
var currentDirection = GetDirection(currentRun, FlowDirection.RightToLeft);
var directionalWidth = 0.0;
currentRunBounds = GetRightToLeftTextRunBounds(currentShapedRun, startX, firstTextSourceIndex, characterIndex, currentPosition, remainingLength);
if (currentRun is DrawableTextRun currentDrawable)
{
directionalWidth = currentDrawable.Size.Width;
}
remainingLength -= currentRunBounds.Length;
currentPosition = currentRunBounds.TextSourceCharacterIndex + currentRunBounds.Length;
endX = currentRunBounds.Rectangle.Right;
startX = currentRunBounds.Rectangle.Left;
// Find consecutive runs of same direction
for (; firstRunIndex - 1 > 0; firstRunIndex--)
{
var previousRun = _textRuns[firstRunIndex - 1];
var rightToLeftRunBounds = new List<TextRunBounds> { currentRunBounds };
var previousDirection = GetDirection(previousRun, currentDirection);
for (int i = rightToLeftIndex - 1; i >= index; i--)
if (currentDirection != previousDirection)
{
if (_textRuns[i] is not ShapedTextRun shapedRun)
break;
}
if (currentRun is DrawableTextRun previousDrawable)
{
directionalWidth += previousDrawable.Size.Width;
}
}
//Skip runs that are not part of the hit test range
switch (currentDirection)
{
case FlowDirection.RightToLeft:
{
continue;
}
for (; lastRunIndex >= firstRunIndex; lastRunIndex--)
{
currentRun = _textRuns[lastRunIndex];
currentShapedRun = shapedRun;
if (currentPosition + currentRun.Length <= firstTextSourceIndex)
{
currentPosition += currentRun.Length;
currentRunBounds = GetRightToLeftTextRunBounds(currentShapedRun, startX, firstTextSourceIndex, characterIndex, currentPosition, remainingLength);
if (currentRun is DrawableTextRun drawableTextRun)
{
currentX -= drawableTextRun.Size.Width;
directionalWidth -= drawableTextRun.Size.Width;
}
rightToLeftRunBounds.Insert(0, currentRunBounds);
continue;
}
remainingLength -= currentRunBounds.Length;
startX = currentRunBounds.Rectangle.Left;
break;
}
currentPosition += currentRunBounds.Length;
}
break;
}
default:
{
for (; firstRunIndex <= lastRunIndex; firstRunIndex++)
{
currentRun = _textRuns[firstRunIndex];
combinedWidth = endX - startX;
if (currentPosition + currentRun.Length <= firstTextSourceIndex)
{
currentPosition += currentRun.Length;
currentRect = new Rect(startX, 0, combinedWidth, Height);
if (currentRun is DrawableTextRun drawableTextRun)
{
currentX += drawableTextRun.Size.Width;
directionalWidth -= drawableTextRun.Size.Width;
}
currentDirection = FlowDirection.RightToLeft;
continue;
}
if (!MathUtilities.IsZero(combinedWidth))
{
result.Add(new TextBounds(currentRect, currentDirection, rightToLeftRunBounds));
}
break;
}
startX = endX;
break;
}
}
}
else
{
if (currentPosition + currentRun.Length <= firstTextSourceIndex)
{
startX += currentRun.Size.Width;
currentPosition += currentRun.Length;
i = firstRunIndex;
if (directionalWidth == 0)
{
continue;
}
if (currentPosition < firstTextSourceIndex)
{
startX += currentRun.Size.Width;
}
var coveredLength = 0;
if (currentPosition + currentRun.Length <= characterIndex)
TextBounds? textBounds = null;
switch (currentDirection)
{
endX += currentRun.Size.Width;
case FlowDirection.LeftToRight:
{
textBounds = GetTextBoundsLeftToRight(firstRunIndex, lastRunIndex, currentX - directionalWidth, firstTextSourceIndex,
currentPosition, remainingLength, out coveredLength, out currentPosition);
currentX -= directionalWidth;
characterLength = currentRun.Length;
break;
}
default:
{
textBounds = GetTextRunBoundsRightToLeft(firstRunIndex, lastRunIndex, currentX, firstTextSourceIndex,
currentPosition, remainingLength, out coveredLength, out currentPosition);
currentX = textBounds.Rectangle.Left;
break;
}
}
}
if (endX < startX)
{
(endX, startX) = (startX, endX);
}
//Visual order is always left to right so we need to insert
result.Insert(0, textBounds);
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
{
characterLength = NewLineLength;
remainingLength -= coveredLength;
if (remainingLength <= 0)
{
break;
}
}
}
combinedWidth = endX - startX;
return result;
}
currentRunBounds = new TextRunBounds(new Rect(startX, 0, combinedWidth, Height), currentPosition, characterLength, currentRun);
private TextBounds GetTextRunBoundsRightToLeft(int firstRunIndex, int lastRunIndex, double endX,
int firstTextSourceIndex, int currentPosition, int remainingLength, out int coveredLength, out int newPosition)
{
coveredLength = 0;
var textRunBounds = new List<TextRunBounds>();
var startX = endX;
currentPosition += characterLength;
for (int i = lastRunIndex; i >= firstRunIndex; i--)
{
var currentRun = _textRuns[i];
remainingLength -= characterLength;
if (currentRun is ShapedTextRun shapedTextRun)
{
var runBounds = GetRunBoundsRightToLeft(shapedTextRun, startX, firstTextSourceIndex, remainingLength, currentPosition, out var offset);
startX = endX;
textRunBounds.Insert(0, runBounds);
if (currentRunBounds.TextRun != null && !MathUtilities.IsZero(combinedWidth) || NewLineLength > 0)
{
if (result.Count > 0 && lastDirection == currentDirection && MathUtilities.AreClose(currentRect.Left, lastRunBounds.Rectangle.Right))
if (offset > 0)
{
currentRect = currentRect.WithWidth(currentWidth + combinedWidth);
endX = runBounds.Rectangle.Right;
var textBounds = result[result.Count - 1];
startX = endX;
}
textBounds.Rectangle = currentRect;
startX -= runBounds.Rectangle.Width;
textBounds.TextRunBounds.Add(currentRunBounds);
}
else
currentPosition += runBounds.Length + offset;
coveredLength += runBounds.Length;
remainingLength -= runBounds.Length;
}
else
{
if (currentRun is DrawableTextRun drawableTextRun)
{
currentRect = currentRunBounds.Rectangle;
startX -= drawableTextRun.Size.Width;
result.Add(new TextBounds(currentRect, currentDirection, new List<TextRunBounds> { currentRunBounds }));
textRunBounds.Insert(0,
new TextRunBounds(
new Rect(startX, 0, drawableTextRun.Size.Width, Height), currentPosition, currentRun.Length, currentRun));
}
}
lastRunBounds = currentRunBounds;
currentPosition += currentRun.Length;
coveredLength += currentRun.Length;
currentWidth += combinedWidth;
remainingLength -= currentRun.Length;
}
if (remainingLength <= 0 || currentPosition >= characterIndex)
if (remainingLength <= 0)
{
break;
}
lastDirection = currentDirection;
}
return result;
}
newPosition = currentPosition;
private IReadOnlyList<TextBounds> GetTextBoundsRightToLeft(int firstTextSourceIndex, int textLength)
{
var characterIndex = firstTextSourceIndex + textLength;
var runWidth = endX - startX;
var result = new List<TextBounds>(_textRuns.Length);
var lastDirection = FlowDirection.LeftToRight;
var currentDirection = lastDirection;
var bounds = new Rect(startX, 0, runWidth, Height);
var currentPosition = FirstTextSourceIndex;
var remainingLength = textLength;
return new TextBounds(bounds, FlowDirection.RightToLeft, textRunBounds);
}
var startX = WidthIncludingTrailingWhitespace;
double currentWidth = 0;
var currentRect = default(Rect);
private TextBounds GetTextBoundsLeftToRight(int firstRunIndex, int lastRunIndex, double startX,
int firstTextSourceIndex, int currentPosition, int remainingLength, out int coveredLength, out int newPosition)
{
coveredLength = 0;
var textRunBounds = new List<TextRunBounds>();
var endX = startX;
for (var index = _textRuns.Length - 1; index >= 0; index--)
for (int i = firstRunIndex; i <= lastRunIndex; i++)
{
if (_textRuns[index] is not DrawableTextRun currentRun)
{
continue;
}
if (currentPosition + currentRun.Length < firstTextSourceIndex)
{
startX -= currentRun.Size.Width;
currentPosition += currentRun.Length;
continue;
}
var characterLength = 0;
var endX = startX;
var currentRun = _textRuns[i];
if (currentRun is ShapedTextRun currentShapedRun)
if (currentRun is ShapedTextRun shapedTextRun)
{
var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
currentPosition += offset;
var startIndex = currentPosition;
double startOffset;
double endOffset;
var runBounds = GetRunBoundsLeftToRight(shapedTextRun, endX, firstTextSourceIndex, remainingLength, currentPosition, out var offset);
if (currentShapedRun.ShapedBuffer.IsLeftToRight)
{
if (currentPosition < startIndex)
{
startOffset = endOffset = 0;
}
else
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
textRunBounds.Add(runBounds);
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
}
}
else
if (offset > 0)
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
startX = runBounds.Rectangle.Left;
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
endX = startX;
}
startX -= currentRun.Size.Width - startOffset;
endX -= currentRun.Size.Width - endOffset;
currentPosition += runBounds.Length + offset;
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
endX += runBounds.Rectangle.Width;
characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength);
coveredLength += runBounds.Length;
currentDirection = currentShapedRun.ShapedBuffer.IsLeftToRight ?
FlowDirection.LeftToRight :
FlowDirection.RightToLeft;
remainingLength -= runBounds.Length;
}
else
{
if (currentPosition + currentRun.Length <= characterIndex)
if (currentRun is DrawableTextRun drawableTextRun)
{
endX -= currentRun.Size.Width;
textRunBounds.Add(
new TextRunBounds(
new Rect(endX, 0, drawableTextRun.Size.Width, Height), currentPosition, currentRun.Length, currentRun));
endX += drawableTextRun.Size.Width;
}
if (currentPosition < firstTextSourceIndex)
{
startX -= currentRun.Size.Width;
currentPosition += currentRun.Length;
characterLength = currentRun.Length;
}
}
coveredLength += currentRun.Length;
if (endX < startX)
{
(endX, startX) = (startX, endX);
remainingLength -= currentRun.Length;
}
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
if (remainingLength <= 0)
{
characterLength = NewLineLength;
break;
}
}
var runWidth = endX - startX;
newPosition = currentPosition;
var currentRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
var runWidth = endX - startX;
if (!MathUtilities.IsZero(runWidth) || NewLineLength > 0)
{
if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, Start + startX))
{
currentRect = currentRect.WithWidth(currentWidth + runWidth);
var bounds = new Rect(startX, 0, runWidth, Height);
var textBounds = result[result.Count - 1];
return new TextBounds(bounds, FlowDirection.LeftToRight, textRunBounds);
}
textBounds.Rectangle = currentRect;
private TextRunBounds GetRunBoundsLeftToRight(ShapedTextRun currentRun, double startX,
int firstTextSourceIndex, int remainingLength, int currentPosition, out int offset)
{
var startIndex = currentPosition;
textBounds.TextRunBounds.Add(currentRunBounds);
}
else
{
currentRect = currentRunBounds.Rectangle;
offset = Math.Max(0, firstTextSourceIndex - currentPosition);
result.Add(new TextBounds(currentRect, currentDirection, new List<TextRunBounds> { currentRunBounds }));
}
}
var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster;
currentWidth += runWidth;
currentPosition += characterLength;
if (currentPosition != firstCluster)
{
startIndex = firstCluster + offset;
}
else
{
startIndex += offset;
}
if (currentPosition > characterIndex)
{
break;
}
var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
lastDirection = currentDirection;
remainingLength -= characterLength;
var endX = startX + endOffset;
startX += startOffset;
if (remainingLength <= 0)
{
break;
}
var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength);
if (endX < startX)
{
(endX, startX) = (startX, endX);
}
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
{
characterLength = NewLineLength;
}
result.Reverse();
var runWidth = endX - startX;
return result;
return new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
}
private TextRunBounds GetRightToLeftTextRunBounds(ShapedTextRun currentRun, double endX, int firstTextSourceIndex, int characterIndex, int currentPosition, int remainingLength)
private TextRunBounds GetRunBoundsRightToLeft(ShapedTextRun currentRun, double endX,
int firstTextSourceIndex, int remainingLength, int currentPosition, out int offset)
{
var startX = endX;
var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
var startIndex = currentPosition;
currentPosition += offset;
offset = Math.Max(0, firstTextSourceIndex - currentPosition);
var startIndex = currentPosition;
var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster;
double startOffset;
double endOffset;
if (currentPosition != firstCluster)
{
startIndex = firstCluster + offset;
}
else
{
startIndex += offset;
}
endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
startX -= currentRun.Size.Width - startOffset;
endX -= currentRun.Size.Width - endOffset;
@ -980,16 +1100,6 @@ namespace Avalonia.Media.TextFormatting
return new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
}
public override IReadOnlyList<TextBounds> GetTextBounds(int firstTextSourceIndex, int textLength)
{
if (_paragraphProperties.FlowDirection == FlowDirection.LeftToRight)
{
return GetTextBoundsLeftToRight(firstTextSourceIndex, textLength);
}
return GetTextBoundsRightToLeft(firstTextSourceIndex, textLength);
}
public override void Dispose()
{
for (int i = 0; i < _textRuns.Length; i++)
@ -1005,6 +1115,11 @@ namespace Avalonia.Media.TextFormatting
{
_textLineMetrics = CreateLineMetrics();
if (_textLineBreak is null && _textRuns.Length > 1 && _textRuns[_textRuns.Length - 1] is TextEndOfLine textEndOfLine)
{
_textLineBreak = new TextLineBreak(textEndOfLine);
}
BidiReorderer.Instance.BidiReorder(_textRuns, _resolvedFlowDirection);
}
@ -1328,7 +1443,7 @@ namespace Avalonia.Media.TextFormatting
{
width = widthIncludingWhitespace + textRun.GlyphRun.Metrics.Width;
trailingWhitespaceLength = textRun.GlyphRun.Metrics.TrailingWhitespaceLength;
newLineLength = textRun.GlyphRun.Metrics.NewLineLength;
newLineLength += textRun.GlyphRun.Metrics.NewLineLength;
}
widthIncludingWhitespace += textRun.Size.Width;
@ -1340,31 +1455,10 @@ namespace Avalonia.Media.TextFormatting
{
widthIncludingWhitespace += drawableTextRun.Size.Width;
switch (_paragraphProperties.FlowDirection)
if (index == lastRunIndex)
{
case FlowDirection.LeftToRight:
{
if (index == lastRunIndex)
{
width = widthIncludingWhitespace;
trailingWhitespaceLength = 0;
newLineLength = 0;
}
break;
}
case FlowDirection.RightToLeft:
{
if (index == lastRunIndex)
{
width = widthIncludingWhitespace;
trailingWhitespaceLength = 0;
newLineLength = 0;
}
break;
}
width = widthIncludingWhitespace;
trailingWhitespaceLength = 0;
}
if (drawableTextRun.Size.Height > height)

51
src/Avalonia.Controls/TextBlock.cs

@ -720,6 +720,16 @@ namespace Avalonia.Controls
var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale);
if (HasComplexContent)
{
ArrangeComplexContent(TextLayout, padding);
}
if (MathUtilities.AreClose(_constraint.Inflate(padding).Width, finalSize.Width))
{
return finalSize;
}
_constraint = new Size(Math.Ceiling(finalSize.Deflate(padding).Width), double.PositiveInfinity);
_textLayout?.Dispose();
@ -727,31 +737,36 @@ namespace Avalonia.Controls
if (HasComplexContent)
{
var currentY = padding.Top;
ArrangeComplexContent(TextLayout, padding);
}
foreach (var textLine in TextLayout.TextLines)
{
var currentX = padding.Left + textLine.Start;
return finalSize;
}
foreach (var run in textLine.TextRuns)
private static void ArrangeComplexContent(TextLayout textLayout, Thickness padding)
{
var currentY = padding.Top;
foreach (var textLine in textLayout.TextLines)
{
var currentX = padding.Left + textLine.Start;
foreach (var run in textLine.TextRuns)
{
if (run is DrawableTextRun drawable)
{
if (run is DrawableTextRun drawable)
if (drawable is EmbeddedControlRun controlRun
&& controlRun.Control is Control control)
{
if (drawable is EmbeddedControlRun controlRun
&& controlRun.Control is Control control)
{
control.Arrange(new Rect(new Point(currentX, currentY), control.DesiredSize));
}
currentX += drawable.Size.Width;
control.Arrange(new Rect(new Point(currentX, currentY), control.DesiredSize));
}
}
currentY += textLine.Height;
currentX += drawable.Size.Width;
}
}
}
return finalSize;
currentY += textLine.Height;
}
}
protected override AutomationPeer OnCreateAutomationPeer()
@ -892,7 +907,7 @@ namespace Avalonia.Controls
return textRun;
}
return null;
return new TextEndOfParagraph();
}
}
}

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

@ -660,6 +660,90 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[Fact]
public void Should_Return_Null_For_Empty_TextSource()
{
using (Start())
{
var defaultRunProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties);
var textSource = new EmptyTextSource();
var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProperties);
Assert.Null(textLine);
}
}
[Fact]
public void Should_Retain_TextEndOfParagraph_With_TextWrapping()
{
using (Start())
{
var defaultRunProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrap: TextWrapping.Wrap);
var text = "Hello World";
var textSource = new SimpleTextSource(text, defaultRunProperties);
var pos = 0;
TextLineBreak previousLineBreak = null;
TextLine textLine = null;
while (pos < text.Length)
{
textLine = TextFormatter.Current.FormatLine(textSource, pos, 30, paragraphProperties, previousLineBreak);
pos += textLine.Length;
previousLineBreak = textLine.TextLineBreak;
}
Assert.NotNull(textLine);
Assert.NotNull(textLine.TextLineBreak.TextEndOfLine);
}
}
protected readonly record struct SimpleTextSource : ITextSource
{
private readonly string _text;
private readonly TextRunProperties _defaultProperties;
public SimpleTextSource(string text, TextRunProperties defaultProperties)
{
_text = text;
_defaultProperties = defaultProperties;
}
public TextRun? GetTextRun(int textSourceIndex)
{
if (textSourceIndex > _text.Length)
{
return new TextEndOfParagraph();
}
var runText = _text.AsMemory(textSourceIndex);
if (runText.IsEmpty)
{
return new TextEndOfParagraph();
}
return new TextCharacters(runText, _defaultProperties);
}
}
private class EmptyTextSource : ITextSource
{
public TextRun GetTextRun(int textSourceIndex)
{
return null;
}
}
private class EndOfLineTextSource : ITextSource
{
public TextRun GetTextRun(int textSourceIndex)

60
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

@ -9,7 +9,6 @@ using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests;
using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
public class TextLayoutTests
@ -1028,6 +1027,65 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[InlineData("mgfg🧐df f sdf", "g🧐d", 20, 40)]
[InlineData("وه. وقد تعرض لانتقادات", "دات", 5, 30)]
[InlineData("وه. وقد تعرض لانتقادات", "تعرض", 20, 50)]
[InlineData(" علمية 😱ومضللة ،", " علمية 😱ومضللة ،", 40, 100)]
[InlineData("في عام 2018 ، رفعت ل", "في عام 2018 ، رفعت ل", 100, 120)]
[Theory]
public void HitTestTextRange_Range_ValidLength(string text, string textToSelect, double minWidth, double maxWidth)
{
using (Start())
{
var layout = new TextLayout(text, Typeface.Default, 12, Brushes.Black);
var start = text.IndexOf(textToSelect);
var selectionRectangles = layout.HitTestTextRange(start, textToSelect.Length);
Assert.Equal(1, selectionRectangles.Count());
var rect = selectionRectangles.First();
Assert.InRange(rect.Width, minWidth, maxWidth);
}
}
[InlineData("012🧐210", 2, 4, FlowDirection.LeftToRight, "14.40234375,40.8046875")]
[InlineData("210🧐012", 2, 4, FlowDirection.RightToLeft, "0,7.201171875;21.603515625,33.603515625;48.005859375,55.20703125")]
[InlineData("שנב🧐שנב", 2, 4, FlowDirection.LeftToRight, "11.63671875,39.779296875")]
[InlineData("שנב🧐שנב", 2, 4, FlowDirection.RightToLeft, "11.63671875,39.779296875")]
[Theory]
public void Should_HitTextTextRangeBetweenRuns(string text, int start, int length,
FlowDirection flowDirection, string expected)
{
using (Start())
{
var expectedRects = expected.Split(';').Select(x =>
{
var startEnd = x.Split(',');
var start = double.Parse(startEnd[0], CultureInfo.InvariantCulture);
var end = double.Parse(startEnd[1], CultureInfo.InvariantCulture);
return new Rect(start, 0, end - start, 0);
}).ToArray();
var textLayout = new TextLayout(text, Typeface.Default, 12, Brushes.Black, flowDirection: flowDirection);
var rects = textLayout.HitTestTextRange(start, length).ToArray();
Assert.Equal(expectedRects.Length, rects.Length);
var endX = textLayout.TextLines[0].GetDistanceFromCharacterHit(new CharacterHit(2));
var startX = textLayout.TextLines[0].GetDistanceFromCharacterHit(new CharacterHit(5, 1));
for (int i = 0; i < expectedRects.Length; i++)
{
var expectedRect = expectedRects[i];
Assert.Equal(expectedRect.Left, rects[i].Left);
Assert.Equal(expectedRect.Right, rects[i].Right);
}
}
}
private static IDisposable Start()

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

@ -604,19 +604,19 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
textBounds = textLine.GetTextBounds(0, 20);
Assert.Equal(2, textBounds.Count);
Assert.Equal(1, textBounds.Count);
Assert.Equal(144.0234375, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(0, 30);
Assert.Equal(3, textBounds.Count);
Assert.Equal(1, textBounds.Count);
Assert.Equal(216.03515625, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(0, 40);
Assert.Equal(4, textBounds.Count);
Assert.Equal(1, textBounds.Count);
Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width));
}
@ -847,7 +847,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textBounds = textLine.GetTextBounds(0, textLine.Length);
Assert.Equal(6, textBounds.Count);
Assert.Equal(1, textBounds.Count);
Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(0, 1);
@ -857,7 +857,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
textBounds = textLine.GetTextBounds(0, firstRun.Length + 1);
Assert.Equal(2, textBounds.Count);
Assert.Equal(1, textBounds.Count);
Assert.Equal(firstRun.Size.Width + 14, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(1, firstRun.Length);
@ -867,7 +867,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
textBounds = textLine.GetTextBounds(0, 1 + firstRun.Length);
Assert.Equal(2, textBounds.Count);
Assert.Equal(1, textBounds.Count);
Assert.Equal(firstRun.Size.Width + 14, textBounds.Sum(x => x.Rectangle.Width));
}
}
@ -958,6 +958,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(secondRun.Size.Width, textBounds[1].Rectangle.Width);
Assert.Equal(7.201171875, textBounds[0].Rectangle.Width);
Assert.Equal(textLine.Start + 7.201171875, textBounds[0].Rectangle.Right);
Assert.Equal(textLine.Start + firstRun.Size.Width, textBounds[1].Rectangle.Left);

Loading…
Cancel
Save