Browse Source

Allow combining TextTrimming and TextWrapping

pull/4260/head
Benedikt Schroeder 6 years ago
parent
commit
e87697f901
  1. 15
      src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs
  2. 3
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs
  3. 23
      src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingProperties.cs
  4. 18
      src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingStyle.cs
  5. 221
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs
  6. 62
      src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs
  7. 11
      src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs
  8. 136
      src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs
  9. 11
      src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs
  10. 5
      src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs
  11. 33
      src/Avalonia.Visuals/Media/TextFormatting/TextTrailingCharacterEllipsis.cs
  12. 37
      src/Avalonia.Visuals/Media/TextFormatting/TextTrailingWordEllipsis.cs
  13. 1
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
  14. 16
      src/Avalonia.Visuals/Media/TextWrapping.cs
  15. 12
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  16. 58
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

15
src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs

@ -4,14 +4,12 @@
{ {
private TextAlignment _textAlignment; private TextAlignment _textAlignment;
private TextWrapping _textWrapping; private TextWrapping _textWrapping;
private TextTrimming _textTrimming;
private double _lineHeight; private double _lineHeight;
public GenericTextParagraphProperties( public GenericTextParagraphProperties(
TextRunProperties defaultTextRunProperties, TextRunProperties defaultTextRunProperties,
TextAlignment textAlignment = TextAlignment.Left, TextAlignment textAlignment = TextAlignment.Left,
TextWrapping textWrapping = TextWrapping.WrapWithOverflow, TextWrapping textWrapping = TextWrapping.NoWrap,
TextTrimming textTrimming = TextTrimming.None,
double lineHeight = 0) double lineHeight = 0)
{ {
DefaultTextRunProperties = defaultTextRunProperties; DefaultTextRunProperties = defaultTextRunProperties;
@ -20,8 +18,6 @@
_textWrapping = textWrapping; _textWrapping = textWrapping;
_textTrimming = textTrimming;
_lineHeight = lineHeight; _lineHeight = lineHeight;
} }
@ -31,8 +27,6 @@
public override TextWrapping TextWrapping => _textWrapping; public override TextWrapping TextWrapping => _textWrapping;
public override TextTrimming TextTrimming => _textTrimming;
public override double LineHeight => _lineHeight; public override double LineHeight => _lineHeight;
/// <summary> /// <summary>
@ -50,13 +44,6 @@
{ {
_textWrapping = textWrapping; _textWrapping = textWrapping;
} }
/// <summary>
/// Set text trimming
/// </summary>
internal void SetTextTrimming(TextTrimming textTrimming)
{
_textTrimming = textTrimming;
}
/// <summary> /// <summary>
/// Set line height /// Set line height

3
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 namespace Avalonia.Media.TextFormatting
{ {

23
src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingProperties.cs

@ -0,0 +1,23 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Properties of text collapsing
/// </summary>
public abstract class TextCollapsingProperties
{
/// <summary>
/// Gets the width in which the collapsible range is constrained to
/// </summary>
public abstract double Width { get; }
/// <summary>
/// Gets the text run that is used as collapsing symbol
/// </summary>
public abstract TextRun Symbol { get; }
/// <summary>
/// Gets the style of collapsing
/// </summary>
public abstract TextCollapsingStyle Style { get; }
}
}

18
src/Avalonia.Visuals/Media/TextFormatting/TextCollapsingStyle.cs

@ -0,0 +1,18 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Text collapsing style
/// </summary>
public enum TextCollapsingStyle
{
/// <summary>
/// Collapse trailing characters
/// </summary>
TrailingCharacter,
/// <summary>
/// Collapse trailing words
/// </summary>
TrailingWord,
}
}

221
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.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
internal class TextFormatterImpl : TextFormatter internal class TextFormatterImpl : TextFormatter
{ {
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' });
/// <inheritdoc cref="TextFormatter.FormatLine"/> /// <inheritdoc cref="TextFormatter.FormatLine"/>
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null) TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null)
{ {
var textTrimming = paragraphProperties.TextTrimming;
var textWrapping = paragraphProperties.TextWrapping; var textWrapping = paragraphProperties.TextWrapping;
TextLine textLine = null;
var textRuns = FetchTextRuns(textSource, firstTextSourceIndex, previousLineBreak, out var nextLineBreak); var textRuns = FetchTextRuns(textSource, firstTextSourceIndex, previousLineBreak, out var nextLineBreak);
var textRange = GetTextRange(textRuns); var textRange = GetTextRange(textRuns);
if (textTrimming != TextTrimming.None) TextLine textLine;
{
textLine = PerformTextTrimming(textRuns, textRange, paragraphWidth, paragraphProperties); switch (textWrapping)
}
else
{ {
switch (textWrapping) case TextWrapping.NoWrap:
{ {
case TextWrapping.NoWrap: var textLineMetrics =
{ TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties);
var textLineMetrics =
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties);
textLine = new TextLineImpl(textRuns, textLineMetrics, nextLineBreak); textLine = new TextLineImpl(textRuns, textLineMetrics, nextLineBreak);
break; break;
} }
case TextWrapping.WrapWithOverflow: case TextWrapping.WrapWithOverflow:
case TextWrapping.Wrap: case TextWrapping.Wrap:
{ {
textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties); textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties);
break; break;
} }
} default:
throw new ArgumentOutOfRangeException();
} }
return textLine; return textLine;
@ -174,87 +166,6 @@ namespace Avalonia.Media.TextFormatting
return false; return false;
} }
/// <summary>
/// Performs text trimming and returns a trimmed line.
/// </summary>
/// <param name="textRuns">The text runs to perform the trimming on.</param>
/// <param name="textRange">The text range that is covered by the text runs.</param>
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</param>
/// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
/// such as TextWrapping, TextAlignment, or TextStyle.</param>
/// <returns></returns>
private static TextLine PerformTextTrimming(IReadOnlyList<ShapedTextCharacters> 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<ShapedTextCharacters>(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));
}
/// <summary> /// <summary>
/// Performs text wrapping returns a list of text lines. /// Performs text wrapping returns a list of text lines.
/// </summary> /// </summary>
@ -269,7 +180,7 @@ namespace Avalonia.Media.TextFormatting
var availableWidth = paragraphWidth; var availableWidth = paragraphWidth;
var currentWidth = 0.0; var currentWidth = 0.0;
var runIndex = 0; var runIndex = 0;
var length = 0; var currentLength = 0;
while (runIndex < textRuns.Count) while (runIndex < textRuns.Count)
{ {
@ -279,58 +190,53 @@ namespace Avalonia.Media.TextFormatting
{ {
var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth); var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth);
var breakFound = false;
var currentBreakPosition = 0;
if (measuredLength < currentRun.Text.Length) if (measuredLength < currentRun.Text.Length)
{ {
if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow) var lineBreaker = new LineBreakEnumerator(currentRun.Text);
{
var lineBreaker = new LineBreakEnumerator(currentRun.Text.Skip(measuredLength));
if (lineBreaker.MoveNext()) while (currentBreakPosition < measuredLength && lineBreaker.MoveNext())
{
measuredLength += lineBreaker.Current.PositionWrap;
}
else
{
measuredLength = currentRun.Text.Length;
}
}
else
{ {
var currentBreakPosition = -1; var nextBreakPosition = lineBreaker.Current.PositionWrap;
var lineBreaker = new LineBreakEnumerator(currentRun.Text); if (nextBreakPosition == 0 || nextBreakPosition > measuredLength)
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext())
{ {
var nextBreakPosition = lineBreaker.Current.PositionWrap; break;
}
if (nextBreakPosition == 0) breakFound = lineBreaker.Current.Required ||
{ lineBreaker.Current.PositionWrap != currentRun.Text.Length;
break;
}
if (nextBreakPosition > measuredLength) currentBreakPosition = nextBreakPosition;
{ }
break; }
}
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, 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 ? var lineBreak = splitResult.Second != null && splitResult.Second.Count > 0 ?
new TextLineBreak(splitResult.Second) : new TextLineBreak(splitResult.Second) :
@ -341,7 +247,7 @@ namespace Avalonia.Media.TextFormatting
currentWidth += currentRun.GlyphRun.Bounds.Width; currentWidth += currentRun.GlyphRun.Bounds.Width;
length += currentRun.GlyphRun.Characters.Length; currentLength += currentRun.GlyphRun.Characters.Length;
runIndex++; runIndex++;
} }
@ -356,7 +262,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textCharacters">The text run.</param> /// <param name="textCharacters">The text run.</param>
/// <param name="availableWidth">The available width.</param> /// <param name="availableWidth">The available width.</param>
/// <returns></returns> /// <returns></returns>
private static int MeasureText(ShapedTextCharacters textCharacters, double availableWidth) internal static int MeasureText(ShapedTextCharacters textCharacters, double availableWidth)
{ {
var glyphRun = textCharacters.GlyphRun; var glyphRun = textCharacters.GlyphRun;
@ -391,10 +297,8 @@ namespace Avalonia.Media.TextFormatting
} }
else 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) if (currentWidth + advance > availableWidth)
{ {
break; break;
@ -423,21 +327,6 @@ namespace Avalonia.Media.TextFormatting
return lastCluster - firstCluster; return lastCluster - firstCluster;
} }
/// <summary>
/// Creates an ellipsis.
/// </summary>
/// <param name="properties">The text run properties.</param>
/// <returns></returns>
private static ShapedTextCharacters CreateEllipsisRun(TextRunProperties properties)
{
var formatterImpl = AvaloniaLocator.Current.GetService<ITextShaperImpl>();
var glyphRun = formatterImpl.ShapeText(s_ellipsis, properties.Typeface, properties.FontRenderingEmSize,
properties.CultureInfo);
return new ShapedTextCharacters(glyphRun, properties);
}
/// <summary> /// <summary>
/// Gets the text range that is covered by the text runs. /// Gets the text range that is covered by the text runs.
/// </summary> /// </summary>
@ -470,7 +359,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textRuns">The text run's.</param> /// <param name="textRuns">The text run's.</param>
/// <param name="length">The length to split at.</param> /// <param name="length">The length to split at.</param>
/// <returns>The split text runs.</returns> /// <returns>The split text runs.</returns>
private static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextCharacters> textRuns, int length) internal static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextCharacters> textRuns, int length)
{ {
var currentLength = 0; var currentLength = 0;
@ -543,7 +432,7 @@ namespace Avalonia.Media.TextFormatting
return new SplitTextRunsResult(textRuns, null); return new SplitTextRunsResult(textRuns, null);
} }
private readonly struct SplitTextRunsResult internal readonly struct SplitTextRunsResult
{ {
public SplitTextRunsResult(IReadOnlyList<ShapedTextCharacters> first, IReadOnlyList<ShapedTextCharacters> second) public SplitTextRunsResult(IReadOnlyList<ShapedTextCharacters> first, IReadOnlyList<ShapedTextCharacters> second)
{ {

62
src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs

@ -1,9 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities; using Avalonia.Utilities;
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -17,6 +15,7 @@ namespace Avalonia.Media.TextFormatting
private readonly ReadOnlySlice<char> _text; private readonly ReadOnlySlice<char> _text;
private readonly TextParagraphProperties _paragraphProperties; private readonly TextParagraphProperties _paragraphProperties;
private readonly IReadOnlyList<ValueSpan<TextRunProperties>> _textStyleOverrides; private readonly IReadOnlyList<ValueSpan<TextRunProperties>> _textStyleOverrides;
private readonly TextTrimming _textTrimming;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class. /// Initializes a new instance of the <see cref="TextLayout" /> class.
@ -54,9 +53,11 @@ namespace Avalonia.Media.TextFormatting
new ReadOnlySlice<char>(text.AsMemory()); new ReadOnlySlice<char>(text.AsMemory());
_paragraphProperties = _paragraphProperties =
CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming, CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping,
textDecorations, lineHeight); textDecorations, lineHeight);
_textTrimming = textTrimming;
_textStyleOverrides = textStyleOverrides; _textStyleOverrides = textStyleOverrides;
LineHeight = lineHeight; LineHeight = lineHeight;
@ -143,18 +144,16 @@ namespace Avalonia.Media.TextFormatting
/// <param name="foreground">The foreground.</param> /// <param name="foreground">The foreground.</param>
/// <param name="textAlignment">The text alignment.</param> /// <param name="textAlignment">The text alignment.</param>
/// <param name="textWrapping">The text wrapping.</param> /// <param name="textWrapping">The text wrapping.</param>
/// <param name="textTrimming">The text trimming.</param>
/// <param name="textDecorations">The text decorations.</param> /// <param name="textDecorations">The text decorations.</param>
/// <param name="lineHeight">The height of each line of text.</param> /// <param name="lineHeight">The height of each line of text.</param>
/// <returns></returns> /// <returns></returns>
private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize, 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) TextDecorationCollection textDecorations, double lineHeight)
{ {
var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground); var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground);
return new GenericTextParagraphProperties(textRunStyle, textAlignment, textWrapping, textTrimming, return new GenericTextParagraphProperties(textRunStyle, textAlignment, textWrapping, lineHeight);
lineHeight);
} }
/// <summary> /// <summary>
@ -214,25 +213,44 @@ namespace Avalonia.Media.TextFormatting
var textSource = new FormattedTextSource(_text, var textSource = new FormattedTextSource(_text,
_paragraphProperties.DefaultTextRunProperties, _textStyleOverrides); _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, 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) 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 struct FormattedTextSource : ITextSource
{ {
private readonly ReadOnlySlice<char> _text; private readonly ReadOnlySlice<char> _text;

11
src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs

@ -39,6 +39,11 @@ namespace Avalonia.Media.TextFormatting
/// </returns> /// </returns>
public abstract TextLineBreak LineBreak { get; } public abstract TextLineBreak LineBreak { get; }
/// <summary>
/// Client to get a boolean value indicates whether a line has been collapsed
/// </summary>
public abstract bool HasCollapsed { get; }
/// <summary> /// <summary>
/// Draws the <see cref="TextLine"/> at the given origin. /// Draws the <see cref="TextLine"/> at the given origin.
/// </summary> /// </summary>
@ -46,6 +51,12 @@ namespace Avalonia.Media.TextFormatting
/// <param name="origin">The origin.</param> /// <param name="origin">The origin.</param>
public abstract void Draw(DrawingContext drawingContext, Point origin); public abstract void Draw(DrawingContext drawingContext, Point origin);
/// <summary>
/// Client to collapse the line and get a collapsed line that fits for display
/// </summary>
/// <param name="collapsingPropertiesList">a list of collapsing properties</param>
public abstract TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList);
/// <summary> /// <summary>
/// Client to get the character hit corresponding to the specified /// Client to get the character hit corresponding to the specified
/// distance from the beginning of the line. /// distance from the beginning of the line.

136
src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs

@ -1,4 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -7,11 +9,12 @@ namespace Avalonia.Media.TextFormatting
private readonly IReadOnlyList<ShapedTextCharacters> _textRuns; private readonly IReadOnlyList<ShapedTextCharacters> _textRuns;
public TextLineImpl(IReadOnlyList<ShapedTextCharacters> textRuns, TextLineMetrics lineMetrics, public TextLineImpl(IReadOnlyList<ShapedTextCharacters> textRuns, TextLineMetrics lineMetrics,
TextLineBreak lineBreak = null) TextLineBreak lineBreak = null, bool hasCollapsed = false)
{ {
_textRuns = textRuns; _textRuns = textRuns;
LineMetrics = lineMetrics; LineMetrics = lineMetrics;
LineBreak = lineBreak; LineBreak = lineBreak;
HasCollapsed = hasCollapsed;
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -26,6 +29,9 @@ namespace Avalonia.Media.TextFormatting
/// <inheritdoc/> /// <inheritdoc/>
public override TextLineBreak LineBreak { get; } public override TextLineBreak LineBreak { get; }
/// <inheritdoc/>
public override bool HasCollapsed { get; }
/// <inheritdoc/> /// <inheritdoc/>
public override void Draw(DrawingContext drawingContext, Point origin) 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<ShapedTextCharacters>(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<ShapedTextCharacters>(_textRuns) { shapedSymbol }, textLineMetrics, null,
true);
}
/// <inheritdoc/> /// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance) public override CharacterHit GetCharacterHitFromDistance(double distance)
{ {
@ -230,5 +328,41 @@ namespace Avalonia.Media.TextFormatting
return runIndex; return runIndex;
} }
/// <summary>
/// Creates a shaped symbol.
/// </summary>
/// <param name="textRun">The symbol run to shape.</param>
/// <returns>
/// The shaped symbol.
/// </returns>
internal static ShapedTextCharacters CreateShapedSymbol(TextRun textRun)
{
var formatterImpl = AvaloniaLocator.Current.GetService<ITextShaperImpl>();
var glyphRun = formatterImpl.ShapeText(textRun.Text, textRun.Properties.Typeface, textRun.Properties.FontRenderingEmSize,
textRun.Properties.CultureInfo);
return new ShapedTextCharacters(glyphRun, textRun.Properties);
}
/// <summary>
/// Gets the shaped width of specified shaped text characters.
/// </summary>
/// <param name="shapedTextCharacters">The shaped text characters.</param>
/// <returns>
/// The shaped width.
/// </returns>
private static double GetShapedWidth(IReadOnlyList<ShapedTextCharacters> shapedTextCharacters)
{
var shapedWidth = 0.0;
for (var i = 0; i < shapedTextCharacters.Count; i++)
{
shapedWidth += shapedTextCharacters[i].Bounds.Width;
}
return shapedWidth;
}
} }
} }

