using System; using System.Collections.Generic; using System.Linq; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { /// /// Represents a multi line text layout. /// public class TextLayout { private readonly ITextSource _textSource; private readonly TextParagraphProperties _paragraphProperties; private readonly TextTrimming _textTrimming; private int _textSourceLength; /// /// Initializes a new instance of the class. /// /// The text. /// The typeface. /// Size of the font. /// The foreground. /// The text alignment. /// The text wrapping. /// The text trimming. /// The text decorations. /// The text flow direction. /// The maximum width. /// The maximum height. /// The height of each line of text. /// The maximum number of text lines. /// The text style overrides. public TextLayout( string? text, Typeface typeface, double fontSize, IBrush? foreground, TextAlignment textAlignment = TextAlignment.Left, TextWrapping textWrapping = TextWrapping.NoWrap, TextTrimming? textTrimming = null, TextDecorationCollection? textDecorations = null, FlowDirection flowDirection = FlowDirection.LeftToRight, double maxWidth = double.PositiveInfinity, double maxHeight = double.PositiveInfinity, double lineHeight = double.NaN, int maxLines = 0, IReadOnlyList>? textStyleOverrides = null) { _paragraphProperties = CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textDecorations, flowDirection, lineHeight); _textSource = new FormattedTextSource(text.AsMemory(), _paragraphProperties.DefaultTextRunProperties, textStyleOverrides); _textTrimming = textTrimming ?? TextTrimming.None; LineHeight = lineHeight; MaxWidth = maxWidth; MaxHeight = maxHeight; MaxLines = maxLines; TextLines = CreateTextLines(); } /// /// Initializes a new instance of the class. /// /// The text source. /// The default text paragraph properties. /// The text trimming. /// The maximum width. /// The maximum height. /// The height of each line of text. /// The maximum number of text lines. 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; MaxWidth = maxWidth; MaxHeight = maxHeight; MaxLines = maxLines; TextLines = CreateTextLines(); } /// /// Gets or sets the height of each line of text. /// /// /// A value of NaN (equivalent to an attribute value of "Auto") indicates that the line height /// is determined automatically from the current font characteristics. The default is NaN. /// public double LineHeight { get; } /// /// Gets the maximum width. /// public double MaxWidth { get; } /// /// Gets the maximum height. /// public double MaxHeight { get; } /// /// Gets the maximum number of text lines. /// public int MaxLines { get; } /// /// Gets the text lines. /// /// /// The text lines. /// public IReadOnlyList TextLines { get; private set; } /// /// Gets the bounds of the layout. /// /// /// The bounds. /// public Rect Bounds { get; private set; } /// /// Draws the text layout. /// /// The drawing context. /// The origin. public void Draw(DrawingContext context, Point origin) { if (!TextLines.Any()) { return; } var (currentX, currentY) = origin; foreach (var textLine in TextLines) { textLine.Draw(context, new Point(currentX + textLine.Start, currentY)); currentY += textLine.Height; } } /// /// Get the pixel location relative to the top-left of the layout box given the text position. /// /// The text position. /// public Rect HitTestTextPosition(int textPosition) { if (TextLines.Count == 0) { return new Rect(); } if (textPosition < 0) { textPosition = _textSourceLength; } var currentY = 0.0; foreach (var textLine in TextLines) { var end = textLine.FirstTextSourceIndex + textLine.Length; if (end <= textPosition && end < _textSourceLength) { currentY += textLine.Height; continue; } var characterHit = new CharacterHit(textPosition); var startX = textLine.GetDistanceFromCharacterHit(characterHit); var nextCharacterHit = textLine.GetNextCaretCharacterHit(characterHit); var endX = textLine.GetDistanceFromCharacterHit(nextCharacterHit); return new Rect(startX, currentY, endX - startX, textLine.Height); } return new Rect(); } public IEnumerable HitTestTextRange(int start, int length) { if (start + length <= 0) { return Array.Empty(); } var result = new List(TextLines.Count); var currentY = 0d; foreach (var textLine in TextLines) { //Current line isn't covered. if (textLine.FirstTextSourceIndex + textLine.Length < start) { currentY += textLine.Height; continue; } var textBounds = textLine.GetTextBounds(start, length); if (textBounds.Count > 0) { foreach (var bounds in textBounds) { Rect? last = result.Count > 0 ? result[result.Count - 1] : null; if (last.HasValue && MathUtilities.AreClose(last.Value.Right, bounds.Rectangle.Left) && MathUtilities.AreClose(last.Value.Top, currentY)) { result[result.Count - 1] = last.Value.WithWidth(last.Value.Width + bounds.Rectangle.Width); } else { result.Add(bounds.Rectangle.WithY(currentY)); } foreach (var runBounds in bounds.TextRunBounds) { start += runBounds.Length; length -= runBounds.Length; } } } if (textLine.FirstTextSourceIndex + textLine.Length >= start + length) { break; } currentY += textLine.Height; } return result; } public TextHitTestResult HitTestPoint(in Point point) { var currentY = 0d; var lineIndex = 0; TextLine? currentLine = null; CharacterHit characterHit; for (; lineIndex < TextLines.Count; lineIndex++) { currentLine = TextLines[lineIndex]; if (currentY + currentLine.Height > point.Y) { characterHit = currentLine.GetCharacterHitFromDistance(point.X); return GetHitTestResult(currentLine, characterHit, point); } currentY += currentLine.Height; } if (currentLine is null) { return new TextHitTestResult(); } characterHit = currentLine.GetCharacterHitFromDistance(point.X); return GetHitTestResult(currentLine, characterHit, point); } public int GetLineIndexFromCharacterIndex(int charIndex, bool trailingEdge) { if (charIndex < 0) { return 0; } if (charIndex > _textSourceLength) { return TextLines.Count - 1; } for (var index = 0; index < TextLines.Count; index++) { var textLine = TextLines[index]; if (textLine.FirstTextSourceIndex + textLine.Length < charIndex) { continue; } if (charIndex >= textLine.FirstTextSourceIndex && charIndex <= textLine.FirstTextSourceIndex + textLine.Length - (trailingEdge ? 0 : 1)) { return index; } } return TextLines.Count - 1; } private TextHitTestResult GetHitTestResult(TextLine textLine, CharacterHit characterHit, Point point) { 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) { lastTrailingIndex -= textLine.NewLineLength; } var textPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; var isTrailing = lastTrailingIndex == textPosition && characterHit.TrailingLength > 0 || y > Bounds.Bottom; if (textPosition == textLine.FirstTextSourceIndex + textLine.Length) { textPosition -= textLine.NewLineLength; } if (textLine.NewLineLength > 0 && textPosition + textLine.NewLineLength == characterHit.FirstCharacterIndex + characterHit.TrailingLength) { characterHit = new CharacterHit(characterHit.FirstCharacterIndex); } return new TextHitTestResult(characterHit, textPosition, isInside, isTrailing); } /// /// Creates the default that are used by the . /// /// The typeface. /// The font size. /// The foreground. /// The text alignment. /// The text wrapping. /// The text decorations. /// The text flow direction. /// The height of each line of text. /// private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize, IBrush? foreground, TextAlignment textAlignment, TextWrapping textWrapping, TextDecorationCollection? textDecorations, FlowDirection flowDirection, double lineHeight) { var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground); return new GenericTextParagraphProperties(flowDirection, textAlignment, true, false, textRunStyle, textWrapping, lineHeight, 0); } /// /// Updates the current bounds. /// /// The text line. /// The current left. /// The current width. /// The current height. private static void UpdateBounds(TextLine textLine, ref double left, ref double width, ref double height) { var lineWidth = textLine.WidthIncludingTrailingWhitespace; if (width < lineWidth) { width = lineWidth; } if (left > textLine.Start) { left = textLine.Start; } height += textLine.Height; } private IReadOnlyList CreateTextLines() { if (MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight)) { var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties); Bounds = new Rect(0, 0, 0, textLine.Height); return new List { textLine }; } var textLines = new List(); double left = double.PositiveInfinity, width = 0.0, height = 0.0; _textSourceLength = 0; TextLine? previousLine = null; while (true) { var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth, _paragraphProperties, previousLine?.TextLineBreak); if (textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph) { if (previousLine != null && previousLine.NewLineLength > 0) { var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth, _paragraphProperties); textLines.Add(emptyTextLine); UpdateBounds(emptyTextLine, ref left, ref width, ref height); } break; } _textSourceLength += textLine.Length; //Fulfill max height constraint if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight) { if (previousLine?.TextLineBreak != null && _textTrimming != TextTrimming.None) { var collapsedLine = previousLine.Collapse(GetCollapsingProperties(MaxWidth)); textLines[textLines.Count - 1] = collapsedLine; } break; } var hasOverflowed = textLine.HasOverflowed; if (hasOverflowed && _textTrimming != TextTrimming.None) { textLine = textLine.Collapse(GetCollapsingProperties(MaxWidth)); } textLines.Add(textLine); UpdateBounds(textLine, ref left, ref width, ref height); previousLine = textLine; //Fulfill max lines constraint if (MaxLines > 0 && textLines.Count >= MaxLines) { if(textLine.TextLineBreak is TextLineBreak lineBreak && lineBreak.RemainingRuns != null) { textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(width)); } break; } } //Make sure the TextLayout always contains at least on empty line if (textLines.Count == 0) { var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties); textLines.Add(textLine); UpdateBounds(textLine, ref left, ref width, ref height); } Bounds = new Rect(left, 0, width, height); if (_paragraphProperties.TextAlignment == TextAlignment.Justify) { var whitespaceWidth = 0d; foreach (var line in textLines) { var lineWhitespaceWidth = line.Width - line.WidthIncludingTrailingWhitespace; if (lineWhitespaceWidth > whitespaceWidth) { whitespaceWidth = lineWhitespaceWidth; } } var justificationWidth = width - whitespaceWidth; if (justificationWidth > 0) { var justificationProperties = new InterWordJustification(justificationWidth); for (var i = 0; i < textLines.Count - 1; i++) { var line = textLines[i]; line.Justify(justificationProperties); } } } return textLines; } /// /// Gets the for current text trimming mode. /// /// The collapsing width. /// The . private TextCollapsingProperties GetCollapsingProperties(double width) { return _textTrimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(width, _paragraphProperties.DefaultTextRunProperties)); } } }