diff --git a/src/Avalonia.Base/Media/FormattedText.cs b/src/Avalonia.Base/Media/FormattedText.cs index 0bab473442..28757b1a1d 100644 --- a/src/Avalonia.Base/Media/FormattedText.cs +++ b/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 /// /// Wrapper of TextFormatter.FormatLine that auto-collapses the line if needed. /// - 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 } /// - 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); diff --git a/src/Avalonia.Base/Media/TextFormatting/ITextSource.cs b/src/Avalonia.Base/Media/TextFormatting/ITextSource.cs index 26966b37bc..32012ab8e9 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ITextSource.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ITextSource.cs @@ -1,6 +1,4 @@ -using Avalonia.Metadata; - -namespace Avalonia.Media.TextFormatting +namespace Avalonia.Media.TextFormatting { /// /// Produces objects that are used by the . diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs index 0b5d7649d7..ff8c1c4860 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs @@ -38,7 +38,7 @@ /// A value that specifies the text formatter state, /// in terms of where the previous line in the paragraph was broken by the text formatting process. /// The formatted line. - 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); } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 812c4e9eb8..7f74f49982 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -18,7 +18,7 @@ namespace Avalonia.Media.TextFormatting [ThreadStatic] private static BidiAlgorithm? t_bidiAlgorithm; /// - 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) { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index 8e85c10bba..4dbc472133 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/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 /// 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); diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index d29063e07d..187b3154ad 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/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; } /// - public override TextLineBreak? TextLineBreak { get; } + public override TextLineBreak? TextLineBreak => _textLineBreak; /// 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 GetTextBoundsLeftToRight(int firstTextSourceIndex, int textLength) + public override IReadOnlyList GetTextBounds(int firstTextSourceIndex, int textLength) { - var characterIndex = firstTextSourceIndex + textLength; + if (_textRuns.Length == 0) + { + return Array.Empty(); + } - var result = new List(_textRuns.Length); - var lastDirection = FlowDirection.LeftToRight; - var currentDirection = lastDirection; + var result = new List(); 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 { 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(); + 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 { 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 GetTextBoundsRightToLeft(int firstTextSourceIndex, int textLength) - { - var characterIndex = firstTextSourceIndex + textLength; + var runWidth = endX - startX; - var result = new List(_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(); + 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 { 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 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) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 9bd1dc95f9..df9a3eb8f3 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/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(); } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 954169f975..8a2d4ecc6b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/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) diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index 2b63f24cf6..3735e9f6d7 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/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() diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 544b84912e..e47542af7a 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/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);