11
src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs

@ -9,11 +9,12 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
public readonly struct TextLineMetrics public readonly struct TextLineMetrics
{ {
public TextLineMetrics(Size size, double textBaseline, TextRange textRange) public TextLineMetrics(Size size, double textBaseline, TextRange textRange, bool hasOverflowed)
{ {
Size = size; Size = size;
TextBaseline = textBaseline; TextBaseline = textBaseline;
TextRange = textRange; TextRange = textRange;
HasOverflowed = hasOverflowed;
} }
/// <summary> /// <summary>
@ -37,6 +38,12 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
public double TextBaseline { get; } public double TextBaseline { get; }
/// <summary>
/// Gets a boolean value that indicates whether content of the line overflows
/// the specified paragraph width.
/// </summary>
public bool HasOverflowed { get; }
/// <summary> /// <summary>
/// Creates the text line metrics. /// Creates the text line metrics.
/// </summary> /// </summary>
@ -83,7 +90,7 @@ namespace Avalonia.Media.TextFormatting
descent - ascent + lineGap : descent - ascent + lineGap :
paragraphProperties.LineHeight); paragraphProperties.LineHeight);
return new TextLineMetrics(size, -ascent, textRange); return new TextLineMetrics(size, -ascent, textRange, size.Width > paragraphWidth);
} }
} }
} }

