diff --git a/src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs index c4302aecec..8e7d934bca 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs @@ -4,14 +4,12 @@ { private TextAlignment _textAlignment; private TextWrapping _textWrapping; - private TextTrimming _textTrimming; private double _lineHeight; public GenericTextParagraphProperties( TextRunProperties defaultTextRunProperties, TextAlignment textAlignment = TextAlignment.Left, - TextWrapping textWrapping = TextWrapping.WrapWithOverflow, - TextTrimming textTrimming = TextTrimming.None, + TextWrapping textWrapping = TextWrapping.NoWrap, double lineHeight = 0) { DefaultTextRunProperties = defaultTextRunProperties; @@ -20,8 +18,6 @@ _textWrapping = textWrapping; - _textTrimming = textTrimming; - _lineHeight = lineHeight; } @@ -31,8 +27,6 @@ public override TextWrapping TextWrapping => _textWrapping; - public override TextTrimming TextTrimming => _textTrimming; - public override double LineHeight => _lineHeight; /// @@ -50,13 +44,6 @@ { _textWrapping = textWrapping; } - /// - /// Set text trimming - /// - internal void SetTextTrimming(TextTrimming textTrimming) - { - _textTrimming = textTrimming; - } /// /// Set line height diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs index b71fe5bc3c..9e67a03f45 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs @@ -1,5 +1,4 @@ -using Avalonia.Media.TextFormatting.Unicode; -using Avalonia.Utilities; +using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingProperties.cs new file mode 100644 index 0000000000..ffd65423a3 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingProperties.cs @@ -0,0 +1,23 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Properties of text collapsing + /// + public abstract class TextCollapsingProperties + { + /// + /// Gets the width in which the collapsible range is constrained to + /// + public abstract double Width { get; } + + /// + /// Gets the text run that is used as collapsing symbol + /// + public abstract TextRun Symbol { get; } + + /// + /// Gets the style of collapsing + /// + public abstract TextCollapsingStyle Style { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingStyle.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingStyle.cs new file mode 100644 index 0000000000..1523cc4d9a --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingStyle.cs @@ -0,0 +1,18 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Text collapsing style + /// + public enum TextCollapsingStyle + { + /// + /// Collapse trailing characters + /// + TrailingCharacter, + + /// + /// Collapse trailing words + /// + TrailingWord, + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs index 3ad23f3504..061949a5c9 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs @@ -1,49 +1,41 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Avalonia.Media.TextFormatting.Unicode; -using Avalonia.Platform; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { internal class TextFormatterImpl : TextFormatter { - private static readonly ReadOnlySlice s_ellipsis = new ReadOnlySlice(new[] { '\u2026' }); - /// public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null) { - var textTrimming = paragraphProperties.TextTrimming; var textWrapping = paragraphProperties.TextWrapping; - TextLine textLine = null; var textRuns = FetchTextRuns(textSource, firstTextSourceIndex, previousLineBreak, out var nextLineBreak); var textRange = GetTextRange(textRuns); - if (textTrimming != TextTrimming.None) - { - textLine = PerformTextTrimming(textRuns, textRange, paragraphWidth, paragraphProperties); - } - else + TextLine textLine; + + switch (textWrapping) { - switch (textWrapping) - { - case TextWrapping.NoWrap: - { - var textLineMetrics = - TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties); + case TextWrapping.NoWrap: + { + var textLineMetrics = + TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties); - textLine = new TextLineImpl(textRuns, textLineMetrics, nextLineBreak); - break; - } - case TextWrapping.WrapWithOverflow: - case TextWrapping.Wrap: - { - textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties); - break; - } - } + textLine = new TextLineImpl(textRuns, textLineMetrics, nextLineBreak); + break; + } + case TextWrapping.WrapWithOverflow: + case TextWrapping.Wrap: + { + textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties); + break; + } + default: + throw new ArgumentOutOfRangeException(); } return textLine; @@ -174,87 +166,6 @@ namespace Avalonia.Media.TextFormatting return false; } - /// - /// Performs text trimming and returns a trimmed line. - /// - /// The text runs to perform the trimming on. - /// The text range that is covered by the text runs. - /// A value that specifies the width of the paragraph that the line fills. - /// A value that represents paragraph properties, - /// such as TextWrapping, TextAlignment, or TextStyle. - /// - private static TextLine PerformTextTrimming(IReadOnlyList textRuns, TextRange textRange, - double paragraphWidth, TextParagraphProperties paragraphProperties) - { - var textTrimming = paragraphProperties.TextTrimming; - var availableWidth = paragraphWidth; - var currentWidth = 0.0; - var runIndex = 0; - - while (runIndex < textRuns.Count) - { - var currentRun = textRuns[runIndex]; - - currentWidth += currentRun.GlyphRun.Bounds.Width; - - if (currentWidth > availableWidth) - { - var ellipsisRun = CreateEllipsisRun(currentRun.Properties); - - var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width); - - if (textTrimming == TextTrimming.WordEllipsis) - { - if (measuredLength < textRange.End) - { - var currentBreakPosition = 0; - - var lineBreaker = new LineBreakEnumerator(currentRun.Text); - - while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) - { - var nextBreakPosition = lineBreaker.Current.PositionWrap; - - if (nextBreakPosition == 0) - { - break; - } - - if (nextBreakPosition > measuredLength) - { - break; - } - - currentBreakPosition = nextBreakPosition; - } - - measuredLength = currentBreakPosition; - } - } - - var splitResult = SplitTextRuns(textRuns, measuredLength); - - var trimmedRuns = new List(splitResult.First.Count + 1); - - trimmedRuns.AddRange(splitResult.First); - - trimmedRuns.Add(ellipsisRun); - - var textLineMetrics = - TextLineMetrics.Create(trimmedRuns, textRange, paragraphWidth, paragraphProperties); - - return new TextLineImpl(trimmedRuns, textLineMetrics); - } - - availableWidth -= currentRun.GlyphRun.Bounds.Width; - - runIndex++; - } - - return new TextLineImpl(textRuns, - TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties)); - } - /// /// Performs text wrapping returns a list of text lines. /// @@ -269,7 +180,7 @@ namespace Avalonia.Media.TextFormatting var availableWidth = paragraphWidth; var currentWidth = 0.0; var runIndex = 0; - var length = 0; + var currentLength = 0; while (runIndex < textRuns.Count) { @@ -279,58 +190,53 @@ namespace Avalonia.Media.TextFormatting { var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth); + var breakFound = false; + + var currentBreakPosition = 0; + if (measuredLength < currentRun.Text.Length) { - if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow) - { - var lineBreaker = new LineBreakEnumerator(currentRun.Text.Skip(measuredLength)); + var lineBreaker = new LineBreakEnumerator(currentRun.Text); - if (lineBreaker.MoveNext()) - { - measuredLength += lineBreaker.Current.PositionWrap; - } - else - { - measuredLength = currentRun.Text.Length; - } - } - else + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) { - var currentBreakPosition = -1; + var nextBreakPosition = lineBreaker.Current.PositionWrap; - var lineBreaker = new LineBreakEnumerator(currentRun.Text); - - while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) + if (nextBreakPosition == 0 || nextBreakPosition > measuredLength) { - var nextBreakPosition = lineBreaker.Current.PositionWrap; + break; + } - if (nextBreakPosition == 0) - { - break; - } + breakFound = lineBreaker.Current.Required || + lineBreaker.Current.PositionWrap != currentRun.Text.Length; - if (nextBreakPosition > measuredLength) - { - break; - } + currentBreakPosition = nextBreakPosition; + } + } - currentBreakPosition = nextBreakPosition; - } + if (breakFound) + { + measuredLength = currentBreakPosition; + } + else + { + if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow) + { + var lineBreaker = new LineBreakEnumerator(currentRun.Text.Skip(currentBreakPosition)); - if (currentBreakPosition != -1) + if (lineBreaker.MoveNext()) { - measuredLength = currentBreakPosition; + measuredLength = currentBreakPosition + lineBreaker.Current.PositionWrap; } - } } - length += measuredLength; + currentLength += measuredLength; - var splitResult = SplitTextRuns(textRuns, length); + var splitResult = SplitTextRuns(textRuns, currentLength); var textLineMetrics = TextLineMetrics.Create(splitResult.First, - new TextRange(textRange.Start, length), paragraphWidth, paragraphProperties); + new TextRange(textRange.Start, currentLength), paragraphWidth, paragraphProperties); var lineBreak = splitResult.Second != null && splitResult.Second.Count > 0 ? new TextLineBreak(splitResult.Second) : @@ -341,7 +247,7 @@ namespace Avalonia.Media.TextFormatting currentWidth += currentRun.GlyphRun.Bounds.Width; - length += currentRun.GlyphRun.Characters.Length; + currentLength += currentRun.GlyphRun.Characters.Length; runIndex++; } @@ -356,7 +262,7 @@ namespace Avalonia.Media.TextFormatting /// The text run. /// The available width. /// - private static int MeasureText(ShapedTextCharacters textCharacters, double availableWidth) + internal static int MeasureText(ShapedTextCharacters textCharacters, double availableWidth) { var glyphRun = textCharacters.GlyphRun; @@ -391,10 +297,8 @@ namespace Avalonia.Media.TextFormatting } else { - for (var i = 0; i < glyphRun.GlyphAdvances.Length; i++) + foreach (var advance in glyphRun.GlyphAdvances) { - var advance = glyphRun.GlyphAdvances[i]; - if (currentWidth + advance > availableWidth) { break; @@ -423,21 +327,6 @@ namespace Avalonia.Media.TextFormatting return lastCluster - firstCluster; } - /// - /// Creates an ellipsis. - /// - /// The text run properties. - /// - private static ShapedTextCharacters CreateEllipsisRun(TextRunProperties properties) - { - var formatterImpl = AvaloniaLocator.Current.GetService(); - - var glyphRun = formatterImpl.ShapeText(s_ellipsis, properties.Typeface, properties.FontRenderingEmSize, - properties.CultureInfo); - - return new ShapedTextCharacters(glyphRun, properties); - } - /// /// Gets the text range that is covered by the text runs. /// @@ -470,7 +359,7 @@ namespace Avalonia.Media.TextFormatting /// The text run's. /// The length to split at. /// The split text runs. - private static SplitTextRunsResult SplitTextRuns(IReadOnlyList textRuns, int length) + internal static SplitTextRunsResult SplitTextRuns(IReadOnlyList textRuns, int length) { var currentLength = 0; @@ -543,7 +432,7 @@ namespace Avalonia.Media.TextFormatting return new SplitTextRunsResult(textRuns, null); } - private readonly struct SplitTextRunsResult + internal readonly struct SplitTextRunsResult { public SplitTextRunsResult(IReadOnlyList first, IReadOnlyList second) { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 54745144c8..92db6b69c4 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Utilities; -using Avalonia.Platform; namespace Avalonia.Media.TextFormatting { @@ -17,6 +15,7 @@ namespace Avalonia.Media.TextFormatting private readonly ReadOnlySlice _text; private readonly TextParagraphProperties _paragraphProperties; private readonly IReadOnlyList> _textStyleOverrides; + private readonly TextTrimming _textTrimming; /// /// Initializes a new instance of the class. @@ -54,9 +53,11 @@ namespace Avalonia.Media.TextFormatting new ReadOnlySlice(text.AsMemory()); _paragraphProperties = - CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming, + CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textDecorations, lineHeight); + _textTrimming = textTrimming; + _textStyleOverrides = textStyleOverrides; LineHeight = lineHeight; @@ -143,18 +144,16 @@ namespace Avalonia.Media.TextFormatting /// The foreground. /// The text alignment. /// The text wrapping. - /// The text trimming. /// The text decorations. /// The height of each line of text. /// private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize, - IBrush foreground, TextAlignment textAlignment, TextWrapping textWrapping, TextTrimming textTrimming, + IBrush foreground, TextAlignment textAlignment, TextWrapping textWrapping, TextDecorationCollection textDecorations, double lineHeight) { var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground); - return new GenericTextParagraphProperties(textRunStyle, textAlignment, textWrapping, textTrimming, - lineHeight); + return new GenericTextParagraphProperties(textRunStyle, textAlignment, textWrapping, lineHeight); } /// @@ -214,25 +213,44 @@ namespace Avalonia.Media.TextFormatting var textSource = new FormattedTextSource(_text, _paragraphProperties.DefaultTextRunProperties, _textStyleOverrides); - TextLineBreak previousLineBreak = null; + TextLine previousLine = null; - while (currentPosition < _text.Length && (MaxLines == 0 || textLines.Count < MaxLines)) + while (currentPosition < _text.Length) { var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth, - _paragraphProperties, previousLineBreak); + _paragraphProperties, previousLine?.LineBreak); - previousLineBreak = textLine.LineBreak; + currentPosition += textLine.TextRange.Length; - textLines.Add(textLine); + if (textLines.Count > 0) + { + if (textLines.Count == MaxLines || !double.IsPositiveInfinity(MaxHeight) && + height + textLine.LineMetrics.Size.Height > MaxHeight) + { + if (previousLine?.LineBreak != null && _textTrimming != TextTrimming.None) + { + var collapsedLine = + previousLine.Collapse(GetCollapsingProperties(MaxWidth)); - UpdateBounds(textLine, ref width, ref height); + textLines[textLines.Count - 1] = collapsedLine; + } + + break; + } + } - if (!double.IsPositiveInfinity(MaxHeight) && height > MaxHeight) + var hasOverflowed = textLine.LineMetrics.HasOverflowed; + + if (hasOverflowed && _textTrimming != TextTrimming.None) { - break; + textLine = textLine.Collapse(GetCollapsingProperties(MaxWidth)); } - currentPosition += textLine.TextRange.Length; + textLines.Add(textLine); + + UpdateBounds(textLine, ref width, ref height); + + previousLine = textLine; if (currentPosition != _text.Length || textLine.LineBreak == null) { @@ -250,6 +268,18 @@ namespace Avalonia.Media.TextFormatting } } + 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(), + }; + } + private readonly struct FormattedTextSource : ITextSource { private readonly ReadOnlySlice _text; diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs index c3b7dfc77a..3e3258f38a 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs @@ -39,6 +39,11 @@ namespace Avalonia.Media.TextFormatting /// public abstract TextLineBreak LineBreak { get; } + /// + /// Client to get a boolean value indicates whether a line has been collapsed + /// + public abstract bool HasCollapsed { get; } + /// /// Draws the at the given origin. /// @@ -46,6 +51,12 @@ namespace Avalonia.Media.TextFormatting /// The origin. public abstract void Draw(DrawingContext drawingContext, Point origin); + /// + /// Client to collapse the line and get a collapsed line that fits for display + /// + /// a list of collapsing properties + public abstract TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList); + /// /// Client to get the character hit corresponding to the specified /// distance from the beginning of the line. diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs index a1a9b50793..820c943aea 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Platform; namespace Avalonia.Media.TextFormatting { @@ -7,11 +9,12 @@ namespace Avalonia.Media.TextFormatting private readonly IReadOnlyList _textRuns; public TextLineImpl(IReadOnlyList textRuns, TextLineMetrics lineMetrics, - TextLineBreak lineBreak = null) + TextLineBreak lineBreak = null, bool hasCollapsed = false) { _textRuns = textRuns; LineMetrics = lineMetrics; LineBreak = lineBreak; + HasCollapsed = hasCollapsed; } /// @@ -26,6 +29,9 @@ namespace Avalonia.Media.TextFormatting /// public override TextLineBreak LineBreak { get; } + /// + public override bool HasCollapsed { get; } + /// public override void Draw(DrawingContext drawingContext, Point origin) { @@ -41,6 +47,98 @@ namespace Avalonia.Media.TextFormatting } } + public override TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList) + { + if (collapsingPropertiesList == null || collapsingPropertiesList.Length == 0) + { + return this; + } + + var collapsingProperties = collapsingPropertiesList[0]; + var runIndex = 0; + var currentWidth = 0.0; + var textRange = TextRange; + var collapsedLength = 0; + TextLineMetrics textLineMetrics; + + var shapedSymbol = CreateShapedSymbol(collapsingProperties.Symbol); + + var availableWidth = collapsingProperties.Width - shapedSymbol.Bounds.Width; + + while (runIndex < _textRuns.Count) + { + var currentRun = _textRuns[runIndex]; + + currentWidth += currentRun.GlyphRun.Bounds.Width; + + if (currentWidth > availableWidth) + { + var measuredLength = TextFormatterImpl.MeasureText(currentRun, availableWidth); + + var currentBreakPosition = 0; + + if (measuredLength < textRange.End) + { + var lineBreaker = new LineBreakEnumerator(currentRun.Text); + + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) + { + var nextBreakPosition = lineBreaker.Current.PositionWrap; + + if (nextBreakPosition == 0) + { + break; + } + + if (nextBreakPosition > measuredLength) + { + break; + } + + currentBreakPosition = nextBreakPosition; + } + } + + if (collapsingProperties.Style == TextCollapsingStyle.TrailingWord) + { + measuredLength = currentBreakPosition; + } + + collapsedLength += measuredLength; + + var splitResult = TextFormatterImpl.SplitTextRuns(_textRuns, collapsedLength); + + var shapedTextCharacters = new List(splitResult.First.Count + 1); + + shapedTextCharacters.AddRange(splitResult.First); + + shapedTextCharacters.Add(shapedSymbol); + + textRange = new TextRange(textRange.Start, collapsedLength); + + var shapedWidth = GetShapedWidth(shapedTextCharacters); + + textLineMetrics = new TextLineMetrics(new Size(shapedWidth, LineMetrics.Size.Height), + LineMetrics.TextBaseline, textRange, false); + + return new TextLineImpl(shapedTextCharacters, textLineMetrics, LineBreak, true); + } + + availableWidth -= currentRun.GlyphRun.Bounds.Width; + + collapsedLength += currentRun.GlyphRun.Characters.Length; + + runIndex++; + } + + textLineMetrics = + new TextLineMetrics(LineMetrics.Size.WithWidth(LineMetrics.Size.Width + shapedSymbol.Bounds.Width), + LineMetrics.TextBaseline, TextRange, LineMetrics.HasOverflowed); + + return new TextLineImpl(new List(_textRuns) { shapedSymbol }, textLineMetrics, null, + true); + } + /// public override CharacterHit GetCharacterHitFromDistance(double distance) { @@ -230,5 +328,41 @@ namespace Avalonia.Media.TextFormatting return runIndex; } + + /// + /// Creates a shaped symbol. + /// + /// The symbol run to shape. + /// + /// The shaped symbol. + /// + internal static ShapedTextCharacters CreateShapedSymbol(TextRun textRun) + { + var formatterImpl = AvaloniaLocator.Current.GetService(); + + var glyphRun = formatterImpl.ShapeText(textRun.Text, textRun.Properties.Typeface, textRun.Properties.FontRenderingEmSize, + textRun.Properties.CultureInfo); + + return new ShapedTextCharacters(glyphRun, textRun.Properties); + } + + /// + /// Gets the shaped width of specified shaped text characters. + /// + /// The shaped text characters. + /// + /// The shaped width. + /// + private static double GetShapedWidth(IReadOnlyList shapedTextCharacters) + { + var shapedWidth = 0.0; + + for (var i = 0; i < shapedTextCharacters.Count; i++) + { + shapedWidth += shapedTextCharacters[i].Bounds.Width; + } + + return shapedWidth; + } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs index 2f7809ff35..6875cc1c04 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs @@ -9,11 +9,12 @@ namespace Avalonia.Media.TextFormatting /// public readonly struct TextLineMetrics { - public TextLineMetrics(Size size, double textBaseline, TextRange textRange) + public TextLineMetrics(Size size, double textBaseline, TextRange textRange, bool hasOverflowed) { Size = size; TextBaseline = textBaseline; TextRange = textRange; + HasOverflowed = hasOverflowed; } /// @@ -37,6 +38,12 @@ namespace Avalonia.Media.TextFormatting /// public double TextBaseline { get; } + /// + /// Gets a boolean value that indicates whether content of the line overflows + /// the specified paragraph width. + /// + public bool HasOverflowed { get; } + /// /// Creates the text line metrics. /// @@ -83,7 +90,7 @@ namespace Avalonia.Media.TextFormatting descent - ascent + lineGap : paragraphProperties.LineHeight); - return new TextLineMetrics(size, -ascent, textRange); + return new TextLineMetrics(size, -ascent, textRange, size.Width > paragraphWidth); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs index 39eb695404..3ecd1aafd9 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs @@ -26,11 +26,6 @@ /// public abstract TextWrapping TextWrapping { get; } - /// - /// Gets the text trimming. - /// - public abstract TextTrimming TextTrimming { get; } - /// /// Paragraph's line height /// diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextTrailingCharacterEllipsis.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextTrailingCharacterEllipsis.cs new file mode 100644 index 0000000000..4bd46e8c75 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextTrailingCharacterEllipsis.cs @@ -0,0 +1,33 @@ +using Avalonia.Utilities; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// a collapsing properties to collapse whole line toward the end + /// at character granularity and with ellipsis being the collapsing symbol + /// + public class TextTrailingCharacterEllipsis : TextCollapsingProperties + { + private static readonly ReadOnlySlice s_ellipsis = new ReadOnlySlice(new[] { '\u2026' }); + + /// + /// Construct a text trailing character ellipsis collapsing properties + /// + /// width in which collapsing is constrained to + /// text run properties of ellispis symbol + public TextTrailingCharacterEllipsis(double width, TextRunProperties textRunProperties) + { + Width = width; + Symbol = new TextCharacters(s_ellipsis, textRunProperties); + } + + /// + public sealed override double Width { get; } + + /// + public sealed override TextRun Symbol { get; } + + /// + public sealed override TextCollapsingStyle Style { get; } = TextCollapsingStyle.TrailingCharacter; + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextTrailingWordEllipsis.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextTrailingWordEllipsis.cs new file mode 100644 index 0000000000..9dffddd207 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextTrailingWordEllipsis.cs @@ -0,0 +1,37 @@ +using Avalonia.Utilities; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// a collapsing properties to collapse whole line toward the end + /// at word granularity and with ellipsis being the collapsing symbol + /// + public class TextTrailingWordEllipsis : TextCollapsingProperties + { + private static readonly ReadOnlySlice s_ellipsis = new ReadOnlySlice(new[] { '\u2026' }); + + /// + /// Construct a text trailing word ellipsis collapsing properties + /// + /// width in which collapsing is constrained to + /// text run properties of ellispis symbol + public TextTrailingWordEllipsis( + double width, + TextRunProperties textRunProperties + ) + { + Width = width; + Symbol = new TextCharacters(s_ellipsis, textRunProperties); + } + + + /// + public sealed override double Width { get; } + + /// + public sealed override TextRun Symbol { get; } + + /// + public sealed override TextCollapsingStyle Style { get; } = TextCollapsingStyle.TrailingWord; + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs index 26f7721128..76bb9ac44f 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs @@ -109,7 +109,6 @@ namespace Avalonia.Media.TextFormatting.Unicode { case PairBreakType.DI: // Direct break shouldBreak = true; - _lastPos = _pos; break; case PairBreakType.IN: // possible indirect break diff --git a/src/Avalonia.Visuals/Media/TextWrapping.cs b/src/Avalonia.Visuals/Media/TextWrapping.cs index d649bda23f..b7915e5612 100644 --- a/src/Avalonia.Visuals/Media/TextWrapping.cs +++ b/src/Avalonia.Visuals/Media/TextWrapping.cs @@ -5,13 +5,6 @@ namespace Avalonia.Media /// public enum TextWrapping { - /// - /// Line-breaking occurs if the line overflows the available block width. - /// However, a line may overflow the block width if the line breaking algorithm - /// cannot determine a break opportunity, as in the case of a very long word. - /// - WrapWithOverflow, - /// /// Text should not wrap. /// @@ -20,6 +13,13 @@ namespace Avalonia.Media /// /// Text can wrap. /// - Wrap + Wrap, + + /// + /// Line-breaking occurs if the line overflows the available block width. + /// However, a line may overflow the block width if the line breaking algorithm + /// cannot determine a break opportunity, as in the case of a very long word. + /// + WrapWithOverflow } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index 43a791b2cb..bf41381b52 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -490,10 +490,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } - [InlineData("0123456789\r0123456789", 2)] - [InlineData("0123456789", 1)] + [InlineData("0123456789\r0123456789")] + [InlineData("0123456789")] [Theory] - public void Should_Include_Last_Line_When_Constraint_Is_Surpassed(string text, int numberOfLines) + public void Should_Include_First_Line_When_Constraint_Is_Surpassed(string text) { using (Start()) { @@ -508,11 +508,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Typeface.Default, 12, Brushes.Black.ToImmutable(), - maxHeight: lineHeight * numberOfLines - lineHeight * 0.5); + maxHeight: lineHeight - lineHeight * 0.5); - Assert.Equal(numberOfLines, layout.TextLines.Count); + Assert.Equal(1, layout.TextLines.Count); - Assert.Equal(numberOfLines * lineHeight, layout.Size.Height); + Assert.Equal(lineHeight, layout.Size.Height); } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index ed00d6aaed..f0951c61d3 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -162,6 +162,64 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [InlineData("01234 01234", 8, TextCollapsingStyle.TrailingCharacter, "01234 0\u2026")] + [InlineData("01234 01234", 8, TextCollapsingStyle.TrailingWord, "01234 \u2026")] + [Theory] + public void Should_Collapse_Line(string text, int numberOfCharacters, TextCollapsingStyle style, string expected) + { + using (Start()) + { + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + + var textSource = new SingleBufferTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.False(textLine.HasCollapsed); + + var glyphTypeface = Typeface.Default.GlyphTypeface; + + var scale = defaultProperties.FontRenderingEmSize / glyphTypeface.DesignEmHeight; + + var width = 1.0; + + for (var i = 0; i < numberOfCharacters; i++) + { + var glyph = glyphTypeface.GetGlyph(text[i]); + + width += glyphTypeface.GetGlyphAdvance(glyph) * scale; + } + + TextCollapsingProperties collapsingProperties; + + if (style == TextCollapsingStyle.TrailingCharacter) + { + collapsingProperties = new TextTrailingCharacterEllipsis(width, defaultProperties); + } + else + { + collapsingProperties = new TextTrailingWordEllipsis(width, defaultProperties); + } + + var collapsedLine = textLine.Collapse(collapsingProperties); + + Assert.True(collapsedLine.HasCollapsed); + + var trimmedText = collapsedLine.TextRuns.SelectMany(x => x.Text).ToArray(); + + Assert.Equal(expected.Length, trimmedText.Length); + + for (var i = 0; i < expected.Length; i++) + { + Assert.Equal(expected[i], trimmedText[i]); + } + } + } + private static IDisposable Start() { var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface