using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { /// /// Represents a multi line text layout. /// public class TextLayout { private static readonly char[] s_empty = { '\u200B' }; private readonly ReadOnlySlice _text; private readonly TextParagraphProperties _paragraphProperties; private readonly IReadOnlyList>? _textStyleOverrides; private readonly TextTrimming _textTrimming; /// /// 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 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 = TextTrimming.None, TextDecorationCollection? textDecorations = null, double maxWidth = double.PositiveInfinity, double maxHeight = double.PositiveInfinity, double lineHeight = double.NaN, int maxLines = 0, IReadOnlyList>? textStyleOverrides = null) { _text = string.IsNullOrEmpty(text) ? new ReadOnlySlice() : new ReadOnlySlice(text.AsMemory()); _paragraphProperties = CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textDecorations, lineHeight); _textTrimming = textTrimming; _textStyleOverrides = textStyleOverrides; LineHeight = lineHeight; MaxWidth = maxWidth; MaxHeight = maxHeight; MaxLines = maxLines; UpdateLayout(); } /// /// 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 size of the layout. /// /// /// The bounds. /// public Size Size { 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 >= _text.Length) { var lastLine = TextLines[TextLines.Count - 1]; var lineX = lastLine.Width; var lineY = Size.Height - lastLine.Height; return new Rect(lineX, lineY, 0, lastLine.Height); } var currentY = 0.0; foreach (var textLine in TextLines) { if (textLine.TextRange.End < textPosition) { 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) { var currentX = textLine.Start; if (textLine.TextRange.End < start) { currentY += textLine.Height; continue; } if (start > textLine.TextRange.Start) { currentX += textLine.GetDistanceFromCharacterHit(new CharacterHit(start)); } var endX = textLine.GetDistanceFromCharacterHit(new CharacterHit(start + length)); result.Add(new Rect(currentX, currentY, endX - currentX, textLine.Height)); if (textLine.TextRange.Start + textLine.TextRange.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.GetNextCaretCharacterHit(new CharacterHit(currentLine.TextRange.End)); return GetHitTestResult(currentLine, characterHit, point); } private TextHitTestResult GetHitTestResult(TextLine textLine, CharacterHit characterHit, Point point) { var (x, y) = point; var lastTrailingIndex = textLine.TextRange.Start + textLine.TextRange.Length; var isInside = x >= 0 && x <= textLine.Width && y >= 0 && y <= textLine.Height; if (x >= textLine.Width && textLine.TextRange.Length > 0 && textLine.NewLineLength > 0) { lastTrailingIndex -= textLine.NewLineLength; } var textPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; var isTrailing = lastTrailingIndex == textPosition && characterHit.TrailingLength > 0 || y > Size.Height; return new TextHitTestResult { IsInside = isInside, IsTrailing = isTrailing, TextPosition = textPosition }; } /// /// 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 height of each line of text. /// private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize, IBrush foreground, TextAlignment textAlignment, TextWrapping textWrapping, TextDecorationCollection? textDecorations, double lineHeight) { var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground); return new GenericTextParagraphProperties(FlowDirection.LeftToRight, textAlignment, true, false, textRunStyle, textWrapping, lineHeight, 0); } /// /// Updates the current bounds. /// /// The text line. /// The current width. /// The current height. private static void UpdateBounds(TextLine textLine, ref double width, ref double height) { var lineWidth = textLine.Width + textLine.Start * 2; if (width < lineWidth) { width = lineWidth; } height += textLine.Height; } /// /// Creates an empty text line. /// /// The empty text line. private TextLine CreateEmptyTextLine(int startingIndex) { var properties = _paragraphProperties.DefaultTextRunProperties; var glyphRun = TextShaper.Current.ShapeText(new ReadOnlySlice(s_empty, startingIndex, 1), properties.Typeface, properties.FontRenderingEmSize, properties.CultureInfo); var textRuns = new List { new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) }; var textRange = new TextRange(startingIndex, 1); return new TextLineImpl(textRuns, textRange, MaxWidth, _paragraphProperties); } /// /// Updates the layout and applies specified text style overrides. /// [MemberNotNull(nameof(TextLines))] private void UpdateLayout() { if (_text.IsEmpty || MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight)) { var textLine = CreateEmptyTextLine(0); TextLines = new List { textLine }; Size = new Size(0, textLine.Height); } else { var textLines = new List(); double width = 0.0, height = 0.0; var currentPosition = 0; var textSource = new FormattedTextSource(_text, _paragraphProperties.DefaultTextRunProperties, _textStyleOverrides); TextLine? previousLine = null; while (currentPosition < _text.Length) { var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth, _paragraphProperties, previousLine?.TextLineBreak); currentPosition += textLine.TextRange.Length; if (textLines.Count > 0) { if (textLines.Count == MaxLines || !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 width, ref height); previousLine = textLine; if (currentPosition == _text.Length && textLine.NewLineLength > 0) { var emptyTextLine = CreateEmptyTextLine(currentPosition); textLines.Add(emptyTextLine); } } Size = new Size(width, height); TextLines = textLines; } } /// /// Gets the for current text trimming mode. /// /// The collapsing width. /// The . private TextCollapsingProperties GetCollapsingProperties(double width) { return _textTrimming switch { TextTrimming.CharacterEllipsis => new TextTrailingCharacterEllipsis(width, _paragraphProperties.DefaultTextRunProperties), TextTrimming.WordEllipsis => new TextTrailingWordEllipsis(width, _paragraphProperties.DefaultTextRunProperties), _ => throw new ArgumentOutOfRangeException(), }; } public int GetLineIndexFromCharacterIndex(int charIndex) { if (TextLines is null) { return -1; } if (charIndex < 0) { return -1; } if (charIndex > _text.Length - 1) { return TextLines.Count - 1; } for (var index = 0; index < TextLines.Count; index++) { var textLine = TextLines[index]; if (textLine.TextRange.End < charIndex) { continue; } if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.End) { return index; } } return TextLines.Count - 1; } public int GetCharacterIndexFromPoint(Point point, bool snapToText) { if (TextLines is null) { return -1; } var (x, y) = point; if (!snapToText && y > Size.Height) { return -1; } var currentY = 0d; foreach (var textLine in TextLines) { if (currentY + textLine.Height <= y) { currentY += textLine.Height; continue; } if (x > textLine.WidthIncludingTrailingWhitespace) { if (snapToText) { return textLine.TextRange.End; } return -1; } var characterHit = textLine.GetCharacterHitFromDistance(x); return characterHit.FirstCharacterIndex + characterHit.TrailingLength; } return _text.Length; } public Rect GetRectFromCharacterIndex(int characterIndex, bool trailingEdge) { if (TextLines is null) { return Rect.Empty; } var distanceY = 0d; var currentIndex = 0; foreach (var textLine in TextLines) { if (currentIndex + textLine.TextRange.Length < characterIndex) { distanceY += textLine.Height; currentIndex += textLine.TextRange.Length; continue; } var characterHit = new CharacterHit(characterIndex); while (characterHit.FirstCharacterIndex < characterIndex) { characterHit = textLine.GetNextCaretCharacterHit(characterHit); } var distanceX = textLine.GetDistanceFromCharacterHit(trailingEdge ? characterHit : new CharacterHit(characterHit.FirstCharacterIndex)); if (characterHit.TrailingLength > 0) { distanceX += 1; } return new Rect(distanceX, distanceY, 0, textLine.Height); } return Rect.Empty; } private readonly struct FormattedTextSource : ITextSource { private readonly ReadOnlySlice _text; private readonly TextRunProperties _defaultProperties; private readonly IReadOnlyList>? _textModifier; public FormattedTextSource(ReadOnlySlice text, TextRunProperties defaultProperties, IReadOnlyList>? textModifier) { _text = text; _defaultProperties = defaultProperties; _textModifier = textModifier; } public TextRun? GetTextRun(int textSourceIndex) { if (textSourceIndex > _text.Length) { return null; } var runText = _text.Skip(textSourceIndex); if (runText.IsEmpty) { return new TextEndOfParagraph(); } var textStyleRun = CreateTextStyleRun(runText, _defaultProperties, _textModifier); return new TextCharacters(runText.Take(textStyleRun.Length), textStyleRun.Value); } /// /// Creates a span of text run properties that has modifier applied. /// /// The text to create the properties for. /// The default text properties. /// The text properties modifier. /// /// The created text style run. /// private static ValueSpan CreateTextStyleRun(ReadOnlySlice text, TextRunProperties defaultProperties, IReadOnlyList>? textModifier) { if (textModifier == null || textModifier.Count == 0) { return new ValueSpan(text.Start, text.Length, defaultProperties); } var currentProperties = defaultProperties; var hasOverride = false; var i = 0; var length = 0; for (; i < textModifier.Count; i++) { var propertiesOverride = textModifier[i]; var textRange = new TextRange(propertiesOverride.Start, propertiesOverride.Length); if (textRange.End < text.Start) { continue; } if (textRange.Start > text.End) { length = text.Length; break; } if (textRange.Start > text.Start) { if (propertiesOverride.Value != currentProperties) { length = Math.Min(Math.Abs(textRange.Start - text.Start), text.Length); break; } } length += Math.Min(text.Length - length, textRange.Length); if (hasOverride) { continue; } hasOverride = true; currentProperties = propertiesOverride.Value; } if (length < text.Length && i == textModifier.Count) { if (currentProperties == defaultProperties) { length = text.Length; } } if (length != text.Length) { text = text.Take(length); } return new ValueSpan(text.Start, length, currentProperties); } } } }