5
src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs

@ -26,11 +26,6 @@
/// </summary> /// </summary>
public abstract TextWrapping TextWrapping { get; } public abstract TextWrapping TextWrapping { get; }
/// <summary>
/// Gets the text trimming.
/// </summary>
public abstract TextTrimming TextTrimming { get; }
/// <summary> /// <summary>
/// Paragraph's line height /// Paragraph's line height
/// </summary> /// </summary>

33
src/Avalonia.Visuals/Media/TextFormatting/TextTrailingCharacterEllipsis.cs

@ -0,0 +1,33 @@
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// a collapsing properties to collapse whole line toward the end
/// at character granularity and with ellipsis being the collapsing symbol
/// </summary>
public class TextTrailingCharacterEllipsis : TextCollapsingProperties
{
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' });
/// <summary>
/// Construct a text trailing character ellipsis collapsing properties
/// </summary>
/// <param name="width">width in which collapsing is constrained to</param>
/// <param name="textRunProperties">text run properties of ellispis symbol</param>
public TextTrailingCharacterEllipsis(double width, TextRunProperties textRunProperties)
{
Width = width;
Symbol = new TextCharacters(s_ellipsis, textRunProperties);
}
/// <inheritdoc/>
public sealed override double Width { get; }
/// <inheritdoc/>
public sealed override TextRun Symbol { get; }
/// <inheritdoc/>
public sealed override TextCollapsingStyle Style { get; } = TextCollapsingStyle.TrailingCharacter;
}
}

37
src/Avalonia.Visuals/Media/TextFormatting/TextTrailingWordEllipsis.cs

@ -0,0 +1,37 @@
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// a collapsing properties to collapse whole line toward the end
/// at word granularity and with ellipsis being the collapsing symbol
/// </summary>
public class TextTrailingWordEllipsis : TextCollapsingProperties
{
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' });
/// <summary>
/// Construct a text trailing word ellipsis collapsing properties
/// </summary>
/// <param name="width">width in which collapsing is constrained to</param>
/// <param name="textRunProperties">text run properties of ellispis symbol</param>
public TextTrailingWordEllipsis(
double width,
TextRunProperties textRunProperties
)
{
Width = width;
Symbol = new TextCharacters(s_ellipsis, textRunProperties);
}
/// <inheritdoc/>
public sealed override double Width { get; }
/// <inheritdoc/>
public sealed override TextRun Symbol { get; }
/// <inheritdoc/>
public sealed override TextCollapsingStyle Style { get; } = TextCollapsingStyle.TrailingWord;
}
}

1
src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs

@ -109,7 +109,6 @@ namespace Avalonia.Media.TextFormatting.Unicode
{ {
case PairBreakType.DI: // Direct break case PairBreakType.DI: // Direct break
shouldBreak = true; shouldBreak = true;
_lastPos = _pos;
break; break;
case PairBreakType.IN: // possible indirect break case PairBreakType.IN: // possible indirect break

16
src/Avalonia.Visuals/Media/TextWrapping.cs

@ -5,13 +5,6 @@ namespace Avalonia.Media
/// </summary> /// </summary>
public enum TextWrapping public enum TextWrapping
{ {
/// <summary>
/// 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.
/// </summary>
WrapWithOverflow,
/// <summary> /// <summary>
/// Text should not wrap. /// Text should not wrap.
/// </summary> /// </summary>
@ -20,6 +13,13 @@ namespace Avalonia.Media
/// <summary> /// <summary>
/// Text can wrap. /// Text can wrap.
/// </summary> /// </summary>
Wrap Wrap,
/// <summary>
/// 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.
/// </summary>
WrapWithOverflow
} }
} }

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

@ -490,10 +490,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
} }
[InlineData("0123456789\r0123456789", 2)] [InlineData("0123456789\r0123456789")]
[InlineData("0123456789", 1)] [InlineData("0123456789")]
[Theory] [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()) using (Start())
{ {
@ -508,11 +508,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Typeface.Default, Typeface.Default,
12, 12,
Brushes.Black.ToImmutable(), 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);
} }
} }

58
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() private static IDisposable Start()
{ {
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

Loading…
Cancel
Save