Browse Source

Merge pull request #4101 from Gillibald/feature/TextRunProperties

Introduce TextRunProperties
pull/4236/head
danwalmsley 6 years ago
committed by GitHub
parent
commit
4d9fcaa02a
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 69
      samples/ControlCatalog/Pages/TextBlockPage.xaml
  2. 15
      src/Avalonia.Controls/Primitives/AccessText.cs
  3. 35
      src/Avalonia.Controls/TextBlock.cs
  4. 4
      src/Avalonia.Visuals/Media/FontManager.cs
  5. 112
      src/Avalonia.Visuals/Media/GlyphRun.cs
  6. 182
      src/Avalonia.Visuals/Media/TextDecoration.cs
  7. 17
      src/Avalonia.Visuals/Media/TextDecorationCollection.cs
  8. 2
      src/Avalonia.Visuals/Media/TextDecorationUnit.cs
  9. 6
      src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs
  10. 69
      src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs
  11. 40
      src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs
  12. 23
      src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs
  13. 164
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs
  14. 212
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs
  15. 395
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs
  16. 259
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs
  17. 181
      src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs
  18. 71
      src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs
  19. 148
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs
  20. 544
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs
  21. 192
      src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs
  22. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs
  23. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs
  24. 235
      src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs
  25. 69
      src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs
  26. 33
      src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs
  27. 16
      src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs
  28. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs
  29. 90
      src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs
  30. 8
      src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs
  31. 39
      src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs
  32. 24
      src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs
  33. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs
  34. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs
  35. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs
  36. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs
  37. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
  38. 9
      src/Avalonia.Visuals/Media/TextWrapping.cs
  39. 12
      src/Avalonia.Visuals/Media/Typeface.cs
  40. 12
      src/Avalonia.Visuals/Platform/ITextShaperImpl.cs
  41. 5
      src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs
  42. 30
      src/Avalonia.Visuals/Utilities/ValueSpan.cs
  43. 2
      src/Skia/Avalonia.Skia/FormattedTextImpl.cs
  44. 14
      src/Skia/Avalonia.Skia/TextShaperImpl.cs
  45. 15
      src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs
  46. 4
      tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs
  47. 2
      tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs
  48. 8
      tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs
  49. 4
      tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs
  50. 38
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs
  51. 36
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs
  52. 30
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs
  53. 275
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  54. 145
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  55. 175
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
  56. 373
      tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs
  57. 19
      tests/Avalonia.UnitTests/MockTextShaperImpl.cs
  58. 2
      tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
  59. 2
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs
  60. 2
      tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs

69
samples/ControlCatalog/Pages/TextBlockPage.xaml

@ -64,51 +64,42 @@
<TextDecorationCollection> <TextDecorationCollection>
<TextDecoration <TextDecoration
Location="Overline" Location="Overline"
PenThicknessUnit="Pixel"> StrokeThicknessUnit="Pixel"
<TextDecoration.Pen> StrokeThickness="2">
<Pen Thickness="2"> <TextDecoration.Stroke>
<Pen.Brush> <LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%"> <LinearGradientBrush.GradientStops>
<LinearGradientBrush.GradientStops> <GradientStop Offset="0" Color="Red"/>
<GradientStop Offset="0" Color="Red"/> <GradientStop Offset="1" Color="Green"/>
<GradientStop Offset="1" Color="Green"/> </LinearGradientBrush.GradientStops>
</LinearGradientBrush.GradientStops> </LinearGradientBrush>
</LinearGradientBrush> </TextDecoration.Stroke>
</Pen.Brush>
</Pen>
</TextDecoration.Pen>
</TextDecoration> </TextDecoration>
<TextDecoration <TextDecoration
Location="Strikethrough" Location="Strikethrough"
PenThicknessUnit="Pixel"> StrokeThicknessUnit="Pixel"
<TextDecoration.Pen> StrokeThickness="1">
<Pen Thickness="1"> <TextDecoration.Stroke>
<Pen.Brush> <LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%"> <LinearGradientBrush.GradientStops>
<LinearGradientBrush.GradientStops> <GradientStop Offset="0" Color="Green"/>
<GradientStop Offset="0" Color="Green"/> <GradientStop Offset="1" Color="Blue"/>
<GradientStop Offset="1" Color="Blue"/> </LinearGradientBrush.GradientStops>
</LinearGradientBrush.GradientStops> </LinearGradientBrush>
</LinearGradientBrush> </TextDecoration.Stroke>
</Pen.Brush>
</Pen>
</TextDecoration.Pen>
</TextDecoration> </TextDecoration>
<TextDecoration <TextDecoration
Location="Underline" Location="Underline"
PenThicknessUnit="Pixel"> StrokeThicknessUnit="Pixel"
<TextDecoration.Pen> StrokeThickness="2">
<Pen Thickness="2"> <TextDecoration.Stroke>
<Pen.Brush> <LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%"> <LinearGradientBrush.GradientStops>
<LinearGradientBrush.GradientStops> <GradientStop Offset="0" Color="Blue"/>
<GradientStop Offset="0" Color="Blue"/> <GradientStop Offset="1" Color="Red"/>
<GradientStop Offset="1" Color="Red"/> </LinearGradientBrush.GradientStops>
</LinearGradientBrush.GradientStops> </LinearGradientBrush>
</LinearGradientBrush> </TextDecoration.Stroke>
</Pen.Brush>
</Pen>
</TextDecoration.Pen>
</TextDecoration> </TextDecoration>
</TextDecorationCollection> </TextDecorationCollection>
</TextBlock.TextDecorations> </TextBlock.TextDecorations>

15
src/Avalonia.Controls/Primitives/AccessText.cs

@ -110,7 +110,7 @@ namespace Avalonia.Controls.Primitives
foreach (var textLine in TextLayout.TextLines) foreach (var textLine in TextLayout.TextLines)
{ {
if (textLine.Text.End < textPosition) if (textLine.TextRange.End < textPosition)
{ {
currentY += textLine.LineMetrics.Size.Height; currentY += textLine.LineMetrics.Size.Height;
@ -121,21 +121,22 @@ namespace Avalonia.Controls.Primitives
foreach (var textRun in textLine.TextRuns) foreach (var textRun in textLine.TextRuns)
{ {
if (!(textRun is ShapedTextRun shapedRun)) if (!(textRun is ShapedTextCharacters shapedTextCharacters))
{ {
continue; continue;
} }
if (shapedRun.GlyphRun.Characters.End < textPosition) if (shapedTextCharacters.GlyphRun.Characters.End < textPosition)
{ {
currentX += shapedRun.GlyphRun.Bounds.Width; currentX += shapedTextCharacters.GlyphRun.Bounds.Width;
continue; continue;
} }
var characterHit = shapedRun.GlyphRun.FindNearestCharacterHit(textPosition, out var width); var characterHit =
shapedTextCharacters.GlyphRun.FindNearestCharacterHit(textPosition, out var width);
var distance = shapedRun.GlyphRun.GetDistanceFromCharacterHit(characterHit); var distance = shapedTextCharacters.GlyphRun.GetDistanceFromCharacterHit(characterHit);
currentX += distance - width; currentX += distance - width;
@ -144,7 +145,7 @@ namespace Avalonia.Controls.Primitives
width = 0.0; width = 0.0;
} }
return new Rect(currentX, currentY, width, shapedRun.GlyphRun.Bounds.Height); return new Rect(currentX, currentY, width, shapedTextCharacters.GlyphRun.Bounds.Height);
} }
} }

35
src/Avalonia.Controls/TextBlock.cs

@ -70,6 +70,15 @@ namespace Avalonia.Controls
Brushes.Black, Brushes.Black,
inherits: true); inherits: true);
/// <summary>
/// Defines the <see cref="LineHeight"/> property.
/// </summary>
public static readonly StyledProperty<double> LineHeightProperty =
AvaloniaProperty.Register<TextBlock, double>(
nameof(LineHeight),
double.NaN,
validate: IsValidLineHeight);
/// <summary> /// <summary>
/// Defines the <see cref="MaxLines"/> property. /// Defines the <see cref="MaxLines"/> property.
/// </summary> /// </summary>
@ -122,19 +131,19 @@ namespace Avalonia.Controls
{ {
ClipToBoundsProperty.OverrideDefaultValue<TextBlock>(true); ClipToBoundsProperty.OverrideDefaultValue<TextBlock>(true);
AffectsRender<TextBlock>(BackgroundProperty, ForegroundProperty, AffectsRender<TextBlock>(BackgroundProperty, ForegroundProperty,
TextAlignmentProperty, TextDecorationsProperty); TextAlignmentProperty, TextDecorationsProperty);
AffectsMeasure<TextBlock>(FontSizeProperty, FontWeightProperty, AffectsMeasure<TextBlock>(FontSizeProperty, FontWeightProperty,
FontStyleProperty, TextWrappingProperty, FontFamilyProperty, FontStyleProperty, TextWrappingProperty, FontFamilyProperty,
TextTrimmingProperty, TextProperty, PaddingProperty); TextTrimmingProperty, TextProperty, PaddingProperty, LineHeightProperty, MaxLinesProperty);
Observable.Merge(TextProperty.Changed, ForegroundProperty.Changed, Observable.Merge(TextProperty.Changed, ForegroundProperty.Changed,
TextAlignmentProperty.Changed, TextWrappingProperty.Changed, TextAlignmentProperty.Changed, TextWrappingProperty.Changed,
TextTrimmingProperty.Changed, FontSizeProperty.Changed, TextTrimmingProperty.Changed, FontSizeProperty.Changed,
FontStyleProperty.Changed, FontWeightProperty.Changed, FontStyleProperty.Changed, FontWeightProperty.Changed,
FontFamilyProperty.Changed, TextDecorationsProperty.Changed, FontFamilyProperty.Changed, TextDecorationsProperty.Changed,
PaddingProperty.Changed PaddingProperty.Changed, MaxLinesProperty.Changed, LineHeightProperty.Changed
).AddClassHandler<TextBlock>((x, _) => x.InvalidateTextLayout()); ).AddClassHandler<TextBlock>((x, _) => x.InvalidateTextLayout());
} }
@ -230,6 +239,15 @@ namespace Avalonia.Controls
set { SetValue(ForegroundProperty, value); } set { SetValue(ForegroundProperty, value); }
} }
/// <summary>
/// Gets or sets the height of each line of content.
/// </summary>
public double LineHeight
{
get => GetValue(LineHeightProperty);
set => SetValue(LineHeightProperty, value);
}
/// <summary> /// <summary>
/// Gets or sets the maximum number of text lines. /// Gets or sets the maximum number of text lines.
/// </summary> /// </summary>
@ -395,7 +413,7 @@ namespace Avalonia.Controls
var padding = Padding; var padding = Padding;
TextLayout?.Draw(context.PlatformImpl, new Point(padding.Left, padding.Top)); TextLayout?.Draw(context, new Point(padding.Left, padding.Top));
} }
/// <summary> /// <summary>
@ -422,7 +440,8 @@ namespace Avalonia.Controls
TextDecorations, TextDecorations,
constraint.Width, constraint.Width,
constraint.Height, constraint.Height,
MaxLines); maxLines: MaxLines,
lineHeight: LineHeight);
} }
/// <summary> /// <summary>
@ -471,5 +490,7 @@ namespace Avalonia.Controls
} }
private static bool IsValidMaxLines(int maxLines) => maxLines >= 0; private static bool IsValidMaxLines(int maxLines) => maxLines >= 0;
private static bool IsValidLineHeight(double lineHeight) => double.IsNaN(lineHeight) || lineHeight > 0;
} }
} }

4
src/Avalonia.Visuals/Media/FontManager.cs

@ -100,7 +100,7 @@ namespace Avalonia.Media
return typeface; return typeface;
} }
typeface = new Typeface(fontFamily, fontWeight, fontStyle); typeface = new Typeface(fontFamily, fontStyle, fontWeight);
if (_typefaceCache.TryAdd(key, typeface)) if (_typefaceCache.TryAdd(key, typeface))
{ {
@ -143,7 +143,7 @@ namespace Avalonia.Media
} }
var matchedTypeface = PlatformImpl.TryMatchCharacter(codepoint, fontWeight, fontStyle, fontFamily, culture, out var key) ? var matchedTypeface = PlatformImpl.TryMatchCharacter(codepoint, fontWeight, fontStyle, fontFamily, culture, out var key) ?
_typefaceCache.GetOrAdd(key, new Typeface(key.FamilyName, key.Weight, key.Style)) : _typefaceCache.GetOrAdd(key, new Typeface(key.FamilyName, key.Style, key.Weight)) :
null; null;
return matchedTypeface; return matchedTypeface;

112
src/Avalonia.Visuals/Media/GlyphRun.cs

@ -1,7 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media namespace Avalonia.Media
{ {
@ -205,13 +205,16 @@ namespace Avalonia.Media
var glyphIndex = FindGlyphIndex(characterHit.FirstCharacterIndex); var glyphIndex = FindGlyphIndex(characterHit.FirstCharacterIndex);
var currentCluster = _glyphClusters[glyphIndex]; if (!GlyphClusters.IsEmpty)
if (characterHit.TrailingLength > 0)
{ {
while (glyphIndex < _glyphClusters.Length && _glyphClusters[glyphIndex] == currentCluster) var currentCluster = GlyphClusters[glyphIndex];
if (characterHit.TrailingLength > 0)
{ {
glyphIndex++; while (glyphIndex < GlyphClusters.Length && GlyphClusters[glyphIndex] == currentCluster)
{
glyphIndex++;
}
} }
} }
@ -302,7 +305,7 @@ namespace Avalonia.Media
} }
} }
var characterHit = FindNearestCharacterHit(GlyphClusters[index], out var width); var characterHit = FindNearestCharacterHit(GlyphClusters.IsEmpty ? index : GlyphClusters[index], out var width);
var offset = GetDistanceFromCharacterHit(new CharacterHit(characterHit.FirstCharacterIndex)); var offset = GetDistanceFromCharacterHit(new CharacterHit(characterHit.FirstCharacterIndex));
@ -370,26 +373,31 @@ namespace Avalonia.Media
/// </returns> /// </returns>
public int FindGlyphIndex(int characterIndex) public int FindGlyphIndex(int characterIndex)
{ {
if (GlyphClusters.IsEmpty)
{
return characterIndex;
}
if (IsLeftToRight) if (IsLeftToRight)
{ {
if (characterIndex < _glyphClusters[0]) if (characterIndex < GlyphClusters[0])
{ {
return 0; return 0;
} }
if (characterIndex > _glyphClusters[_glyphClusters.Length - 1]) if (characterIndex > GlyphClusters[GlyphClusters.Length - 1])
{ {
return _glyphClusters.End; return _glyphClusters.End;
} }
} }
else else
{ {
if (characterIndex < _glyphClusters[_glyphClusters.Length - 1]) if (characterIndex < GlyphClusters[GlyphClusters.Length - 1])
{ {
return _glyphClusters.End; return _glyphClusters.End;
} }
if (characterIndex > _glyphClusters[0]) if (characterIndex > GlyphClusters[0])
{ {
return 0; return 0;
} }
@ -397,7 +405,7 @@ namespace Avalonia.Media
var comparer = IsLeftToRight ? s_ascendingComparer : s_descendingComparer; var comparer = IsLeftToRight ? s_ascendingComparer : s_descendingComparer;
var clusters = _glyphClusters.Buffer.Span; var clusters = GlyphClusters.Buffer.Span;
// Find the start of the cluster at the character index. // Find the start of the cluster at the character index.
var start = clusters.BinarySearch((ushort)characterIndex, comparer); var start = clusters.BinarySearch((ushort)characterIndex, comparer);
@ -418,9 +426,19 @@ namespace Avalonia.Media
} }
} }
while (start > 0 && clusters[start - 1] == clusters[start]) if (IsLeftToRight)
{ {
start--; while (start > 0 && clusters[start - 1] == clusters[start])
{
start--;
}
}
else
{
while (start + 1 < clusters.Length && clusters[start + 1] == clusters[start])
{
start++;
}
} }
return start; return start;
@ -440,34 +458,74 @@ namespace Avalonia.Media
var start = FindGlyphIndex(index); var start = FindGlyphIndex(index);
var currentCluster = _glyphClusters[start]; if (GlyphClusters.IsEmpty)
{
width = GetGlyphWidth(index);
return new CharacterHit(start, 1);
}
var trailingLength = 0; var cluster = GlyphClusters[start];
while (start < _glyphClusters.Length && _glyphClusters[start] == currentCluster) var nextCluster = cluster;
var currentIndex = start;
while (nextCluster == cluster)
{ {
if (GlyphAdvances.IsEmpty) width += GetGlyphWidth(currentIndex);
if (IsLeftToRight)
{ {
var glyph = GlyphIndices[start]; currentIndex++;
width += GlyphTypeface.GetGlyphAdvance(glyph) * Scale; if (currentIndex == GlyphClusters.Length)
{
break;
}
} }
else else
{ {
width += GlyphAdvances[start]; currentIndex--;
if (currentIndex < 0)
{
break;
}
} }
trailingLength++; nextCluster = GlyphClusters[currentIndex];
start++; }
int trailingLength;
if (nextCluster == cluster)
{
trailingLength = Characters.Start + Characters.Length - cluster;
}
else
{
trailingLength = nextCluster - cluster;
} }
if (start == _glyphClusters.Length && return new CharacterHit(cluster, trailingLength);
currentCluster + trailingLength != Characters.Start + Characters.Length) }
/// <summary>
/// Gets a glyph's width.
/// </summary>
/// <param name="index">The glyph index.</param>
/// <returns>The glyph's width.</returns>
private double GetGlyphWidth(int index)
{
if (GlyphAdvances.IsEmpty)
{ {
trailingLength = Characters.Start + Characters.Length - currentCluster; var glyph = GlyphIndices[index];
return GlyphTypeface.GetGlyphAdvance(glyph) * Scale;
} }
return new CharacterHit(currentCluster, trailingLength); return GlyphAdvances[index];
} }
/// <summary> /// <summary>

182
src/Avalonia.Visuals/Media/TextDecoration.cs

@ -1,4 +1,5 @@
using Avalonia.Media.Immutable; using Avalonia.Collections;
using Avalonia.Media.TextFormatting;
namespace Avalonia.Media namespace Avalonia.Media
{ {
@ -14,28 +15,52 @@ namespace Avalonia.Media
AvaloniaProperty.Register<TextDecoration, TextDecorationLocation>(nameof(Location)); AvaloniaProperty.Register<TextDecoration, TextDecorationLocation>(nameof(Location));
/// <summary> /// <summary>
/// Defines the <see cref="Pen"/> property. /// Defines the <see cref="Stroke"/> property.
/// </summary> /// </summary>
public static readonly StyledProperty<IPen> PenProperty = public static readonly StyledProperty<IBrush> StrokeProperty =
AvaloniaProperty.Register<TextDecoration, IPen>(nameof(Pen)); AvaloniaProperty.Register<TextDecoration, IBrush>(nameof(Stroke));
/// <summary> /// <summary>
/// Defines the <see cref="PenThicknessUnit"/> property. /// Defines the <see cref="StrokeThicknessUnit"/> property.
/// </summary> /// </summary>
public static readonly StyledProperty<TextDecorationUnit> PenThicknessUnitProperty = public static readonly StyledProperty<TextDecorationUnit> StrokeThicknessUnitProperty =
AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(PenThicknessUnit)); AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(StrokeThicknessUnit));
/// <summary> /// <summary>
/// Defines the <see cref="PenOffset"/> property. /// Defines the <see cref="StrokeDashArray"/> property.
/// </summary> /// </summary>
public static readonly StyledProperty<double> PenOffsetProperty = public static readonly StyledProperty<AvaloniaList<double>> StrokeDashArrayProperty =
AvaloniaProperty.Register<TextDecoration, double>(nameof(PenOffset)); AvaloniaProperty.Register<TextDecoration, AvaloniaList<double>>(nameof(StrokeDashArray));
/// <summary> /// <summary>
/// Defines the <see cref="PenOffsetUnit"/> property. /// Defines the <see cref="StrokeDashOffset"/> property.
/// </summary> /// </summary>
public static readonly StyledProperty<TextDecorationUnit> PenOffsetUnitProperty = public static readonly StyledProperty<double> StrokeDashOffsetProperty =
AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(PenOffsetUnit)); AvaloniaProperty.Register<TextDecoration, double>(nameof(StrokeDashOffset));
/// <summary>
/// Defines the <see cref="StrokeThickness"/> property.
/// </summary>
public static readonly StyledProperty<double> StrokeThicknessProperty =
AvaloniaProperty.Register<TextDecoration, double>(nameof(StrokeThickness), 1);
/// <summary>
/// Defines the <see cref="StrokeLineCap"/> property.
/// </summary>
public static readonly StyledProperty<PenLineCap> StrokeLineCapProperty =
AvaloniaProperty.Register<TextDecoration, PenLineCap>(nameof(StrokeLineCap));
/// <summary>
/// Defines the <see cref="StrokeOffset"/> property.
/// </summary>
public static readonly StyledProperty<double> StrokeOffsetProperty =
AvaloniaProperty.Register<TextDecoration, double>(nameof(StrokeOffset));
/// <summary>
/// Defines the <see cref="StrokeOffsetUnit"/> property.
/// </summary>
public static readonly StyledProperty<TextDecorationUnit> StrokeOffsetUnitProperty =
AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(StrokeOffsetUnit));
/// <summary> /// <summary>
/// Gets or sets the location. /// Gets or sets the location.
@ -50,54 +75,139 @@ namespace Avalonia.Media
} }
/// <summary> /// <summary>
/// Gets or sets the pen. /// Gets or sets the <see cref="IBrush"/> that specifies how the <see cref="TextDecoration"/> is painted.
/// </summary> /// </summary>
/// <value> public IBrush Stroke
/// The pen. {
/// </value> get { return GetValue(StrokeProperty); }
public IPen Pen set { SetValue(StrokeProperty, value); }
}
/// <summary>
/// Gets the units in which the thickness of the <see cref="TextDecoration"/> is expressed.
/// </summary>
public TextDecorationUnit StrokeThicknessUnit
{
get => GetValue(StrokeThicknessUnitProperty);
set => SetValue(StrokeThicknessUnitProperty, value);
}
/// <summary>
/// Gets or sets a collection of <see cref="double"/> values that indicate the pattern of dashes and gaps
/// that is used to draw the <see cref="TextDecoration"/>.
/// </summary>
public AvaloniaList<double> StrokeDashArray
{
get { return GetValue(StrokeDashArrayProperty); }
set { SetValue(StrokeDashArrayProperty, value); }
}
/// <summary>
/// Gets or sets a value that specifies the distance within the dash pattern where a dash begins.
/// </summary>
public double StrokeDashOffset
{
get { return GetValue(StrokeDashOffsetProperty); }
set { SetValue(StrokeDashOffsetProperty, value); }
}
/// <summary>
/// Gets or sets the thickness of the <see cref="TextDecoration"/>.
/// </summary>
public double StrokeThickness
{ {
get => GetValue(PenProperty); get { return GetValue(StrokeThicknessProperty); }
set => SetValue(PenProperty, value); set { SetValue(StrokeThicknessProperty, value); }
} }
/// <summary> /// <summary>
/// Gets the units in which the Thickness of the text decoration's <see cref="Pen"/> is expressed. /// Gets or sets a <see cref="PenLineCap"/> enumeration value that describes the shape at the ends of a line.
/// </summary> /// </summary>
public TextDecorationUnit PenThicknessUnit public PenLineCap StrokeLineCap
{ {
get => GetValue(PenThicknessUnitProperty); get { return GetValue(StrokeLineCapProperty); }
set => SetValue(PenThicknessUnitProperty, value); set { SetValue(StrokeLineCapProperty, value); }
} }
/// <summary> /// <summary>
/// Gets or sets the pen offset. /// The stroke's offset.
/// </summary> /// </summary>
/// <value> /// <value>
/// The pen offset. /// The pen offset.
/// </value> /// </value>
public double PenOffset public double StrokeOffset
{ {
get => GetValue(PenOffsetProperty); get => GetValue(StrokeOffsetProperty);
set => SetValue(PenOffsetProperty, value); set => SetValue(StrokeOffsetProperty, value);
} }
/// <summary> /// <summary>
/// Gets the units in which the <see cref="PenOffset"/> value is expressed. /// Gets the units in which the <see cref="StrokeOffset"/> value is expressed.
/// </summary> /// </summary>
public TextDecorationUnit PenOffsetUnit public TextDecorationUnit StrokeOffsetUnit
{ {
get => GetValue(PenOffsetUnitProperty); get => GetValue(StrokeOffsetUnitProperty);
set => SetValue(PenOffsetUnitProperty, value); set => SetValue(StrokeOffsetUnitProperty, value);
} }
/// <summary> /// <summary>
/// Creates an immutable clone of the <see cref="TextDecoration"/>. /// Draws the <see cref="TextDecoration"/> at given origin.
/// </summary> /// </summary>
/// <returns>The immutable clone.</returns> /// <param name="drawingContext">The drawing context.</param>
public ImmutableTextDecoration ToImmutable() /// <param name="shapedTextCharacters">The shaped characters that are decorated.</param>
/// <param name="origin">The origin.</param>
internal void Draw(DrawingContext drawingContext, ShapedTextCharacters shapedTextCharacters, Point origin)
{ {
return new ImmutableTextDecoration(Location, Pen?.ToImmutable(), PenThicknessUnit, PenOffset, PenOffsetUnit); var fontRenderingEmSize = shapedTextCharacters.Properties.FontRenderingEmSize;
var fontMetrics = shapedTextCharacters.FontMetrics;
var thickness = StrokeThickness;
switch (StrokeThicknessUnit)
{
case TextDecorationUnit.FontRecommended:
switch (Location)
{
case TextDecorationLocation.Underline:
thickness = fontMetrics.UnderlineThickness;
break;
case TextDecorationLocation.Strikethrough:
thickness = fontMetrics.StrikethroughThickness;
break;
}
break;
case TextDecorationUnit.FontRenderingEmSize:
thickness = fontRenderingEmSize * thickness;
break;
}
switch (Location)
{
case TextDecorationLocation.Overline:
origin += new Point(0, fontMetrics.Ascent);
break;
case TextDecorationLocation.Strikethrough:
origin += new Point(0, -fontMetrics.StrikethroughPosition);
break;
case TextDecorationLocation.Underline:
origin += new Point(0, -fontMetrics.UnderlinePosition);
break;
}
switch (StrokeOffsetUnit)
{
case TextDecorationUnit.FontRenderingEmSize:
origin += new Point(0, StrokeOffset * fontRenderingEmSize);
break;
case TextDecorationUnit.Pixel:
origin += new Point(0, StrokeOffset);
break;
}
var pen = new Pen(Stroke ?? shapedTextCharacters.Properties.ForegroundBrush, thickness,
new DashStyle(StrokeDashArray, StrokeDashOffset), StrokeLineCap);
drawingContext.DrawLine(pen, origin, origin + new Point(shapedTextCharacters.Bounds.Width, 0));
} }
} }
} }

17
src/Avalonia.Visuals/Media/TextDecorationCollection.cs

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Media.Immutable;
using Avalonia.Utilities; using Avalonia.Utilities;
namespace Avalonia.Media namespace Avalonia.Media
@ -11,22 +10,6 @@ namespace Avalonia.Media
/// </summary> /// </summary>
public class TextDecorationCollection : AvaloniaList<TextDecoration> public class TextDecorationCollection : AvaloniaList<TextDecoration>
{ {
/// <summary>
/// Creates an immutable clone of the <see cref="TextDecorationCollection"/>.
/// </summary>
/// <returns>The immutable clone.</returns>
public ImmutableTextDecoration[] ToImmutable()
{
var immutable = new ImmutableTextDecoration[Count];
for (var i = 0; i < Count; i++)
{
immutable[i] = this[i].ToImmutable();
}
return immutable;
}
/// <summary> /// <summary>
/// Parses a <see cref="TextDecorationCollection"/> string. /// Parses a <see cref="TextDecorationCollection"/> string.
/// </summary> /// </summary>

2
src/Avalonia.Visuals/Media/TextDecorationUnit.cs

@ -1,7 +1,7 @@
namespace Avalonia.Media namespace Avalonia.Media
{ {
/// <summary> /// <summary>
/// Specifies the unit type of either a <see cref="TextDecoration.PenOffset"/> or a <see cref="Pen"/> thickness value. /// Specifies the unit type of either a <see cref="TextDecoration.StrokeOffset"/> or a <see cref="TextDecoration.StrokeThickness"/> value.
/// </summary> /// </summary>
public enum TextDecorationUnit public enum TextDecorationUnit
{ {

6
src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs

@ -1,6 +1,4 @@
using Avalonia.Platform; namespace Avalonia.Media.TextFormatting
namespace Avalonia.Media.TextFormatting
{ {
/// <summary> /// <summary>
/// A text run that supports drawing content. /// A text run that supports drawing content.
@ -17,6 +15,6 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
/// <param name="drawingContext">The drawing context.</param> /// <param name="drawingContext">The drawing context.</param>
/// <param name="origin">The origin.</param> /// <param name="origin">The origin.</param>
public abstract void Draw(IDrawingContextImpl drawingContext, Point origin); public abstract void Draw(DrawingContext drawingContext, Point origin);
} }
} }

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

@ -0,0 +1,69 @@
namespace Avalonia.Media.TextFormatting
{
public class GenericTextParagraphProperties : TextParagraphProperties
{
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,
double lineHeight = 0)
{
DefaultTextRunProperties = defaultTextRunProperties;
_textAlignment = textAlignment;
_textWrapping = textWrapping;
_textTrimming = textTrimming;
_lineHeight = lineHeight;
}
public override TextRunProperties DefaultTextRunProperties { get; }
public override TextAlignment TextAlignment => _textAlignment;
public override TextWrapping TextWrapping => _textWrapping;
public override TextTrimming TextTrimming => _textTrimming;
public override double LineHeight => _lineHeight;
/// <summary>
/// Set text alignment
/// </summary>
internal void SetTextAlignment(TextAlignment textAlignment)
{
_textAlignment = textAlignment;
}
/// <summary>
/// Set text wrap
/// </summary>
internal void SetTextWrapping(TextWrapping textWrapping)
{
_textWrapping = textWrapping;
}
/// <summary>
/// Set text trimming
/// </summary>
internal void SetTextTrimming(TextTrimming textTrimming)
{
_textTrimming = textTrimming;
}
/// <summary>
/// Set line height
/// </summary>
internal void SetLineHeight(double lineHeight)
{
_lineHeight = lineHeight;
}
}
}

40
src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs

@ -0,0 +1,40 @@
using System.Globalization;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Generic implementation of TextRunProperties
/// </summary>
public class GenericTextRunProperties : TextRunProperties
{
public GenericTextRunProperties(Typeface typeface, double fontRenderingEmSize = 12,
TextDecorationCollection textDecorations = null, IBrush foregroundBrush = null, IBrush backgroundBrush = null,
CultureInfo cultureInfo = null)
{
Typeface = typeface;
FontRenderingEmSize = fontRenderingEmSize;
TextDecorations = textDecorations;
ForegroundBrush = foregroundBrush;
BackgroundBrush = backgroundBrush;
CultureInfo = cultureInfo;
}
/// <inheritdoc />
public override Typeface Typeface { get; }
/// <inheritdoc />
public override double FontRenderingEmSize { get; }
/// <inheritdoc />
public override TextDecorationCollection TextDecorations { get; }
/// <inheritdoc />
public override IBrush ForegroundBrush { get; }
/// <inheritdoc />
public override IBrush BackgroundBrush { get; }
/// <inheritdoc />
public override CultureInfo CultureInfo { get; }
}
}

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

@ -0,0 +1,23 @@
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// A group of characters that can be shaped.
/// </summary>
public sealed class ShapeableTextCharacters : TextRun
{
public ShapeableTextCharacters(ReadOnlySlice<char> text, TextRunProperties properties)
{
TextSourceLength = text.Length;
Text = text;
Properties = properties;
}
public override int TextSourceLength { get; }
public override ReadOnlySlice<char> Text { get; }
public override TextRunProperties Properties { get; }
}
}

164
src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs

@ -0,0 +1,164 @@
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// A text run that holds shaped characters.
/// </summary>
public sealed class ShapedTextCharacters : DrawableTextRun
{
public ShapedTextCharacters(GlyphRun glyphRun, TextRunProperties properties)
{
Text = glyphRun.Characters;
Properties = properties;
TextSourceLength = Text.Length;
FontMetrics = new FontMetrics(Properties.Typeface, Properties.FontRenderingEmSize);
GlyphRun = glyphRun;
}
/// <inheritdoc/>
public override ReadOnlySlice<char> Text { get; }
/// <inheritdoc/>
public override TextRunProperties Properties { get; }
/// <inheritdoc/>
public override int TextSourceLength { get; }
/// <inheritdoc/>
public override Rect Bounds => GlyphRun.Bounds;
/// <summary>
/// Gets the font metrics.
/// </summary>
/// <value>
/// The font metrics.
/// </value>
public FontMetrics FontMetrics { get; }
/// <summary>
/// Gets the glyph run.
/// </summary>
/// <value>
/// The glyphs.
/// </value>
public GlyphRun GlyphRun { get; }
/// <inheritdoc/>
public override void Draw(DrawingContext drawingContext, Point origin)
{
if (GlyphRun.GlyphIndices.Length == 0)
{
return;
}
if (Properties.Typeface == null)
{
return;
}
if (Properties.ForegroundBrush == null)
{
return;
}
if (Properties.BackgroundBrush != null)
{
drawingContext.DrawRectangle(Properties.BackgroundBrush, null,
new Rect(origin.X, origin.Y + FontMetrics.Ascent, Bounds.Width, Bounds.Height));
}
drawingContext.DrawGlyphRun(Properties.ForegroundBrush, GlyphRun, origin);
if (Properties.TextDecorations == null)
{
return;
}
foreach (var textDecoration in Properties.TextDecorations)
{
textDecoration.Draw(drawingContext, this, origin);
}
}
/// <summary>
/// Splits the <see cref="TextRun"/> at specified length.
/// </summary>
/// <param name="length">The length.</param>
/// <returns>The split result.</returns>
public SplitTextCharactersResult Split(int length)
{
var glyphCount = 0;
var firstCharacters = GlyphRun.Characters.Take(length);
var codepointEnumerator = new CodepointEnumerator(firstCharacters);
while (codepointEnumerator.MoveNext())
{
glyphCount++;
}
if (GlyphRun.Characters.Length == length)
{
return new SplitTextCharactersResult(this, null);
}
if (GlyphRun.GlyphIndices.Length == glyphCount)
{
return new SplitTextCharactersResult(this, null);
}
var firstGlyphRun = new GlyphRun(
Properties.Typeface.GlyphTypeface,
Properties.FontRenderingEmSize,
GlyphRun.GlyphIndices.Take(glyphCount),
GlyphRun.GlyphAdvances.Take(glyphCount),
GlyphRun.GlyphOffsets.Take(glyphCount),
GlyphRun.Characters.Take(length),
GlyphRun.GlyphClusters.Take(glyphCount));
var firstTextRun = new ShapedTextCharacters(firstGlyphRun, Properties);
var secondGlyphRun = new GlyphRun(
Properties.Typeface.GlyphTypeface,
Properties.FontRenderingEmSize,
GlyphRun.GlyphIndices.Skip(glyphCount),
GlyphRun.GlyphAdvances.Skip(glyphCount),
GlyphRun.GlyphOffsets.Skip(glyphCount),
GlyphRun.Characters.Skip(length),
GlyphRun.GlyphClusters.Skip(glyphCount));
var secondTextRun = new ShapedTextCharacters(secondGlyphRun, Properties);
return new SplitTextCharactersResult(firstTextRun, secondTextRun);
}
public readonly struct SplitTextCharactersResult
{
public SplitTextCharactersResult(ShapedTextCharacters first, ShapedTextCharacters second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text run.
/// </summary>
/// <value>
/// The first text run.
/// </value>
public ShapedTextCharacters First { get; }
/// <summary>
/// Gets the second text run.
/// </summary>
/// <value>
/// The second text run.
/// </value>
public ShapedTextCharacters Second { get; }
}
}
}

212
src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs

@ -1,212 +0,0 @@
using Avalonia.Media.Immutable;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utility;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// A text run that holds a shaped glyph run.
/// </summary>
public sealed class ShapedTextRun : DrawableTextRun
{
public ShapedTextRun(ReadOnlySlice<char> text, TextStyle style) : this(
TextShaper.Current.ShapeText(text, style.TextFormat), style)
{
}
public ShapedTextRun(GlyphRun glyphRun, TextStyle style)
{
Text = glyphRun.Characters;
Style = style;
GlyphRun = glyphRun;
}
/// <inheritdoc/>
public override Rect Bounds => GlyphRun.Bounds;
/// <summary>
/// Gets the glyph run.
/// </summary>
/// <value>
/// The glyphs.
/// </value>
public GlyphRun GlyphRun { get; }
/// <inheritdoc/>
public override void Draw(IDrawingContextImpl drawingContext, Point origin)
{
if (GlyphRun.GlyphIndices.Length == 0)
{
return;
}
if (Style.TextFormat.Typeface == null)
{
return;
}
if (Style.Foreground == null)
{
return;
}
drawingContext.DrawGlyphRun(Style.Foreground, GlyphRun, origin);
if (Style.TextDecorations == null)
{
return;
}
foreach (var textDecoration in Style.TextDecorations)
{
DrawTextDecoration(drawingContext, textDecoration, origin);
}
}
/// <summary>
/// Draws the <see cref="TextDecoration"/> at given origin.
/// </summary>
/// <param name="drawingContext">The drawing context.</param>
/// <param name="textDecoration">The text decoration.</param>
/// <param name="origin">The origin.</param>
private void DrawTextDecoration(IDrawingContextImpl drawingContext, ImmutableTextDecoration textDecoration, Point origin)
{
var textFormat = Style.TextFormat;
var fontMetrics = Style.TextFormat.FontMetrics;
var thickness = textDecoration.Pen?.Thickness ?? 1.0;
switch (textDecoration.PenThicknessUnit)
{
case TextDecorationUnit.FontRecommended:
switch (textDecoration.Location)
{
case TextDecorationLocation.Underline:
thickness = fontMetrics.UnderlineThickness;
break;
case TextDecorationLocation.Strikethrough:
thickness = fontMetrics.StrikethroughThickness;
break;
}
break;
case TextDecorationUnit.FontRenderingEmSize:
thickness = textFormat.FontRenderingEmSize * thickness;
break;
}
switch (textDecoration.Location)
{
case TextDecorationLocation.Overline:
origin += new Point(0, textFormat.FontMetrics.Ascent);
break;
case TextDecorationLocation.Strikethrough:
origin += new Point(0, -textFormat.FontMetrics.StrikethroughPosition);
break;
case TextDecorationLocation.Underline:
origin += new Point(0, -textFormat.FontMetrics.UnderlinePosition);
break;
}
switch (textDecoration.PenOffsetUnit)
{
case TextDecorationUnit.FontRenderingEmSize:
origin += new Point(0, textDecoration.PenOffset * textFormat.FontRenderingEmSize);
break;
case TextDecorationUnit.Pixel:
origin += new Point(0, textDecoration.PenOffset);
break;
}
var pen = new ImmutablePen(
textDecoration.Pen?.Brush ?? Style.Foreground.ToImmutable(),
thickness,
textDecoration.Pen?.DashStyle?.ToImmutable(),
textDecoration.Pen?.LineCap ?? default,
textDecoration.Pen?.LineJoin ?? PenLineJoin.Miter,
textDecoration.Pen?.MiterLimit ?? 10.0);
drawingContext.DrawLine(pen, origin, origin + new Point(GlyphRun.Bounds.Width, 0));
}
/// <summary>
/// Splits the <see cref="TextRun"/> at specified length.
/// </summary>
/// <param name="length">The length.</param>
/// <returns>The split result.</returns>
public SplitTextCharactersResult Split(int length)
{
var glyphCount = 0;
var firstCharacters = GlyphRun.Characters.Take(length);
var codepointEnumerator = new CodepointEnumerator(firstCharacters);
while (codepointEnumerator.MoveNext())
{
glyphCount++;
}
if (GlyphRun.Characters.Length == length)
{
return new SplitTextCharactersResult(this, null);
}
if (GlyphRun.GlyphIndices.Length == glyphCount)
{
return new SplitTextCharactersResult(this, null);
}
var firstGlyphRun = new GlyphRun(
Style.TextFormat.Typeface.GlyphTypeface,
Style.TextFormat.FontRenderingEmSize,
GlyphRun.GlyphIndices.Take(glyphCount),
GlyphRun.GlyphAdvances.Take(glyphCount),
GlyphRun.GlyphOffsets.Take(glyphCount),
GlyphRun.Characters.Take(length),
GlyphRun.GlyphClusters.Take(length));
var firstTextRun = new ShapedTextRun(firstGlyphRun, Style);
var secondGlyphRun = new GlyphRun(
Style.TextFormat.Typeface.GlyphTypeface,
Style.TextFormat.FontRenderingEmSize,
GlyphRun.GlyphIndices.Skip(glyphCount),
GlyphRun.GlyphAdvances.Skip(glyphCount),
GlyphRun.GlyphOffsets.Skip(glyphCount),
GlyphRun.Characters.Skip(length),
GlyphRun.GlyphClusters.Skip(length));
var secondTextRun = new ShapedTextRun(secondGlyphRun, Style);
return new SplitTextCharactersResult(firstTextRun, secondTextRun);
}
public readonly struct SplitTextCharactersResult
{
public SplitTextCharactersResult(ShapedTextRun first, ShapedTextRun second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text run.
/// </summary>
/// <value>
/// The first text run.
/// </value>
public ShapedTextRun First { get; }
/// <summary>
/// Gets the second text run.
/// </summary>
/// <value>
/// The second text run.
/// </value>
public ShapedTextRun Second { get; }
}
}
}

395
src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs

@ -1,395 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utility;
namespace Avalonia.Media.TextFormatting
{
internal class SimpleTextFormatter : TextFormatter
{
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' });
/// <inheritdoc cref="TextFormatter.FormatLine"/>
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties)
{
var textTrimming = paragraphProperties.TextTrimming;
var textWrapping = paragraphProperties.TextWrapping;
TextLine textLine;
var textRuns = FormatTextRuns(textSource, firstTextSourceIndex, out var textPointer);
if (textTrimming != TextTrimming.None)
{
textLine = PerformTextTrimming(textPointer, textRuns, paragraphWidth, paragraphProperties);
}
else
{
if (textWrapping == TextWrapping.Wrap)
{
textLine = PerformTextWrapping(textPointer, textRuns, paragraphWidth, paragraphProperties);
}
else
{
var textLineMetrics =
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment);
textLine = new SimpleTextLine(textPointer, textRuns, textLineMetrics);
}
}
return textLine;
}
/// <summary>
/// Formats text runs with optional text style overrides.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="firstTextSourceIndex">The first text source index.</param>
/// <param name="textPointer">The text pointer that covers the formatted text runs.</param>
/// <returns>
/// The formatted text runs.
/// </returns>
private List<ShapedTextRun> FormatTextRuns(ITextSource textSource, int firstTextSourceIndex, out TextPointer textPointer)
{
var start = -1;
var length = 0;
var textRuns = new List<ShapedTextRun>();
while (true)
{
var textRun = textSource.GetTextRun(firstTextSourceIndex + length);
if (start == -1)
{
start = textRun.Text.Start;
}
if (textRun is TextEndOfLine)
{
break;
}
switch (textRun)
{
case TextCharacters textCharacters:
var runText = textCharacters.Text;
while (!runText.IsEmpty)
{
var shapableTextStyleRun = CreateShapableTextStyleRun(runText, textRun.Style);
var shapedRun = new ShapedTextRun(runText.Take(shapableTextStyleRun.TextPointer.Length),
shapableTextStyleRun.Style);
textRuns.Add(shapedRun);
runText = runText.Skip(shapedRun.Text.Length);
}
break;
default:
throw new NotSupportedException("Run type not supported by the formatter.");
}
length += textRun.Text.Length;
}
textPointer = new TextPointer(start, length);
return textRuns;
}
/// <summary>
/// Performs text trimming and returns a trimmed line.
/// </summary>
/// <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>
/// <param name="textRuns">The text runs to perform the trimming on.</param>
/// <param name="text">The text that was used to construct the text runs.</param>
/// <returns></returns>
private static TextLine PerformTextTrimming(TextPointer text, IReadOnlyList<ShapedTextRun> textRuns,
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.Style);
var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width);
if (textTrimming == TextTrimming.WordEllipsis)
{
if (measuredLength < text.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<ShapedTextRun>(splitResult.First.Count + 1);
trimmedRuns.AddRange(splitResult.First);
trimmedRuns.Add(ellipsisRun);
var textLineMetrics =
TextLineMetrics.Create(trimmedRuns, paragraphWidth, paragraphProperties.TextAlignment);
return new SimpleTextLine(text.Take(measuredLength), trimmedRuns, textLineMetrics);
}
availableWidth -= currentRun.GlyphRun.Bounds.Width;
runIndex++;
}
return new SimpleTextLine(text, textRuns,
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment));
}
/// <summary>
/// Performs text wrapping returns a list of text lines.
/// </summary>
/// <param name="paragraphProperties">The text paragraph properties.</param>
/// <param name="textRuns">The text run'S.</param>
/// <param name="text">The text to analyze for break opportunities.</param>
/// <param name="paragraphWidth"></param>
/// <returns></returns>
private static TextLine PerformTextWrapping(TextPointer text, IReadOnlyList<ShapedTextRun> textRuns,
double paragraphWidth, TextParagraphProperties paragraphProperties)
{
var availableWidth = paragraphWidth;
var currentWidth = 0.0;
var runIndex = 0;
var length = 0;
while (runIndex < textRuns.Count)
{
var currentRun = textRuns[runIndex];
if (currentWidth + currentRun.GlyphRun.Bounds.Width > availableWidth)
{
var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth);
if (measuredLength < currentRun.Text.Length)
{
var currentBreakPosition = -1;
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 (currentBreakPosition != -1)
{
measuredLength = currentBreakPosition;
}
}
length += measuredLength;
var splitResult = SplitTextRuns(textRuns, length);
var textLineMetrics =
TextLineMetrics.Create(splitResult.First, paragraphWidth, paragraphProperties.TextAlignment);
return new SimpleTextLine(text.Take(length), splitResult.First, textLineMetrics);
}
currentWidth += currentRun.GlyphRun.Bounds.Width;
length += currentRun.GlyphRun.Characters.Length;
runIndex++;
}
return new SimpleTextLine(text, textRuns,
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment));
}
/// <summary>
/// Measures the number of characters that fits into available width.
/// </summary>
/// <param name="textRun">The text run.</param>
/// <param name="availableWidth">The available width.</param>
/// <returns></returns>
private static int MeasureText(ShapedTextRun textRun, double availableWidth)
{
var glyphRun = textRun.GlyphRun;
var characterHit = glyphRun.GetCharacterHitFromDistance(availableWidth, out _);
return characterHit.FirstCharacterIndex + characterHit.TrailingLength - textRun.Text.Start;
}
/// <summary>
/// Creates an ellipsis.
/// </summary>
/// <param name="textStyle">The text style.</param>
/// <returns></returns>
private static ShapedTextRun CreateEllipsisRun(TextStyle textStyle)
{
var formatterImpl = AvaloniaLocator.Current.GetService<ITextShaperImpl>();
var glyphRun = formatterImpl.ShapeText(s_ellipsis, textStyle.TextFormat);
return new ShapedTextRun(glyphRun, textStyle);
}
private readonly struct SplitTextRunsResult
{
public SplitTextRunsResult(IReadOnlyList<ShapedTextRun> first, IReadOnlyList<ShapedTextRun> second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text runs.
/// </summary>
/// <value>
/// The first text runs.
/// </value>
public IReadOnlyList<ShapedTextRun> First { get; }
/// <summary>
/// Gets the second text runs.
/// </summary>
/// <value>
/// The second text runs.
/// </value>
public IReadOnlyList<ShapedTextRun> Second { get; }
}
/// <summary>
/// Split a sequence of runs into two segments at specified length.
/// </summary>
/// <param name="textRuns">The text run's.</param>
/// <param name="length">The length to split at.</param>
/// <returns></returns>
private static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextRun> textRuns, int length)
{
var currentLength = 0;
for (var i = 0; i < textRuns.Count; i++)
{
var currentRun = textRuns[i];
if (currentLength + currentRun.GlyphRun.Characters.Length < length)
{
currentLength += currentRun.GlyphRun.Characters.Length;
continue;
}
var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i;
var first = new ShapedTextRun[firstCount];
if (firstCount > 1)
{
for (var j = 0; j < i; j++)
{
first[j] = textRuns[j];
}
}
var secondCount = textRuns.Count - firstCount;
if (currentLength + currentRun.GlyphRun.Characters.Length == length)
{
var second = new ShapedTextRun[secondCount];
var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0;
if (secondCount > 0)
{
for (var j = 0; j < secondCount; j++)
{
second[j] = textRuns[i + j + offset];
}
}
first[i] = currentRun;
return new SplitTextRunsResult(first, second);
}
else
{
secondCount++;
var second = new ShapedTextRun[secondCount];
if (secondCount > 0)
{
for (var j = 1; j < secondCount; j++)
{
second[j] = textRuns[i + j];
}
}
var split = currentRun.Split(length - currentLength);
first[i] = split.First;
second[0] = split.Second;
return new SplitTextRunsResult(first, second);
}
}
return new SplitTextRunsResult(textRuns, null);
}
}
}

259
src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs

@ -1,259 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting
{
internal class SimpleTextLine : TextLine
{
private readonly IReadOnlyList<ShapedTextRun> _textRuns;
public SimpleTextLine(TextPointer textPointer, IReadOnlyList<ShapedTextRun> textRuns, TextLineMetrics lineMetrics)
{
Text = textPointer;
_textRuns = textRuns;
LineMetrics = lineMetrics;
}
/// <inheritdoc/>
public override TextPointer Text { get; }
/// <inheritdoc/>
public override IReadOnlyList<TextRun> TextRuns => _textRuns;
/// <inheritdoc/>
public override TextLineMetrics LineMetrics { get; }
/// <inheritdoc/>
public override void Draw(IDrawingContextImpl drawingContext, Point origin)
{
var currentX = origin.X;
foreach (var textRun in _textRuns)
{
var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X,
origin.Y + LineMetrics.BaselineOrigin.Y);
textRun.Draw(drawingContext, baselineOrigin);
currentX += textRun.Bounds.Width;
}
}
/// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
if (distance < 0)
{
// hit happens before the line, return the first position
return new CharacterHit(Text.Start);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
foreach (var run in _textRuns)
{
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _);
if (distance <= run.Bounds.Width)
{
break;
}
distance -= run.Bounds.Width;
}
return characterHit;
}
/// <inheritdoc/>
public override double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0));
}
/// <inheritdoc/>
public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit)
{
int nextVisibleCp;
bool navigableCpFound;
if (characterHit.TrailingLength == 0)
{
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex, out nextVisibleCp);
if (navigableCpFound)
{
// Move from leading to trailing edge
return new CharacterHit(nextVisibleCp, 1);
}
}
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex + 1, out nextVisibleCp);
if (navigableCpFound)
{
// Move from trailing edge of current character to trailing edge of next
return new CharacterHit(nextVisibleCp, 1);
}
// Can't move, we're after the last character
return characterHit;
}
/// <inheritdoc/>
public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit)
{
int previousCodepointIndex;
bool codepointIndexFound;
var cpHit = characterHit.FirstCharacterIndex;
var trailingHit = characterHit.TrailingLength != 0;
// Input can be right after the end of the current line. Snap it to be at the end of the line.
if (cpHit >= Text.Start + Text.Length)
{
cpHit = Text.Start + Text.Length - 1;
trailingHit = true;
}
if (trailingHit)
{
codepointIndexFound = FindPreviousCodepointIndex(cpHit, out previousCodepointIndex);
if (codepointIndexFound)
{
// Move from trailing to leading edge
return new CharacterHit(previousCodepointIndex, 0);
}
}
codepointIndexFound = FindPreviousCodepointIndex(cpHit - 1, out previousCodepointIndex);
if (codepointIndexFound)
{
// Move from leading edge of current character to leading edge of previous
return new CharacterHit(previousCodepointIndex, 0);
}
// Can't move, we're before the first character
return characterHit;
}
/// <inheritdoc/>
public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit)
{
// same operation as move-to-previous
return GetPreviousCaretCharacterHit(characterHit);
}
/// <summary>
/// Get distance from line start to the specified codepoint index
/// </summary>
private double DistanceFromCodepointIndex(int codepointIndex)
{
var currentDistance = 0.0;
foreach (var textRun in _textRuns)
{
if (codepointIndex > textRun.Text.End)
{
currentDistance += textRun.Bounds.Width;
continue;
}
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex));
}
return currentDistance;
}
/// <summary>
/// Search forward from the given codepoint index (inclusive) to find the next navigable codepoint index.
/// Return true if one such codepoint index is found, false otherwise.
/// </summary>
private bool FindNextCodepointIndex(int codepointIndex, out int nextCodepointIndex)
{
nextCodepointIndex = codepointIndex;
if (codepointIndex >= Text.Start + Text.Length)
{
return false; // Cannot go forward anymore
}
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var cpRunStart);
while (runIndex < TextRuns.Count)
{
// When navigating forward, only the trailing edge of visible content is
// navigable.
if (runIndex < TextRuns.Count)
{
nextCodepointIndex = Math.Max(cpRunStart, codepointIndex);
return true;
}
cpRunStart += TextRuns[runIndex++].Text.Length;
}
return false;
}
/// <summary>
/// Search backward from the given codepoint index (inclusive) to find the previous navigable codepoint index.
/// Return true if one such codepoint is found, false otherwise.
/// </summary>
private bool FindPreviousCodepointIndex(int codepointIndex, out int previousCodepointIndex)
{
previousCodepointIndex = codepointIndex;
if (codepointIndex < Text.Start)
{
return false; // Cannot go backward anymore.
}
// Position the cpRunEnd at the end of the span that contains the given cp
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var codepointIndexAtRunEnd);
codepointIndexAtRunEnd += TextRuns[runIndex].Text.End;
while (runIndex >= 0)
{
// Visible content has caret stops at its leading edge.
if (runIndex + 1 < TextRuns.Count)
{
previousCodepointIndex = Math.Min(codepointIndexAtRunEnd, codepointIndex);
return true;
}
// Newline sequence has caret stops at its leading edge.
if (runIndex == TextRuns.Count)
{
// Get the cp index at the beginning of the newline sequence.
previousCodepointIndex = codepointIndexAtRunEnd - TextRuns[runIndex].Text.Length + 1;
return true;
}
codepointIndexAtRunEnd -= TextRuns[runIndex--].Text.Length;
}
return false;
}
private void GetRunIndexAtCodepointIndex(int codepointIndex, out int runIndex, out int codepointIndexAtRunStart)
{
codepointIndexAtRunStart = Text.Start;
runIndex = 0;
// Find the span that contains the given cp
while (runIndex < TextRuns.Count &&
codepointIndexAtRunStart + TextRuns[runIndex].Text.Length <= codepointIndex)
{
codepointIndexAtRunStart += TextRuns[runIndex++].Text.Length;
}
}
}
}

181
src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs

@ -1,4 +1,6 @@
using Avalonia.Utility; using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -7,15 +9,182 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
public class TextCharacters : TextRun public class TextCharacters : TextRun
{ {
protected TextCharacters() public TextCharacters(ReadOnlySlice<char> text, TextRunProperties properties)
{ {
TextSourceLength = text.Length;
Text = text;
Properties = properties;
} }
public TextCharacters(ReadOnlySlice<char> text, TextStyle style) /// <inheritdoc />
public override int TextSourceLength { get; }
/// <inheritdoc />
public override ReadOnlySlice<char> Text { get; }
/// <inheritdoc />
public override TextRunProperties Properties { get; }
/// <summary>
/// Gets a list of <see cref="ShapeableTextCharacters"/>.
/// </summary>
/// <returns>The shapeable text characters.</returns>
internal IList<ShapeableTextCharacters> GetShapeableCharacters()
{ {
Text = text; var shapeableCharacters = new List<ShapeableTextCharacters>(2);
Style = style;
var runText = Text;
while (!runText.IsEmpty)
{
var shapeableRun = CreateShapeableRun(runText, Properties);
shapeableCharacters.Add(shapeableRun);
runText = runText.Skip(shapeableRun.Text.Length);
}
return shapeableCharacters;
}
/// <summary>
/// Creates a shapeable text run with unique properties.
/// </summary>
/// <param name="text">The text to create text runs from.</param>
/// <param name="defaultProperties">The default text run properties.</param>
/// <returns>A list of shapeable text runs.</returns>
private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice<char> text, TextRunProperties defaultProperties)
{
var defaultTypeface = defaultProperties.Typeface;
var currentTypeface = defaultTypeface;
if (TryGetRunProperties(text, currentTypeface, defaultTypeface, out var count))
{
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
}
var codepoint = Codepoint.ReadAt(text, count, out _);
//ToDo: Fix FontFamily fallback
currentTypeface =
FontManager.Current.MatchCharacter(codepoint, defaultTypeface.Weight, defaultTypeface.Style, defaultTypeface.FontFamily);
if (currentTypeface != null && TryGetRunProperties(text, currentTypeface, defaultTypeface, out count))
{
//Fallback found
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
}
// no fallback found
currentTypeface = defaultTypeface;
var glyphTypeface = currentTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
if (!grapheme.FirstCodepoint.IsWhiteSpace && glyphTypeface.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
count += grapheme.Text.Length;
}
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
}
/// <summary>
/// Tries to get run properties.
/// </summary>
/// <param name="defaultTypeface"></param>
/// <param name="text"></param>
/// <param name="typeface">The typeface that is used to find matching characters.</param>
/// <param name="count"></param>
/// <returns></returns>
protected bool TryGetRunProperties(ReadOnlySlice<char> text, Typeface typeface, Typeface defaultTypeface,
out int count)
{
if (text.Length == 0)
{
count = 0;
return false;
}
var isFallback = typeface != defaultTypeface;
count = 0;
var script = Script.Common;
//var direction = BiDiClass.LeftToRight;
var font = typeface.GlyphTypeface;
var defaultFont = defaultTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
var currentScript = grapheme.FirstCodepoint.Script;
//var currentDirection = grapheme.FirstCodepoint.BiDiClass;
//// ToDo: Implement BiDi algorithm
//if (currentScript.HorizontalDirection != direction)
//{
// if (!UnicodeUtility.IsWhiteSpace(grapheme.FirstCodepoint))
// {
// break;
// }
//}
if (currentScript != script)
{
if (currentScript != Script.Inherited && currentScript != Script.Common)
{
if (script == Script.Inherited || script == Script.Common)
{
script = currentScript;
}
else
{
break;
}
}
}
if (isFallback)
{
if (defaultFont.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
}
if (!font.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
if (!grapheme.FirstCodepoint.IsWhiteSpace)
{
break;
}
}
count += grapheme.Text.Length;
}
return count > 0;
} }
} }
} }

71
src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs

@ -1,71 +0,0 @@
using System;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Unique text formatting properties that are used by the <see cref="TextFormatter"/>.
/// </summary>
public readonly struct TextFormat : IEquatable<TextFormat>
{
public TextFormat(Typeface typeface, double fontRenderingEmSize)
{
Typeface = typeface;
FontRenderingEmSize = fontRenderingEmSize;
FontMetrics = new FontMetrics(typeface, fontRenderingEmSize);
}
/// <summary>
/// Gets the typeface.
/// </summary>
/// <value>
/// The typeface.
/// </value>
public Typeface Typeface { get; }
/// <summary>
/// Gets the font rendering em size.
/// </summary>
/// <value>
/// The em rendering size of the font.
/// </value>
public double FontRenderingEmSize { get; }
/// <summary>
/// Gets the font metrics.
/// </summary>
/// <value>
/// The metrics of the font.
/// </value>
public FontMetrics FontMetrics { get; }
public static bool operator ==(TextFormat self, TextFormat other)
{
return self.Equals(other);
}
public static bool operator !=(TextFormat self, TextFormat other)
{
return !(self == other);
}
public bool Equals(TextFormat other)
{
return Typeface.Equals(other.Typeface) && FontRenderingEmSize.Equals(other.FontRenderingEmSize);
}
public override bool Equals(object obj)
{
return obj is TextFormat other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = (Typeface != null ? Typeface.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ FontRenderingEmSize.GetHashCode();
return hashCode;
}
}
}
}

148
src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs

@ -1,5 +1,4 @@
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utility;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -22,7 +21,7 @@ namespace Avalonia.Media.TextFormatting
return current; return current;
} }
current = new SimpleTextFormatter(); current = new TextFormatterImpl();
AvaloniaLocator.CurrentMutable.Bind<TextFormatter>().ToConstant(current); AvaloniaLocator.CurrentMutable.Bind<TextFormatter>().ToConstant(current);
@ -38,149 +37,10 @@ namespace Avalonia.Media.TextFormatting
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</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, /// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
/// such as TextWrapping, TextAlignment, or TextStyle.</param> /// such as TextWrapping, TextAlignment, or TextStyle.</param>
/// <param name="previousLineBreak">A <see cref="TextLineBreak"/> value that specifies the text formatter state,
/// in terms of where the previous line in the paragraph was broken by the text formatting process.</param>
/// <returns>The formatted line.</returns> /// <returns>The formatted line.</returns>
public abstract TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, public abstract TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties); TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null);
/// <summary>
/// Creates a text style run with unique properties.
/// </summary>
/// <param name="text">The text to create text runs from.</param>
/// <param name="defaultStyle"></param>
/// <returns>A list of text runs.</returns>
protected TextStyleRun CreateShapableTextStyleRun(ReadOnlySlice<char> text, TextStyle defaultStyle)
{
var defaultTypeface = defaultStyle.TextFormat.Typeface;
var currentTypeface = defaultTypeface;
if (TryGetRunProperties(text, currentTypeface, defaultTypeface, out var count))
{
return new TextStyleRun(new TextPointer(text.Start, count), new TextStyle(currentTypeface,
defaultStyle.TextFormat.FontRenderingEmSize,
defaultStyle.Foreground, defaultStyle.TextDecorations));
}
var codepoint = Codepoint.ReadAt(text, count, out _);
//ToDo: Fix FontFamily fallback
currentTypeface =
FontManager.Current.MatchCharacter(codepoint, defaultTypeface.Weight, defaultTypeface.Style, defaultStyle.TextFormat.Typeface.FontFamily);
if (currentTypeface != null && TryGetRunProperties(text, currentTypeface, defaultTypeface, out count))
{
//Fallback found
return new TextStyleRun(new TextPointer(text.Start, count), new TextStyle(currentTypeface,
defaultStyle.TextFormat.FontRenderingEmSize,
defaultStyle.Foreground, defaultStyle.TextDecorations));
}
// no fallback found
currentTypeface = defaultTypeface;
var glyphTypeface = currentTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
if (!grapheme.FirstCodepoint.IsWhiteSpace && glyphTypeface.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
count += grapheme.Text.Length;
}
return new TextStyleRun(new TextPointer(text.Start, count),
new TextStyle(currentTypeface, defaultStyle.TextFormat.FontRenderingEmSize,
defaultStyle.Foreground, defaultStyle.TextDecorations));
}
/// <summary>
/// Tries to get run properties.
/// </summary>
/// <param name="defaultTypeface"></param>
/// <param name="text"></param>
/// <param name="typeface">The typeface that is used to find matching characters.</param>
/// <param name="count"></param>
/// <returns></returns>
protected bool TryGetRunProperties(ReadOnlySlice<char> text, Typeface typeface, Typeface defaultTypeface,
out int count)
{
if (text.Length == 0)
{
count = 0;
return false;
}
var isFallback = typeface != defaultTypeface;
count = 0;
var script = Script.Common;
//var direction = BiDiClass.LeftToRight;
var font = typeface.GlyphTypeface;
var defaultFont = defaultTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
var currentScript = grapheme.FirstCodepoint.Script;
//var currentDirection = grapheme.FirstCodepoint.BiDiClass;
//// ToDo: Implement BiDi algorithm
//if (currentScript.HorizontalDirection != direction)
//{
// if (!UnicodeUtility.IsWhiteSpace(grapheme.FirstCodepoint))
// {
// break;
// }
//}
if (currentScript != script)
{
if (currentScript != Script.Inherited && currentScript != Script.Common)
{
if (script == Script.Inherited || script == Script.Common)
{
script = currentScript;
}
else
{
break;
}
}
}
if (isFallback)
{
if (defaultFont.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
}
if (!font.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
if (!grapheme.FirstCodepoint.IsWhiteSpace)
{
break;
}
}
count += grapheme.Text.Length;
}
return count > 0;
}
} }
} }

544
src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs

@ -0,0 +1,544 @@
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<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' });
/// <inheritdoc cref="TextFormatter.FormatLine"/>
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
{
switch (textWrapping)
{
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;
}
}
}
return textLine;
}
/// <summary>
/// Fetches text runs.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="firstTextSourceIndex">The first text source index.</param>
/// <param name="previousLineBreak">Previous line break. Can be null.</param>
/// <param name="nextLineBreak">Next line break. Can be null.</param>
/// <returns>
/// The formatted text runs.
/// </returns>
private static IReadOnlyList<ShapedTextCharacters> FetchTextRuns(ITextSource textSource,
int firstTextSourceIndex, TextLineBreak previousLineBreak, out TextLineBreak nextLineBreak)
{
nextLineBreak = default;
var currentLength = 0;
var textRuns = new List<ShapedTextCharacters>();
if (previousLineBreak != null)
{
foreach (var shapedCharacters in previousLineBreak.RemainingCharacters)
{
textRuns.Add(shapedCharacters);
if (TryGetLineBreak(shapedCharacters, out var runLineBreak))
{
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap);
nextLineBreak = new TextLineBreak(splitResult.Second);
return splitResult.First;
}
currentLength += shapedCharacters.Text.Length;
}
}
firstTextSourceIndex += currentLength;
var textRunEnumerator = new TextRunEnumerator(textSource, firstTextSourceIndex);
while (textRunEnumerator.MoveNext())
{
var textRun = textRunEnumerator.Current;
switch (textRun)
{
case TextCharacters textCharacters:
{
var shapeableRuns = textCharacters.GetShapeableCharacters();
foreach (var run in shapeableRuns)
{
var glyphRun = TextShaper.Current.ShapeText(run.Text, run.Properties.Typeface,
run.Properties.FontRenderingEmSize, run.Properties.CultureInfo);
var shapedCharacters = new ShapedTextCharacters(glyphRun, textRun.Properties);
textRuns.Add(shapedCharacters);
}
break;
}
}
if (TryGetLineBreak(textRun, out var runLineBreak))
{
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap);
nextLineBreak = new TextLineBreak(splitResult.Second);
return splitResult.First;
}
currentLength += textRun.Text.Length;
}
return textRuns;
}
private static bool TryGetLineBreak(TextRun textRun, out LineBreak lineBreak)
{
lineBreak = default;
if (textRun.Text.IsEmpty)
{
return false;
}
var lineBreakEnumerator = new LineBreakEnumerator(textRun.Text);
while (lineBreakEnumerator.MoveNext())
{
if (!lineBreakEnumerator.Current.Required)
{
continue;
}
lineBreak = lineBreakEnumerator.Current;
if (lineBreak.PositionWrap >= textRun.Text.Length)
{
return true;
}
//The line breaker isn't treating \n\r as a pair so we have to fix that here.
if (textRun.Text[lineBreak.PositionMeasure] == '\n'
&& textRun.Text[lineBreak.PositionWrap] == '\r')
{
lineBreak = new LineBreak(lineBreak.PositionMeasure, lineBreak.PositionWrap + 1,
lineBreak.Required);
}
return true;
}
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>
/// Performs text wrapping returns a list of text lines.
/// </summary>
/// <param name="textRuns">The text run's.</param>
/// <param name="textRange">The text range that is covered by the text runs.</param>
/// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="paragraphProperties">The text paragraph properties.</param>
/// <returns>The wrapped text line.</returns>
private static TextLine PerformTextWrapping(IReadOnlyList<ShapedTextCharacters> textRuns, TextRange textRange,
double paragraphWidth, TextParagraphProperties paragraphProperties)
{
var availableWidth = paragraphWidth;
var currentWidth = 0.0;
var runIndex = 0;
var length = 0;
while (runIndex < textRuns.Count)
{
var currentRun = textRuns[runIndex];
if (currentWidth + currentRun.GlyphRun.Bounds.Width > availableWidth)
{
var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth);
if (measuredLength < currentRun.Text.Length)
{
if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow)
{
var lineBreaker = new LineBreakEnumerator(currentRun.Text.Skip(measuredLength));
if (lineBreaker.MoveNext())
{
measuredLength += lineBreaker.Current.PositionWrap;
}
else
{
measuredLength = currentRun.Text.Length;
}
}
else
{
var currentBreakPosition = -1;
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 (currentBreakPosition != -1)
{
measuredLength = currentBreakPosition;
}
}
}
length += measuredLength;
var splitResult = SplitTextRuns(textRuns, length);
var textLineMetrics = TextLineMetrics.Create(splitResult.First,
new TextRange(textRange.Start, length), paragraphWidth, paragraphProperties);
var lineBreak = splitResult.Second != null && splitResult.Second.Count > 0 ?
new TextLineBreak(splitResult.Second) :
null;
return new TextLineImpl(splitResult.First, textLineMetrics, lineBreak);
}
currentWidth += currentRun.GlyphRun.Bounds.Width;
length += currentRun.GlyphRun.Characters.Length;
runIndex++;
}
return new TextLineImpl(textRuns,
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties));
}
/// <summary>
/// Measures the number of characters that fits into available width.
/// </summary>
/// <param name="textCharacters">The text run.</param>
/// <param name="availableWidth">The available width.</param>
/// <returns></returns>
private static int MeasureText(ShapedTextCharacters textCharacters, double availableWidth)
{
var glyphRun = textCharacters.GlyphRun;
var characterHit = glyphRun.GetCharacterHitFromDistance(availableWidth, out _);
return characterHit.FirstCharacterIndex + characterHit.TrailingLength - textCharacters.Text.Start;
}
/// <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>
/// Gets the text range that is covered by the text runs.
/// </summary>
/// <param name="textRuns">The text runs.</param>
/// <returns>The text range that is covered by the text runs.</returns>
private static TextRange GetTextRange(IReadOnlyList<TextRun> textRuns)
{
if (textRuns is null || textRuns.Count == 0)
{
return new TextRange();
}
var firstTextRun = textRuns[0];
if (textRuns.Count == 1)
{
return new TextRange(firstTextRun.Text.Start, firstTextRun.Text.Length);
}
var start = firstTextRun.Text.Start;
var end = textRuns[textRuns.Count - 1].Text.End + 1;
return new TextRange(start, end - start);
}
/// <summary>
/// Split a sequence of runs into two segments at specified length.
/// </summary>
/// <param name="textRuns">The text run's.</param>
/// <param name="length">The length to split at.</param>
/// <returns>The split text runs.</returns>
private static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextCharacters> textRuns, int length)
{
var currentLength = 0;
for (var i = 0; i < textRuns.Count; i++)
{
var currentRun = textRuns[i];
if (currentLength + currentRun.GlyphRun.Characters.Length < length)
{
currentLength += currentRun.GlyphRun.Characters.Length;
continue;
}
var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i;
var first = new ShapedTextCharacters[firstCount];
if (firstCount > 1)
{
for (var j = 0; j < i; j++)
{
first[j] = textRuns[j];
}
}
var secondCount = textRuns.Count - firstCount;
if (currentLength + currentRun.GlyphRun.Characters.Length == length)
{
var second = new ShapedTextCharacters[secondCount];
var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0;
if (secondCount > 0)
{
for (var j = 0; j < secondCount; j++)
{
second[j] = textRuns[i + j + offset];
}
}
first[i] = currentRun;
return new SplitTextRunsResult(first, second);
}
else
{
secondCount++;
var second = new ShapedTextCharacters[secondCount];
if (secondCount > 0)
{
for (var j = 1; j < secondCount; j++)
{
second[j] = textRuns[i + j];
}
}
var split = currentRun.Split(length - currentLength);
first[i] = split.First;
second[0] = split.Second;
return new SplitTextRunsResult(first, second);
}
}
return new SplitTextRunsResult(textRuns, null);
}
private readonly struct SplitTextRunsResult
{
public SplitTextRunsResult(IReadOnlyList<ShapedTextCharacters> first, IReadOnlyList<ShapedTextCharacters> second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text runs.
/// </summary>
/// <value>
/// The first text runs.
/// </value>
public IReadOnlyList<ShapedTextCharacters> First { get; }
/// <summary>
/// Gets the second text runs.
/// </summary>
/// <value>
/// The second text runs.
/// </value>
public IReadOnlyList<ShapedTextCharacters> Second { get; }
}
private struct TextRunEnumerator
{
private readonly ITextSource _textSource;
private int _pos;
public TextRunEnumerator(ITextSource textSource, int firstTextSourceIndex)
{
_textSource = textSource;
_pos = firstTextSourceIndex;
Current = null;
}
// ReSharper disable once MemberHidesStaticFromOuterClass
public TextRun Current { get; private set; }
public bool MoveNext()
{
Current = _textSource.GetTextRun(_pos);
if (Current is null)
{
return false;
}
if (Current.TextSourceLength == 0)
{
return false;
}
_pos += Current.TextSourceLength;
return !(Current is TextEndOfLine);
}
}
}
}

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

@ -1,11 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Avalonia.Media.Immutable;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities; using Avalonia.Utilities;
using Avalonia.Utility; using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -14,11 +12,11 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
public class TextLayout public class TextLayout
{ {
private static readonly ReadOnlySlice<char> s_empty = new ReadOnlySlice<char>(new[] { '\u200B' }); private static readonly char[] s_empty = { '\u200B' };
private readonly ReadOnlySlice<char> _text; private readonly ReadOnlySlice<char> _text;
private readonly TextParagraphProperties _paragraphProperties; private readonly TextParagraphProperties _paragraphProperties;
private readonly IReadOnlyList<TextStyleRun> _textStyleOverrides; private readonly IReadOnlyList<ValueSpan<TextRunProperties>> _textStyleOverrides;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class. /// Initializes a new instance of the <see cref="TextLayout" /> class.
@ -33,6 +31,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textDecorations">The text decorations.</param> /// <param name="textDecorations">The text decorations.</param>
/// <param name="maxWidth">The maximum width.</param> /// <param name="maxWidth">The maximum width.</param>
/// <param name="maxHeight">The maximum height.</param> /// <param name="maxHeight">The maximum height.</param>
/// <param name="lineHeight">The height of each line of text.</param>
/// <param name="maxLines">The maximum number of text lines.</param> /// <param name="maxLines">The maximum number of text lines.</param>
/// <param name="textStyleOverrides">The text style overrides.</param> /// <param name="textStyleOverrides">The text style overrides.</param>
public TextLayout( public TextLayout(
@ -46,18 +45,22 @@ namespace Avalonia.Media.TextFormatting
TextDecorationCollection textDecorations = null, TextDecorationCollection textDecorations = null,
double maxWidth = double.PositiveInfinity, double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity, double maxHeight = double.PositiveInfinity,
double lineHeight = double.NaN,
int maxLines = 0, int maxLines = 0,
IReadOnlyList<TextStyleRun> textStyleOverrides = null) IReadOnlyList<ValueSpan<TextRunProperties>> textStyleOverrides = null)
{ {
_text = string.IsNullOrEmpty(text) ? _text = string.IsNullOrEmpty(text) ?
new ReadOnlySlice<char>() : new ReadOnlySlice<char>() :
new ReadOnlySlice<char>(text.AsMemory()); new ReadOnlySlice<char>(text.AsMemory());
_paragraphProperties = _paragraphProperties =
CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming, textDecorations?.ToImmutable()); CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming,
textDecorations, lineHeight);
_textStyleOverrides = textStyleOverrides; _textStyleOverrides = textStyleOverrides;
LineHeight = lineHeight;
MaxWidth = maxWidth; MaxWidth = maxWidth;
MaxHeight = maxHeight; MaxHeight = maxHeight;
@ -67,22 +70,29 @@ namespace Avalonia.Media.TextFormatting
UpdateLayout(); UpdateLayout();
} }
/// <summary>
/// Gets or sets the height of each line of text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public double LineHeight { get; }
/// <summary> /// <summary>
/// Gets the maximum width. /// Gets the maximum width.
/// </summary> /// </summary>
public double MaxWidth { get; } public double MaxWidth { get; }
/// <summary> /// <summary>
/// Gets the maximum height. /// Gets the maximum height.
/// </summary> /// </summary>
public double MaxHeight { get; } public double MaxHeight { get; }
/// <summary> /// <summary>
/// Gets the maximum number of text lines. /// Gets the maximum number of text lines.
/// </summary> /// </summary>
public double MaxLines { get; } public int MaxLines { get; }
/// <summary> /// <summary>
/// Gets the text lines. /// Gets the text lines.
@ -105,7 +115,7 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
/// <param name="context">The drawing context.</param> /// <param name="context">The drawing context.</param>
/// <param name="origin">The origin.</param> /// <param name="origin">The origin.</param>
public void Draw(IDrawingContextImpl context, Point origin) public void Draw(DrawingContext context, Point origin)
{ {
if (!TextLines.Any()) if (!TextLines.Any())
{ {
@ -132,14 +142,16 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textWrapping">The text wrapping.</param> /// <param name="textWrapping">The text wrapping.</param>
/// <param name="textTrimming">The text trimming.</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>
/// <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, TextTrimming textTrimming,
ImmutableTextDecoration[] textDecorations) TextDecorationCollection textDecorations, double lineHeight)
{ {
var textRunStyle = new TextStyle(typeface, fontSize, foreground, textDecorations); var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground);
return new TextParagraphProperties(textRunStyle, textAlignment, textWrapping, textTrimming); return new GenericTextParagraphProperties(textRunStyle, textAlignment, textWrapping, textTrimming,
lineHeight);
} }
/// <summary> /// <summary>
@ -170,14 +182,15 @@ namespace Avalonia.Media.TextFormatting
/// <returns>The empty text line.</returns> /// <returns>The empty text line.</returns>
private TextLine CreateEmptyTextLine(int startingIndex) private TextLine CreateEmptyTextLine(int startingIndex)
{ {
var textFormat = _paragraphProperties.DefaultTextStyle.TextFormat; var properties = _paragraphProperties.DefaultTextRunProperties;
var glyphRun = TextShaper.Current.ShapeText(s_empty, textFormat); var glyphRun = TextShaper.Current.ShapeText(new ReadOnlySlice<char>(s_empty, startingIndex, 1),
properties.Typeface, properties.FontRenderingEmSize, properties.CultureInfo);
var textRuns = new[] { new ShapedTextRun(glyphRun, _paragraphProperties.DefaultTextStyle) }; var textRuns = new[] { new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) };
return new SimpleTextLine(new TextPointer(startingIndex, 0), textRuns, return new TextLineImpl(textRuns,
TextLineMetrics.Create(textRuns, MaxWidth, _paragraphProperties.TextAlignment)); TextLineMetrics.Create(textRuns, new TextRange(startingIndex, 1), MaxWidth, _paragraphProperties));
} }
/// <summary> /// <summary>
@ -199,77 +212,38 @@ namespace Avalonia.Media.TextFormatting
double left = 0.0, right = 0.0, bottom = 0.0; double left = 0.0, right = 0.0, bottom = 0.0;
var lineBreaker = new LineBreakEnumerator(_text);
var currentPosition = 0; var currentPosition = 0;
var textSource = new FormattedTextSource(_text,
_paragraphProperties.DefaultTextRunProperties, _textStyleOverrides);
TextLineBreak previousLineBreak = null;
while (currentPosition < _text.Length && (MaxLines == 0 || textLines.Count < MaxLines)) while (currentPosition < _text.Length && (MaxLines == 0 || textLines.Count < MaxLines))
{ {
int length; var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth,
_paragraphProperties, previousLineBreak);
if (lineBreaker.MoveNext()) previousLineBreak = textLine.LineBreak;
{
if (!lineBreaker.Current.Required)
{
continue;
}
length = lineBreaker.Current.PositionWrap - currentPosition; textLines.Add(textLine);
if (currentPosition + length < _text.Length) UpdateBounds(textLine, ref left, ref right, ref bottom);
{
//The line breaker isn't treating \n\r as a pair so we have to fix that here. if (!double.IsPositiveInfinity(MaxHeight) && bottom > MaxHeight)
if (_text[lineBreaker.Current.PositionMeasure] == '\n'
&& _text[lineBreaker.Current.PositionWrap] == '\r')
{
length++;
}
}
}
else
{ {
length = _text.Length - currentPosition; break;
} }
var remainingLength = length; currentPosition += textLine.TextRange.Length;
while (remainingLength > 0 && (MaxLines == 0 || textLines.Count < MaxLines)) if (currentPosition != _text.Length || textLine.LineBreak == null)
{ {
var textSlice = _text.AsSlice(currentPosition, remainingLength); continue;
var textSource = new FormattedTextSource(textSlice, _paragraphProperties.DefaultTextStyle, _textStyleOverrides);
var textLine = TextFormatter.Current.FormatLine(textSource, 0, MaxWidth, _paragraphProperties);
UpdateBounds(textLine, ref left, ref right, ref bottom);
textLines.Add(textLine);
if (!double.IsPositiveInfinity(MaxHeight) && bottom + textLine.LineMetrics.Size.Height > MaxHeight)
{
currentPosition = _text.Length;
break;
}
if (_paragraphProperties.TextTrimming != TextTrimming.None)
{
currentPosition += remainingLength;
break;
}
remainingLength -= textLine.Text.Length;
currentPosition += textLine.Text.Length;
} }
}
if (lineBreaker.Current.Required && currentPosition == _text.Length)
{
var emptyTextLine = CreateEmptyTextLine(currentPosition); var emptyTextLine = CreateEmptyTextLine(currentPosition);
UpdateBounds(emptyTextLine, ref left, ref right, ref bottom);
textLines.Add(emptyTextLine); textLines.Add(emptyTextLine);
} }
@ -279,22 +253,27 @@ namespace Avalonia.Media.TextFormatting
} }
} }
private struct FormattedTextSource : ITextSource private readonly struct FormattedTextSource : ITextSource
{ {
private readonly ReadOnlySlice<char> _text; private readonly ReadOnlySlice<char> _text;
private readonly TextStyle _defaultStyle; private readonly TextRunProperties _defaultProperties;
private readonly IReadOnlyList<TextStyleRun> _textStyleOverrides; private readonly IReadOnlyList<ValueSpan<TextRunProperties>> _textModifier;
public FormattedTextSource(ReadOnlySlice<char> text, TextStyle defaultStyle, public FormattedTextSource(ReadOnlySlice<char> text, TextRunProperties defaultProperties,
IReadOnlyList<TextStyleRun> textStyleOverrides) IReadOnlyList<ValueSpan<TextRunProperties>> textModifier)
{ {
_text = text; _text = text;
_defaultStyle = defaultStyle; _defaultProperties = defaultProperties;
_textStyleOverrides = textStyleOverrides; _textModifier = textModifier;
} }
public TextRun GetTextRun(int textSourceIndex) public TextRun GetTextRun(int textSourceIndex)
{ {
if (textSourceIndex > _text.End)
{
return new TextEndOfLine();
}
var runText = _text.Skip(textSourceIndex); var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty) if (runText.IsEmpty)
@ -302,30 +281,29 @@ namespace Avalonia.Media.TextFormatting
return new TextEndOfLine(); return new TextEndOfLine();
} }
var textStyleRun = CreateTextStyleRunWithOverride(runText, _defaultStyle, _textStyleOverrides); var textStyleRun = CreateTextStyleRun(runText, _defaultProperties, _textModifier);
return new TextCharacters(runText.Take(textStyleRun.TextPointer.Length), textStyleRun.Style); return new TextCharacters(runText.Take(textStyleRun.Length), textStyleRun.Value);
} }
/// <summary> /// <summary>
/// Creates a text style run that has overrides applied. Only overrides with equal TextStyle. /// Creates a span of text run properties that has modifier applied.
/// If optimizeForShaping is <c>true</c> Foreground is ignored.
/// </summary> /// </summary>
/// <param name="text">The text to create the run for.</param> /// <param name="text">The text to create the properties for.</param>
/// <param name="defaultTextStyle">The default text style for segments that don't have an override.</param> /// <param name="defaultProperties">The default text properties.</param>
/// <param name="textStyleOverrides">The text style overrides.</param> /// <param name="textModifier">The text properties modifier.</param>
/// <returns> /// <returns>
/// The created text style run. /// The created text style run.
/// </returns> /// </returns>
private static TextStyleRun CreateTextStyleRunWithOverride(ReadOnlySlice<char> text, private static ValueSpan<TextRunProperties> CreateTextStyleRun(ReadOnlySlice<char> text,
TextStyle defaultTextStyle, IReadOnlyList<TextStyleRun> textStyleOverrides) TextRunProperties defaultProperties, IReadOnlyList<ValueSpan<TextRunProperties>> textModifier)
{ {
if(textStyleOverrides == null || textStyleOverrides.Count == 0) if (textModifier == null || textModifier.Count == 0)
{ {
return new TextStyleRun(new TextPointer(text.Start, text.Length), defaultTextStyle); return new ValueSpan<TextRunProperties>(text.Start, text.Length, defaultProperties);
} }
var currentTextStyle = defaultTextStyle; var currentProperties = defaultProperties;
var hasOverride = false; var hasOverride = false;
@ -333,35 +311,34 @@ namespace Avalonia.Media.TextFormatting
var length = 0; var length = 0;
for (; i < textStyleOverrides.Count; i++) for (; i < textModifier.Count; i++)
{ {
var styleOverride = textStyleOverrides[i]; var propertiesOverride = textModifier[i];
var textPointer = styleOverride.TextPointer; var textRange = new TextRange(propertiesOverride.Start, propertiesOverride.Length);
if (textPointer.End < text.Start) if (textRange.End < text.Start)
{ {
continue; continue;
} }
if (textPointer.Start > text.End) if (textRange.Start > text.End)
{ {
length = text.Length; length = text.Length;
break; break;
} }
if (textPointer.Start > text.Start) if (textRange.Start > text.Start)
{ {
if (styleOverride.Style.TextFormat != currentTextStyle.TextFormat || if (propertiesOverride.Value != currentProperties)
!currentTextStyle.Foreground.Equals(styleOverride.Style.Foreground))
{ {
length = Math.Min(Math.Abs(textPointer.Start - text.Start), text.Length); length = Math.Min(Math.Abs(textRange.Start - text.Start), text.Length);
break; break;
} }
} }
length += Math.Min(text.Length - length, textPointer.Length); length += Math.Min(text.Length - length, textRange.Length);
if (hasOverride) if (hasOverride)
{ {
@ -370,13 +347,12 @@ namespace Avalonia.Media.TextFormatting
hasOverride = true; hasOverride = true;
currentTextStyle = styleOverride.Style; currentProperties = propertiesOverride.Value;
} }
if (length < text.Length && i == textStyleOverrides.Count) if (length < text.Length && i == textModifier.Count)
{ {
if (currentTextStyle.Foreground.Equals(defaultTextStyle.Foreground) && if (currentProperties == defaultProperties)
currentTextStyle.TextFormat == defaultTextStyle.TextFormat)
{ {
length = text.Length; length = text.Length;
} }
@ -387,7 +363,7 @@ namespace Avalonia.Media.TextFormatting
text = text.Take(length); text = text.Take(length);
} }
return new TextStyleRun(new TextPointer(text.Start, length), currentTextStyle); return new ValueSpan<TextRunProperties>(text.Start, length, currentProperties);
} }
} }
} }

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

@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -9,12 +8,12 @@ namespace Avalonia.Media.TextFormatting
public abstract class TextLine public abstract class TextLine
{ {
/// <summary> /// <summary>
/// Gets the text. /// Gets the text range that is covered by the line.
/// </summary> /// </summary>
/// <value> /// <value>
/// The text pointer. /// The text range that is covered by the line.
/// </value> /// </value>
public abstract TextPointer Text { get; } public abstract TextRange TextRange { get; }
/// <summary> /// <summary>
/// Gets the text runs. /// Gets the text runs.
@ -32,12 +31,20 @@ namespace Avalonia.Media.TextFormatting
/// </value> /// </value>
public abstract TextLineMetrics LineMetrics { get; } public abstract TextLineMetrics LineMetrics { get; }
/// <summary>
/// Gets the state of the line when broken by line breaking process.
/// </summary>
/// <returns>
/// A <see cref="LineBreak"/> value that represents the line break.
/// </returns>
public abstract TextLineBreak LineBreak { get; }
/// <summary> /// <summary>
/// Draws the <see cref="TextLine"/> at the given origin. /// Draws the <see cref="TextLine"/> at the given origin.
/// </summary> /// </summary>
/// <param name="drawingContext">The drawing context.</param> /// <param name="drawingContext">The drawing context.</param>
/// <param name="origin">The origin.</param> /// <param name="origin">The origin.</param>
public abstract void Draw(IDrawingContextImpl drawingContext, Point origin); public abstract void Draw(DrawingContext drawingContext, Point origin);
/// <summary> /// <summary>
/// Client to get the character hit corresponding to the specified /// Client to get the character hit corresponding to the specified

17
src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs

@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace Avalonia.Media.TextFormatting
{
public class TextLineBreak
{
public TextLineBreak(IReadOnlyList<ShapedTextCharacters> remainingCharacters)
{
RemainingCharacters = remainingCharacters;
}
/// <summary>
/// Get the remaining shaped characters that were split up by the <see cref="TextFormatter"/> during the formatting process.
/// </summary>
public IReadOnlyList<ShapedTextCharacters> RemainingCharacters { get; }
}
}

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

@ -0,0 +1,235 @@
using System.Collections.Generic;
namespace Avalonia.Media.TextFormatting
{
internal class TextLineImpl : TextLine
{
private readonly IReadOnlyList<ShapedTextCharacters> _textRuns;
public TextLineImpl(IReadOnlyList<ShapedTextCharacters> textRuns, TextLineMetrics lineMetrics,
TextLineBreak lineBreak = null)
{
_textRuns = textRuns;
LineMetrics = lineMetrics;
LineBreak = lineBreak;
}
/// <inheritdoc/>
public override TextRange TextRange => LineMetrics.TextRange;
/// <inheritdoc/>
public override IReadOnlyList<TextRun> TextRuns => _textRuns;
/// <inheritdoc/>
public override TextLineMetrics LineMetrics { get; }
/// <inheritdoc/>
public override TextLineBreak LineBreak { get; }
/// <inheritdoc/>
public override void Draw(DrawingContext drawingContext, Point origin)
{
var currentX = origin.X;
foreach (var textRun in _textRuns)
{
var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X,
origin.Y + LineMetrics.BaselineOrigin.Y);
textRun.Draw(drawingContext, baselineOrigin);
currentX += textRun.Bounds.Width;
}
}
/// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
if (distance < 0)
{
// hit happens before the line, return the first position
return new CharacterHit(TextRange.Start);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
foreach (var run in _textRuns)
{
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _);
if (distance <= run.Bounds.Width)
{
break;
}
distance -= run.Bounds.Width;
}
return characterHit;
}
/// <inheritdoc/>
public override double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0));
}
/// <inheritdoc/>
public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit)
{
if (TryFindNextCharacterHit(characterHit, out var nextCharacterHit))
{
return nextCharacterHit;
}
return new CharacterHit(TextRange.End); // Can't move, we're after the last character
}
/// <inheritdoc/>
public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit)
{
if (TryFindPreviousCharacterHit(characterHit, out var previousCharacterHit))
{
return previousCharacterHit;
}
return new CharacterHit(TextRange.Start); // Can't move, we're before the first character
}
/// <inheritdoc/>
public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit)
{
// same operation as move-to-previous
return GetPreviousCaretCharacterHit(characterHit);
}
/// <summary>
/// Get distance from line start to the specified codepoint index.
/// </summary>
private double DistanceFromCodepointIndex(int codepointIndex)
{
var currentDistance = 0.0;
foreach (var textRun in _textRuns)
{
if (codepointIndex > textRun.Text.End)
{
currentDistance += textRun.Bounds.Width;
continue;
}
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex));
}
return currentDistance;
}
/// <summary>
/// Tries to find the next character hit.
/// </summary>
/// <param name="characterHit">The current character hit.</param>
/// <param name="nextCharacterHit">The next character hit.</param>
/// <returns></returns>
private bool TryFindNextCharacterHit(CharacterHit characterHit, out CharacterHit nextCharacterHit)
{
nextCharacterHit = characterHit;
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (codepointIndex >= TextRange.Start + TextRange.Length)
{
return false; // Cannot go forward anymore
}
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex);
while (runIndex < TextRuns.Count)
{
var run = _textRuns[runIndex];
nextCharacterHit = run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _);
if (codepointIndex <= nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength)
{
return true;
}
runIndex++;
}
return false;
}
/// <summary>
/// Tries to find the previous character hit.
/// </summary>
/// <param name="characterHit">The current character hit.</param>
/// <param name="previousCharacterHit">The previous character hit.</param>
/// <returns></returns>
private bool TryFindPreviousCharacterHit(CharacterHit characterHit, out CharacterHit previousCharacterHit)
{
previousCharacterHit = characterHit;
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (codepointIndex < TextRange.Start)
{
return false; // Cannot go backward anymore.
}
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex);
while (runIndex >= 0)
{
var run = _textRuns[runIndex];
previousCharacterHit = run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _);
if (previousCharacterHit.FirstCharacterIndex < codepointIndex)
{
return true;
}
runIndex--;
}
return false;
}
/// <summary>
/// Gets the run index of the specified codepoint index.
/// </summary>
/// <param name="codepointIndex">The codepoint index.</param>
/// <returns>The text run index.</returns>
private int GetRunIndexAtCodepointIndex(int codepointIndex)
{
if (codepointIndex >= TextRange.End)
{
return _textRuns.Count - 1;
}
if (codepointIndex <= 0)
{
return 0;
}
var runIndex = 0;
while (runIndex < _textRuns.Count)
{
var run = _textRuns[runIndex];
if (run.Text.End > codepointIndex)
{
return runIndex;
}
runIndex++;
}
return runIndex;
}
}
}

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

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -8,38 +9,20 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
public readonly struct TextLineMetrics public readonly struct TextLineMetrics
{ {
public TextLineMetrics(double width, double xOrigin, double ascent, double descent, double lineGap) public TextLineMetrics(Size size, Point baselineOrigin, TextRange textRange)
{ {
Ascent = ascent; Size = size;
Descent = descent; BaselineOrigin = baselineOrigin;
LineGap = lineGap; TextRange = textRange;
Size = new Size(width, descent - ascent + lineGap);
BaselineOrigin = new Point(xOrigin, -ascent);
} }
/// <summary> /// <summary>
/// Gets the overall recommended distance above the baseline. /// Gets the text range that is covered by the text line.
/// </summary> /// </summary>
/// <value> /// <value>
/// The ascent. /// The text range that is covered by the text line.
/// </value> /// </value>
public double Ascent { get; } public TextRange TextRange { get; }
/// <summary>
/// Gets the overall recommended distance under the baseline.
/// </summary>
/// <value>
/// The descent.
/// </value>
public double Descent { get; }
/// <summary>
/// Gets the overall recommended additional space between two lines of text.
/// </summary>
/// <value>
/// The leading.
/// </value>
public double LineGap { get; }
/// <summary> /// <summary>
/// Gets the size of the text line. /// Gets the size of the text line.
@ -61,10 +44,12 @@ namespace Avalonia.Media.TextFormatting
/// Creates the text line metrics. /// Creates the text line metrics.
/// </summary> /// </summary>
/// <param name="textRuns">The text runs.</param> /// <param name="textRuns">The text runs.</param>
/// <param name="textRange">The text range that is covered by the text line.</param>
/// <param name="paragraphWidth">The paragraph width.</param> /// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="textAlignment">The text alignment.</param> /// <param name="paragraphProperties">The text alignment.</param>
/// <returns></returns> /// <returns></returns>
public static TextLineMetrics Create(IEnumerable<TextRun> textRuns, double paragraphWidth, TextAlignment textAlignment) public static TextLineMetrics Create(IEnumerable<TextRun> textRuns, TextRange textRange, double paragraphWidth,
TextParagraphProperties paragraphProperties)
{ {
var lineWidth = 0.0; var lineWidth = 0.0;
var ascent = 0.0; var ascent = 0.0;
@ -73,31 +58,39 @@ namespace Avalonia.Media.TextFormatting
foreach (var textRun in textRuns) foreach (var textRun in textRuns)
{ {
var shapedRun = (ShapedTextRun)textRun; var shapedRun = (ShapedTextCharacters)textRun;
lineWidth += shapedRun.Bounds.Width; var fontMetrics =
new FontMetrics(shapedRun.Properties.Typeface, shapedRun.Properties.FontRenderingEmSize);
var textFormat = textRun.Style.TextFormat; lineWidth += shapedRun.Bounds.Width;
if (ascent > textRun.Style.TextFormat.FontMetrics.Ascent) if (ascent > fontMetrics.Ascent)
{ {
ascent = textFormat.FontMetrics.Ascent; ascent = fontMetrics.Ascent;
} }
if (descent < textFormat.FontMetrics.Descent) if (descent < fontMetrics.Descent)
{ {
descent = textFormat.FontMetrics.Descent; descent = fontMetrics.Descent;
} }
if (lineGap < textFormat.FontMetrics.LineGap) if (lineGap < fontMetrics.LineGap)
{ {
lineGap = textFormat.FontMetrics.LineGap; lineGap = fontMetrics.LineGap;
} }
} }
var xOrigin = TextLine.GetParagraphOffsetX(lineWidth, paragraphWidth, textAlignment); var xOrigin = TextLine.GetParagraphOffsetX(lineWidth, paragraphWidth, paragraphProperties.TextAlignment);
var baselineOrigin = new Point(xOrigin, -ascent);
var size = new Size(lineWidth,
double.IsNaN(paragraphProperties.LineHeight) || MathUtilities.IsZero(paragraphProperties.LineHeight) ?
descent - ascent + lineGap :
paragraphProperties.LineHeight);
return new TextLineMetrics(lineWidth, xOrigin, ascent, descent, lineGap); return new TextLineMetrics(size, baselineOrigin, textRange);
} }
} }
} }

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

@ -3,38 +3,37 @@
/// <summary> /// <summary>
/// Provides a set of properties that are used during the paragraph layout. /// Provides a set of properties that are used during the paragraph layout.
/// </summary> /// </summary>
public readonly struct TextParagraphProperties public abstract class TextParagraphProperties
{ {
public TextParagraphProperties( /// <summary>
TextStyle defaultTextStyle, /// Gets the text alignment.
TextAlignment textAlignment = TextAlignment.Left, /// </summary>
TextWrapping textWrapping = TextWrapping.NoWrap, public abstract TextAlignment TextAlignment { get; }
TextTrimming textTrimming = TextTrimming.None)
{
DefaultTextStyle = defaultTextStyle;
TextAlignment = textAlignment;
TextWrapping = textWrapping;
TextTrimming = textTrimming;
}
/// <summary> /// <summary>
/// Gets the default text style. /// Gets the default text style.
/// </summary> /// </summary>
public TextStyle DefaultTextStyle { get; } public abstract TextRunProperties DefaultTextRunProperties { get; }
/// <summary> /// <summary>
/// Gets the text alignment. /// If not null, text decorations to apply to all runs in the line. This is in addition
/// to any text decorations specified by the TextRunProperties for individual text runs.
/// </summary> /// </summary>
public TextAlignment TextAlignment { get; } public virtual TextDecorationCollection TextDecorations => null;
/// <summary> /// <summary>
/// Gets the text wrapping. /// Gets the text wrapping.
/// </summary> /// </summary>
public TextWrapping TextWrapping { get; } public abstract TextWrapping TextWrapping { get; }
/// <summary> /// <summary>
/// Gets the text trimming. /// Gets the text trimming.
/// </summary> /// </summary>
public TextTrimming TextTrimming { get; } public abstract TextTrimming TextTrimming { get; }
/// <summary>
/// Paragraph's line height
/// </summary>
public abstract double LineHeight { get; }
} }
} }

16
src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs → src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs

@ -5,9 +5,9 @@ namespace Avalonia.Media.TextFormatting
/// <summary> /// <summary>
/// References a portion of a text buffer. /// References a portion of a text buffer.
/// </summary> /// </summary>
public readonly struct TextPointer public readonly struct TextRange
{ {
public TextPointer(int start, int length) public TextRange(int start, int length)
{ {
Start = start; Start = start;
Length = length; Length = length;
@ -41,30 +41,30 @@ namespace Avalonia.Media.TextFormatting
/// Returns a specified number of contiguous elements from the start of the slice. /// Returns a specified number of contiguous elements from the start of the slice.
/// </summary> /// </summary>
/// <param name="length">The number of elements to return.</param> /// <param name="length">The number of elements to return.</param>
/// <returns>A <see cref="TextPointer"/> that contains the specified number of elements from the start of this slice.</returns> /// <returns>A <see cref="TextRange"/> that contains the specified number of elements from the start of this slice.</returns>
public TextPointer Take(int length) public TextRange Take(int length)
{ {
if (length > Length) if (length > Length)
{ {
throw new ArgumentOutOfRangeException(nameof(length)); throw new ArgumentOutOfRangeException(nameof(length));
} }
return new TextPointer(Start, length); return new TextRange(Start, length);
} }
/// <summary> /// <summary>
/// Bypasses a specified number of elements in the slice and then returns the remaining elements. /// Bypasses a specified number of elements in the slice and then returns the remaining elements.
/// </summary> /// </summary>
/// <param name="length">The number of elements to skip before returning the remaining elements.</param> /// <param name="length">The number of elements to skip before returning the remaining elements.</param>
/// <returns>A <see cref="TextPointer"/> that contains the elements that occur after the specified index in this slice.</returns> /// <returns>A <see cref="TextRange"/> that contains the elements that occur after the specified index in this slice.</returns>
public TextPointer Skip(int length) public TextRange Skip(int length)
{ {
if (length > Length) if (length > Length)
{ {
throw new ArgumentOutOfRangeException(nameof(length)); throw new ArgumentOutOfRangeException(nameof(length));
} }
return new TextPointer(Start + length, Length - length); return new TextRange(Start + length, Length - length);
} }
} }
} }

17
src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs

@ -1,5 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -9,15 +9,22 @@ namespace Avalonia.Media.TextFormatting
[DebuggerTypeProxy(typeof(TextRunDebuggerProxy))] [DebuggerTypeProxy(typeof(TextRunDebuggerProxy))]
public abstract class TextRun public abstract class TextRun
{ {
public static readonly int DefaultTextSourceLength = 1;
/// <summary>
/// Gets the text source length.
/// </summary>
public virtual int TextSourceLength => DefaultTextSourceLength;
/// <summary> /// <summary>
/// Gets the text run's text. /// Gets the text run's text.
/// </summary> /// </summary>
public ReadOnlySlice<char> Text { get; protected set; } public virtual ReadOnlySlice<char> Text => default;
/// <summary> /// <summary>
/// Gets the text run's style. /// A set of properties shared by every characters in the run
/// </summary> /// </summary>
public TextStyle Style { get; protected set; } public virtual TextRunProperties Properties => null;
private class TextRunDebuggerProxy private class TextRunDebuggerProxy
{ {
@ -42,7 +49,7 @@ namespace Avalonia.Media.TextFormatting
} }
} }
public TextStyle Style => _textRun.Style; public TextRunProperties Properties => _textRun.Properties;
} }
} }
} }

90
src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs

@ -0,0 +1,90 @@
using System;
using System.Globalization;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Properties that can change from one run to the next, such as typeface or foreground brush.
/// </summary>
/// <remarks>
/// The client provides a concrete implementation of this abstract run properties class. This
/// allows client to implement their run properties the way that fits with their run formatting
/// store.
/// </remarks>
public abstract class TextRunProperties : IEquatable<TextRunProperties>
{
/// <summary>
/// Run typeface
/// </summary>
public abstract Typeface Typeface { get; }
/// <summary>
/// Em size of font used to format and display text
/// </summary>
public abstract double FontRenderingEmSize { get; }
///<summary>
/// Run TextDecorations.
///</summary>
public abstract TextDecorationCollection TextDecorations { get; }
/// <summary>
/// Brush used to fill text.
/// </summary>
public abstract IBrush ForegroundBrush { get; }
/// <summary>
/// Brush used to paint background of run.
/// </summary>
public abstract IBrush BackgroundBrush { get; }
/// <summary>
/// Run text culture.
/// </summary>
public abstract CultureInfo CultureInfo { get; }
public bool Equals(TextRunProperties other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return Typeface.Equals(other.Typeface) &&
FontRenderingEmSize.Equals(other.FontRenderingEmSize)
&& Equals(TextDecorations, other.TextDecorations) &&
Equals(ForegroundBrush, other.ForegroundBrush) &&
Equals(BackgroundBrush, other.BackgroundBrush) &&
Equals(CultureInfo, other.CultureInfo);
}
public override bool Equals(object obj)
{
return ReferenceEquals(this, obj) || obj is TextRunProperties other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = (Typeface != null ? Typeface.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ FontRenderingEmSize.GetHashCode();
hashCode = (hashCode * 397) ^ (TextDecorations != null ? TextDecorations.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (ForegroundBrush != null ? ForegroundBrush.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (BackgroundBrush != null ? BackgroundBrush.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (CultureInfo != null ? CultureInfo.GetHashCode() : 0);
return hashCode;
}
}
public static bool operator ==(TextRunProperties left, TextRunProperties right)
{
return Equals(left, right);
}
public static bool operator !=(TextRunProperties left, TextRunProperties right)
{
return !Equals(left, right);
}
}
}

8
src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs

@ -1,6 +1,7 @@
using System; using System;
using System.Globalization;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting namespace Avalonia.Media.TextFormatting
{ {
@ -44,9 +45,10 @@ namespace Avalonia.Media.TextFormatting
} }
/// <inheritdoc cref="ITextShaperImpl.ShapeText"/> /// <inheritdoc cref="ITextShaperImpl.ShapeText"/>
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat) public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize,
CultureInfo culture)
{ {
return _platformImpl.ShapeText(text, textFormat); return _platformImpl.ShapeText(text, typeface, fontRenderingEmSize, culture);
} }
} }
} }

39
src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs

@ -1,39 +0,0 @@
using Avalonia.Media.Immutable;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Unique text formatting properties that effect the styling of a text.
/// </summary>
public readonly struct TextStyle
{
public TextStyle(Typeface typeface, double fontRenderingEmSize = 12, IBrush foreground = null,
ImmutableTextDecoration[] textDecorations = null)
: this(new TextFormat(typeface, fontRenderingEmSize), foreground, textDecorations)
{
}
public TextStyle(TextFormat textFormat, IBrush foreground = null,
ImmutableTextDecoration[] textDecorations = null)
{
TextFormat = textFormat;
Foreground = foreground;
TextDecorations = textDecorations;
}
/// <summary>
/// Gets the text format.
/// </summary>
public TextFormat TextFormat { get; }
/// <summary>
/// Gets the foreground.
/// </summary>
public IBrush Foreground { get; }
/// <summary>
/// Gets the text decorations.
/// </summary>
public ImmutableTextDecoration[] TextDecorations { get; }
}
}

24
src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs

@ -1,24 +0,0 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Represents a text run's style and is used during the layout process of the <see cref="TextFormatter"/>.
/// </summary>
public readonly struct TextStyleRun
{
public TextStyleRun(TextPointer textPointer, TextStyle style)
{
TextPointer = textPointer;
Style = style;
}
/// <summary>
/// Gets the text pointer.
/// </summary>
public TextPointer TextPointer { get; }
/// <summary>
/// Gets the text style.
/// </summary>
public TextStyle Style { get; }
}
}

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs

@ -1,4 +1,4 @@
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode namespace Avalonia.Media.TextFormatting.Unicode
{ {

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs

@ -1,4 +1,4 @@
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode namespace Avalonia.Media.TextFormatting.Unicode
{ {

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs

@ -1,4 +1,4 @@
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode namespace Avalonia.Media.TextFormatting.Unicode
{ {

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs

@ -4,7 +4,7 @@
// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode namespace Avalonia.Media.TextFormatting.Unicode
{ {

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

@ -16,7 +16,7 @@
// Ported from: https://github.com/foliojs/linebreak // Ported from: https://github.com/foliojs/linebreak
// Copied from: https://github.com/toptensoftware/RichTextKit // Copied from: https://github.com/toptensoftware/RichTextKit
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode namespace Avalonia.Media.TextFormatting.Unicode
{ {

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

@ -5,6 +5,13 @@ 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>
@ -15,4 +22,4 @@ namespace Avalonia.Media
/// </summary> /// </summary>
Wrap Wrap
} }
} }

12
src/Avalonia.Visuals/Media/Typeface.cs

@ -16,11 +16,11 @@ namespace Avalonia.Media
/// Initializes a new instance of the <see cref="Typeface"/> class. /// Initializes a new instance of the <see cref="Typeface"/> class.
/// </summary> /// </summary>
/// <param name="fontFamily">The font family.</param> /// <param name="fontFamily">The font family.</param>
/// <param name="weight">The font weight.</param>
/// <param name="style">The font style.</param> /// <param name="style">The font style.</param>
/// <param name="weight">The font weight.</param>
public Typeface([NotNull]FontFamily fontFamily, public Typeface([NotNull]FontFamily fontFamily,
FontWeight weight = FontWeight.Normal, FontStyle style = FontStyle.Normal,
FontStyle style = FontStyle.Normal) FontWeight weight = FontWeight.Normal)
{ {
if (weight <= 0) if (weight <= 0)
{ {
@ -39,9 +39,9 @@ namespace Avalonia.Media
/// <param name="style">The font style.</param> /// <param name="style">The font style.</param>
/// <param name="weight">The font weight.</param> /// <param name="weight">The font weight.</param>
public Typeface(string fontFamilyName, public Typeface(string fontFamilyName,
FontWeight weight = FontWeight.Normal, FontStyle style = FontStyle.Normal,
FontStyle style = FontStyle.Normal) FontWeight weight = FontWeight.Normal)
: this(new FontFamily(fontFamilyName), weight, style) : this(new FontFamily(fontFamilyName), style, weight)
{ {
} }

12
src/Avalonia.Visuals/Platform/ITextShaperImpl.cs

@ -1,6 +1,6 @@
using Avalonia.Media; using System.Globalization;
using Avalonia.Media.TextFormatting; using Avalonia.Media;
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.Platform namespace Avalonia.Platform
{ {
@ -13,8 +13,10 @@ namespace Avalonia.Platform
/// Shapes the specified region within the text and returns a resulting glyph run. /// Shapes the specified region within the text and returns a resulting glyph run.
/// </summary> /// </summary>
/// <param name="text">The text.</param> /// <param name="text">The text.</param>
/// <param name="textFormat">The text format.</param> /// <param name="typeface">The typeface.</param>
/// <param name="fontRenderingEmSize">The font rendering em size.</param>
/// <param name="culture">The culture.</param>
/// <returns>A shaped glyph run.</returns> /// <returns>A shaped glyph run.</returns>
GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat); GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture);
} }
} }

5
src/Avalonia.Visuals/Utility/ReadOnlySlice.cs → src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs

@ -2,9 +2,8 @@
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using Avalonia.Utilities;
namespace Avalonia.Utility namespace Avalonia.Utilities
{ {
/// <summary> /// <summary>
/// ReadOnlySlice enables the ability to work with a sequence within a region of memory and retains the position in within that region. /// ReadOnlySlice enables the ability to work with a sequence within a region of memory and retains the position in within that region.
@ -47,7 +46,7 @@ namespace Avalonia.Utility
public int Length { get; } public int Length { get; }
/// <summary> /// <summary>
/// Gets a value that indicates whether this instance of <see cref="ReadOnlySpan{T}"/> is Empty. /// Gets a value that indicates whether this instance of <see cref="ReadOnlySlice{T}"/> is Empty.
/// </summary> /// </summary>
public bool IsEmpty => Length == 0; public bool IsEmpty => Length == 0;

30
src/Avalonia.Visuals/Utilities/ValueSpan.cs

@ -0,0 +1,30 @@
namespace Avalonia.Utilities
{
/// <summary>
/// Pairing of value and positions sharing that value.
/// </summary>
public readonly struct ValueSpan<T>
{
public ValueSpan(int start, int length, T value)
{
Start = start;
Length = length;
Value = value;
}
/// <summary>
/// Get's the start of the span.
/// </summary>
public int Start { get; }
/// <summary>
/// Get's the length of the span.
/// </summary>
public int Length { get; }
/// <summary>
/// Get's the value of the span.
/// </summary>
public T Value { get; }
}
}

2
src/Skia/Avalonia.Skia/FormattedTextImpl.cs

@ -569,7 +569,7 @@ namespace Avalonia.Skia
float constraint = -1; float constraint = -1;
if (_wrapping == TextWrapping.Wrap) if (_wrapping != TextWrapping.NoWrap)
{ {
constraint = widthConstraint <= 0 ? MAX_LINE_WIDTH : widthConstraint; constraint = widthConstraint <= 0 ? MAX_LINE_WIDTH : widthConstraint;
if (constraint > MAX_LINE_WIDTH) if (constraint > MAX_LINE_WIDTH)

14
src/Skia/Avalonia.Skia/TextShaperImpl.cs

@ -1,9 +1,9 @@
using System; using System;
using System.Globalization;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Utility; using Avalonia.Utilities;
using HarfBuzzSharp; using HarfBuzzSharp;
using Buffer = HarfBuzzSharp.Buffer; using Buffer = HarfBuzzSharp.Buffer;
@ -11,7 +11,7 @@ namespace Avalonia.Skia
{ {
internal class TextShaperImpl : ITextShaperImpl internal class TextShaperImpl : ITextShaperImpl
{ {
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat) public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
{ {
using (var buffer = new Buffer()) using (var buffer = new Buffer())
{ {
@ -61,9 +61,11 @@ namespace Avalonia.Skia
buffer.AddUtf16(text.Buffer.Span); buffer.AddUtf16(text.Buffer.Span);
} }
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
buffer.GuessSegmentProperties(); buffer.GuessSegmentProperties();
var glyphTypeface = textFormat.Typeface.GlyphTypeface; var glyphTypeface = typeface.GlyphTypeface;
var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font; var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
@ -71,7 +73,7 @@ namespace Avalonia.Skia
font.GetScale(out var scaleX, out _); font.GetScale(out var scaleX, out _);
var textScale = textFormat.FontRenderingEmSize / scaleX; var textScale = fontRenderingEmSize / scaleX;
var bufferLength = buffer.Length; var bufferLength = buffer.Length;
@ -101,7 +103,7 @@ namespace Avalonia.Skia
SetOffset(glyphPositions, i, textScale, ref glyphOffsets); SetOffset(glyphPositions, i, textScale, ref glyphOffsets);
} }
return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize, return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices), new ReadOnlySlice<ushort>(glyphIndices),
new ReadOnlySlice<double>(glyphAdvances), new ReadOnlySlice<double>(glyphAdvances),
new ReadOnlySlice<Vector>(glyphOffsets), new ReadOnlySlice<Vector>(glyphOffsets),

15
src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs

@ -1,8 +1,9 @@
using Avalonia.Media; using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Utility; using Avalonia.Utilities;
using HarfBuzzSharp; using HarfBuzzSharp;
using Buffer = HarfBuzzSharp.Buffer; using Buffer = HarfBuzzSharp.Buffer;
@ -10,7 +11,7 @@ namespace Avalonia.Direct2D1.Media
{ {
internal class TextShaperImpl : ITextShaperImpl internal class TextShaperImpl : ITextShaperImpl
{ {
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat) public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
{ {
using (var buffer = new Buffer()) using (var buffer = new Buffer())
{ {
@ -62,15 +63,17 @@ namespace Avalonia.Direct2D1.Media
buffer.GuessSegmentProperties(); buffer.GuessSegmentProperties();
var glyphTypeface = textFormat.Typeface.GlyphTypeface; var glyphTypeface = typeface.GlyphTypeface;
var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font; var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
font.Shape(buffer); font.Shape(buffer);
font.GetScale(out var scaleX, out _); font.GetScale(out var scaleX, out _);
var textScale = textFormat.FontRenderingEmSize / scaleX; var textScale = fontRenderingEmSize / scaleX;
var len = buffer.Length; var len = buffer.Length;
@ -104,7 +107,7 @@ namespace Avalonia.Direct2D1.Media
glyphOffsets[i] = new Vector(offsetX, offsetY); glyphOffsets[i] = new Vector(offsetX, offsetY);
} }
return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize, return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices), new ReadOnlySlice<ushort>(glyphIndices),
new ReadOnlySlice<double>(glyphAdvances), new ReadOnlySlice<double>(glyphAdvances),
new ReadOnlySlice<Vector>(glyphOffsets), new ReadOnlySlice<Vector>(glyphOffsets),

4
tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs

@ -41,7 +41,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media
var fontManager = new FontManagerImpl(); var fontManager = new FontManagerImpl();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(new FontFamily("A, B, Arial"), FontWeight.Bold)); new Typeface(new FontFamily("A, B, Arial"), weight: FontWeight.Bold));
var font = glyphTypeface.DWFont; var font = glyphTypeface.DWFont;
@ -105,7 +105,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media
var fontManager = new FontManagerImpl(); var fontManager = new FontManagerImpl();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(s_fontUri, FontWeight.Black, FontStyle.Italic)); new Typeface(s_fontUri, FontStyle.Italic, FontWeight.Black));
var font = glyphTypeface.DWFont; var font = glyphTypeface.DWFont;

2
tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs → tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs

@ -6,7 +6,7 @@ using Avalonia.Media.Fonts;
using Avalonia.Platform; using Avalonia.Platform;
using SkiaSharp; using SkiaSharp;
namespace Avalonia.Skia.UnitTests namespace Avalonia.Skia.UnitTests.Media
{ {
public class CustomFontManagerImpl : IFontManagerImpl public class CustomFontManagerImpl : IFontManagerImpl
{ {

8
tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs → tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs

@ -1,13 +1,11 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Reflection;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using SkiaSharp; using SkiaSharp;
using Xunit; using Xunit;
namespace Avalonia.Skia.UnitTests namespace Avalonia.Skia.UnitTests.Media
{ {
public class FontManagerImplTests public class FontManagerImplTests
{ {
@ -39,7 +37,7 @@ namespace Avalonia.Skia.UnitTests
string fontName = fontManager.GetInstalledFontFamilyNames().First(); string fontName = fontManager.GetInstalledFontFamilyNames().First();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(new FontFamily($"A, B, {fontName}"), FontWeight.Bold)); new Typeface(new FontFamily($"A, B, {fontName}"), weight: FontWeight.Bold));
var skTypeface = glyphTypeface.Typeface; var skTypeface = glyphTypeface.Typeface;
@ -88,7 +86,7 @@ namespace Avalonia.Skia.UnitTests
var fontManager = new FontManagerImpl(); var fontManager = new FontManagerImpl();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(s_fontUri, FontWeight.Black, FontStyle.Italic)); new Typeface(s_fontUri, FontStyle.Italic, FontWeight.Black));
var skTypeface = glyphTypeface.Typeface; var skTypeface = glyphTypeface.Typeface;

4
tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs → tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs

@ -2,7 +2,7 @@
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Xunit; using Xunit;
namespace Avalonia.Skia.UnitTests namespace Avalonia.Skia.UnitTests.Media
{ {
public class SKTypefaceCollectionCacheTests public class SKTypefaceCollectionCacheTests
{ {
@ -19,7 +19,7 @@ namespace Avalonia.Skia.UnitTests
var notoMonoCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(notoMono); var notoMonoCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(notoMono);
var typeface = new Typeface("ABC", FontWeight.Bold, FontStyle.Italic); var typeface = new Typeface("ABC", FontStyle.Italic, FontWeight.Bold);
Assert.Equal("Noto Mono", notoMonoCollection.Get(typeface).FamilyName); Assert.Equal("Noto Mono", notoMonoCollection.Get(typeface).FamilyName);

38
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs

@ -0,0 +1,38 @@
using System;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
internal class FormattableTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextRunProperties _defaultStyle;
private ReadOnlySlice<ValueSpan<TextRunProperties>> _styleSpans;
public FormattableTextSource(string text, TextRunProperties defaultStyle,
ReadOnlySlice<ValueSpan<TextRunProperties>> styleSpans)
{
_text = text.AsMemory();
_defaultStyle = defaultStyle;
_styleSpans = styleSpans;
}
public TextRun GetTextRun(int textSourceIndex)
{
if (_styleSpans.IsEmpty)
{
return new TextEndOfParagraph();
}
var currentSpan = _styleSpans[0];
_styleSpans = _styleSpans.Skip(1);
return new TextCharacters(_text.AsSlice(currentSpan.Start, currentSpan.Length),
_defaultStyle);
}
}
}

36
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs

@ -0,0 +1,36 @@
using System;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
internal class MultiBufferTextSource : ITextSource
{
private readonly string[] _runTexts;
private readonly GenericTextRunProperties _defaultStyle;
public MultiBufferTextSource(GenericTextRunProperties defaultStyle)
{
_defaultStyle = defaultStyle;
_runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" };
}
public static TextRange TextRange => new TextRange(0, 50);
public TextRun GetTextRun(int textSourceIndex)
{
if (textSourceIndex == 50)
{
return new TextEndOfParagraph();
}
var index = textSourceIndex / 10;
var runText = _runTexts[index];
return new TextCharacters(
new ReadOnlySlice<char>(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle);
}
}
}

30
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs

@ -0,0 +1,30 @@
using System;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
internal class SingleBufferTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly GenericTextRunProperties _defaultGenericPropertiesRunProperties;
public SingleBufferTextSource(string text, GenericTextRunProperties defaultProperties)
{
_text = text.AsMemory();
_defaultGenericPropertiesRunProperties = defaultProperties;
}
public TextRun GetTextRun(int textSourceIndex)
{
var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty)
{
return new TextEndOfParagraph();
}
return new TextCharacters(runText, _defaultGenericPropertiesRunProperties);
}
}
}

275
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs

@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests;
using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
public class TextFormatterTests
{
[Fact]
public void Should_Format_TextRuns_With_Default_Style()
{
using (Start())
{
const string text = "0123456789";
var defaultProperties =
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Single(textLine.TextRuns);
var textRun = textLine.TextRuns[0];
Assert.Equal(defaultProperties.Typeface, textRun.Properties.Typeface);
Assert.Equal(defaultProperties.ForegroundBrush, textRun.Properties.ForegroundBrush);
Assert.Equal(text.Length, textRun.Text.Length);
}
}
[Fact]
public void Should_Format_TextRuns_With_Multiple_Buffers()
{
using (Start())
{
var defaultProperties =
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black);
var textSource = new MultiBufferTextSource(defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Equal(5, textLine.TextRuns.Count);
Assert.Equal(50, textLine.TextRange.Length);
}
}
[Fact]
public void Should_Format_TextRuns_With_TextRunStyles()
{
using (Start())
{
const string text = "0123456789";
var defaultProperties =
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black);
var GenericTextRunPropertiesRuns = new[]
{
new ValueSpan<TextRunProperties>(0, 3, defaultProperties),
new ValueSpan<TextRunProperties>(3, 3,
new GenericTextRunProperties(Typeface.Default, 13, foregroundBrush: Brushes.Black)),
new ValueSpan<TextRunProperties>(6, 3,
new GenericTextRunProperties(Typeface.Default, 14, foregroundBrush: Brushes.Black)),
new ValueSpan<TextRunProperties>(9, 1, defaultProperties)
};
var textSource = new FormattableTextSource(text, defaultProperties, GenericTextRunPropertiesRuns);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Equal(text.Length, textLine.TextRange.Length);
for (var i = 0; i < GenericTextRunPropertiesRuns.Length; i++)
{
var GenericTextRunPropertiesRun = GenericTextRunPropertiesRuns[i];
var textRun = textLine.TextRuns[i];
Assert.Equal(GenericTextRunPropertiesRun.Length, textRun.Text.Length);
}
}
}
[Theory]
[InlineData("0123", 1)]
[InlineData("\r\n", 1)]
[InlineData("👍b", 2)]
[InlineData("a👍b", 3)]
[InlineData("a👍子b", 4)]
public void Should_Produce_Unique_Runs(string text, int numberOfRuns)
{
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.Equal(numberOfRuns, textLine.TextRuns.Count);
}
}
[Fact]
public void Should_Split_Run_On_Script()
{
using (Start())
{
const string text = "1234الدولي";
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.Equal(4, textLine.TextRuns[0].Text.Length);
}
}
[InlineData("𐐷𐐷𐐷𐐷𐐷", 10, 1)]
[InlineData("01234 56789 01234 56789", 6, 4)]
[Theory]
public void Should_Wrap_With_Overflow(string text, int expectedCharactersPerLine, int expectedNumberOfLines)
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var numberOfLines = 0;
var currentPosition = 0;
while (currentPosition < text.Length)
{
var textLine =
formatter.FormatLine(textSource, currentPosition, 1,
new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.WrapWithOverflow));
if (text.Length - currentPosition > expectedCharactersPerLine)
{
Assert.Equal(expectedCharactersPerLine, textLine.TextRange.Length);
}
currentPosition += textLine.TextRange.Length;
numberOfLines++;
}
Assert.Equal(expectedNumberOfLines, numberOfLines);
}
}
[InlineData("Whether to turn off HTTPS. This option only applies if Individual, " +
"IndividualB2C, SingleOrg, or MultiOrg aren't used for &#8209;&#8209;auth."
, "Noto Sans", 40)]
[InlineData("01234 56789 01234 56789", "Noto Mono", 7)]
[Theory]
public void Should_Wrap(string text, string familyName, int numberOfCharactersPerLine)
{
using (Start())
{
var lineBreaker = new LineBreakEnumerator(text.AsMemory());
var expected = new List<int>();
while (lineBreaker.MoveNext())
{
expected.Add(lineBreaker.Current.PositionWrap - 1);
}
var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#" +
familyName);
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var glyph = typeface.GlyphTypeface.GetGlyph('a');
var advance = typeface.GlyphTypeface.GetGlyphAdvance(glyph) *
(12.0 / typeface.GlyphTypeface.DesignEmHeight);
var paragraphWidth = advance * numberOfCharactersPerLine;
var currentPosition = 0;
while (currentPosition < text.Length)
{
var textLine =
formatter.FormatLine(textSource, currentPosition, paragraphWidth,
new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap));
Assert.True(expected.Contains(textLine.TextRange.End));
var index = expected.IndexOf(textLine.TextRange.End);
for (var i = 0; i <= index; i++)
{
expected.RemoveAt(0);
}
currentPosition += textLine.TextRange.Length;
}
}
}
[Fact]
public void Should_Produce_Fixed_Height_Lines()
{
using (Start())
{
const string text = "012345";
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, lineHeight: 50));
Assert.Equal(50, textLine.LineMetrics.Size.Height);
}
}
public static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl()));
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().ToConstant(new FontManager(new CustomFontManagerImpl()));
return disposable;
}
}
}

145
tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs → tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

@ -4,15 +4,33 @@ using Avalonia.Media;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Avalonia.Utilities;
using Xunit; using Xunit;
namespace Avalonia.Skia.UnitTests namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{ {
public class TextLayoutTests public class TextLayoutTests
{ {
private static readonly string s_singleLineText = "0123456789"; private static readonly string s_singleLineText = "0123456789";
private static readonly string s_multiLineText = "012345678\r\r0123456789"; private static readonly string s_multiLineText = "012345678\r\r0123456789";
[InlineData("01234\r01234\r", 3)]
[InlineData("01234\r01234", 2)]
[Theory]
public void Should_Break_Lines(string text, int numberOfLines)
{
using (Start())
{
var layout = new TextLayout(
text,
Typeface.Default,
12.0f,
Brushes.Black);
Assert.Equal(numberOfLines, layout.TextLines.Count);
}
}
[Fact] [Fact]
public void Should_Apply_TextStyleSpan_To_Text_In_Between() public void Should_Apply_TextStyleSpan_To_Text_In_Between()
{ {
@ -22,17 +40,16 @@ namespace Avalonia.Skia.UnitTests
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(1, 2,
new TextPointer(1, 2), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
new TextStyle(Typeface.Default, 12, foreground))
}; };
var layout = new TextLayout( var layout = new TextLayout(
s_multiLineText, s_multiLineText,
Typeface.Default, Typeface.Default,
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
textStyleOverrides : spans); textStyleOverrides: spans);
var textLine = layout.TextLines[0]; var textLine = layout.TextLines[0];
@ -46,7 +63,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("12", actual); Assert.Equal("12", actual);
Assert.Equal(foreground, textRun.Style.Foreground); Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
} }
} }
@ -61,9 +78,8 @@ namespace Avalonia.Skia.UnitTests
{ {
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(0, i,
new TextPointer(0, i), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
new TextStyle(Typeface.Default, 12, foreground))
}; };
var expected = new TextLayout( var expected = new TextLayout(
@ -72,22 +88,22 @@ namespace Avalonia.Skia.UnitTests
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
textWrapping: TextWrapping.Wrap, textWrapping: TextWrapping.Wrap,
maxWidth : 25); maxWidth: 25);
var actual = new TextLayout( var actual = new TextLayout(
s_multiLineText, s_multiLineText,
Typeface.Default, Typeface.Default,
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
textWrapping : TextWrapping.Wrap, textWrapping: TextWrapping.Wrap,
maxWidth : 25, maxWidth: 25,
textStyleOverrides : spans); textStyleOverrides: spans);
Assert.Equal(expected.TextLines.Count, actual.TextLines.Count); Assert.Equal(expected.TextLines.Count, actual.TextLines.Count);
for (var j = 0; j < actual.TextLines.Count; j++) for (var j = 0; j < actual.TextLines.Count; j++)
{ {
Assert.Equal(expected.TextLines[j].Text.Length, actual.TextLines[j].Text.Length); Assert.Equal(expected.TextLines[j].TextRange.Length, actual.TextLines[j].TextRange.Length);
Assert.Equal(expected.TextLines[j].TextRuns.Sum(x => x.Text.Length), Assert.Equal(expected.TextLines[j].TextRuns.Sum(x => x.Text.Length),
actual.TextLines[j].TextRuns.Sum(x => x.Text.Length)); actual.TextLines[j].TextRuns.Sum(x => x.Text.Length));
@ -105,9 +121,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(0, 2,
new TextPointer(0, 2), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
new TextStyle(Typeface.Default, 12, foreground))
}; };
var layout = new TextLayout( var layout = new TextLayout(
@ -115,7 +130,7 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default, Typeface.Default,
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
textStyleOverrides : spans); textStyleOverrides: spans);
var textLine = layout.TextLines[0]; var textLine = layout.TextLines[0];
@ -130,7 +145,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("01", actual); Assert.Equal("01", actual);
Assert.Equal(foreground, textRun.Style.Foreground); Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
} }
} }
@ -143,9 +158,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(8, 2,
new TextPointer(8, 2), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground)),
new TextStyle(Typeface.Default, 12, foreground))
}; };
var layout = new TextLayout( var layout = new TextLayout(
@ -153,7 +167,7 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default, Typeface.Default,
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
textStyleOverrides : spans); textStyleOverrides: spans);
var textLine = layout.TextLines[0]; var textLine = layout.TextLines[0];
@ -167,7 +181,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("89", actual); Assert.Equal("89", actual);
Assert.Equal(foreground, textRun.Style.Foreground); Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
} }
} }
@ -180,9 +194,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(0, 1,
new TextPointer(0, 1), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
new TextStyle(Typeface.Default, 12, foreground))
}; };
var layout = new TextLayout( var layout = new TextLayout(
@ -190,7 +203,7 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default, Typeface.Default,
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
textStyleOverrides : spans); textStyleOverrides: spans);
var textLine = layout.TextLines[0]; var textLine = layout.TextLines[0];
@ -200,7 +213,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal(1, textRun.Text.Length); Assert.Equal(1, textRun.Text.Length);
Assert.Equal(foreground, textRun.Style.Foreground); Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
} }
} }
@ -215,9 +228,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(2, 2,
new TextPointer(2, 2), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
new TextStyle(Typeface.Default, 12, foreground))
}; };
var layout = new TextLayout( var layout = new TextLayout(
@ -239,7 +251,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("😄", actual); Assert.Equal("😄", actual);
Assert.Equal(foreground, textRun.Style.Foreground); Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
} }
} }
@ -254,7 +266,7 @@ namespace Avalonia.Skia.UnitTests
12.0f, 12.0f,
Brushes.Black.ToImmutable()); Brushes.Black.ToImmutable());
Assert.Equal(s_multiLineText.Length, layout.TextLines.Sum(x => x.Text.Length)); Assert.Equal(s_multiLineText.Length, layout.TextLines.Sum(x => x.TextRange.Length));
} }
} }
@ -291,9 +303,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(0, 24,
new TextPointer(0, 24), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
new TextStyle(Typeface.Default, 12, foreground))
}; };
var layout = new TextLayout( var layout = new TextLayout(
@ -301,8 +312,8 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default, Typeface.Default,
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
textWrapping : TextWrapping.Wrap, textWrapping: TextWrapping.Wrap,
maxWidth : 180, maxWidth: 180,
textStyleOverrides: spans); textStyleOverrides: spans);
Assert.Equal( Assert.Equal(
@ -322,9 +333,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[] var spans = new[]
{ {
new TextStyleRun( new ValueSpan<TextRunProperties>(5, 20,
new TextPointer(5, 20), new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
new TextStyle(Typeface.Default, 12, foreground))
}; };
var layout = new TextLayout( var layout = new TextLayout(
@ -332,13 +342,13 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default, Typeface.Default,
12.0f, 12.0f,
Brushes.Black.ToImmutable(), Brushes.Black.ToImmutable(),
maxWidth : 200, maxWidth: 200,
maxHeight : 125, maxHeight: 125,
textStyleOverrides: spans); textStyleOverrides: spans);
Assert.Equal(foreground, layout.TextLines[0].TextRuns[1].Style.Foreground); Assert.Equal(foreground, layout.TextLines[0].TextRuns[1].Properties.ForegroundBrush);
Assert.Equal(foreground, layout.TextLines[1].TextRuns[0].Style.Foreground); Assert.Equal(foreground, layout.TextLines[1].TextRuns[0].Properties.ForegroundBrush);
Assert.Equal(foreground, layout.TextLines[2].TextRuns[0].Style.Foreground); Assert.Equal(foreground, layout.TextLines[2].TextRuns[0].Properties.ForegroundBrush);
} }
} }
@ -355,7 +365,7 @@ namespace Avalonia.Skia.UnitTests
12.0f, 12.0f,
Brushes.Black.ToImmutable()); Brushes.Black.ToImmutable());
var shapedRun = (ShapedTextRun)layout.TextLines[0].TextRuns[0]; var shapedRun = (ShapedTextCharacters)layout.TextLines[0].TextRuns[0];
var glyphRun = shapedRun.GlyphRun; var glyphRun = shapedRun.GlyphRun;
@ -390,7 +400,7 @@ namespace Avalonia.Skia.UnitTests
foreach (var textRun in textLine.TextRuns) foreach (var textRun in textLine.TextRuns)
{ {
var shapedRun = (ShapedTextRun)textRun; var shapedRun = (ShapedTextCharacters)textRun;
var glyphRun = shapedRun.GlyphRun; var glyphRun = shapedRun.GlyphRun;
@ -426,13 +436,13 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal(1, layout.TextLines[0].TextRuns.Count); Assert.Equal(1, layout.TextLines[0].TextRuns.Count);
Assert.Equal(expectedLength, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters.Length); Assert.Equal(expectedLength, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters.Length);
Assert.Equal(5, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[5]); Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[5]);
if(expectedLength == 7) if (expectedLength == 7)
{ {
Assert.Equal(5, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[6]); Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[6]);
} }
} }
} }
@ -467,7 +477,7 @@ namespace Avalonia.Skia.UnitTests
var textLine = layout.TextLines[0]; var textLine = layout.TextLines[0];
var textRun = (ShapedTextRun)textLine.TextRuns[0]; var textRun = (ShapedTextCharacters)textLine.TextRuns[0];
Assert.Equal(7, textRun.Text.Length); Assert.Equal(7, textRun.Text.Length);
@ -526,9 +536,28 @@ namespace Avalonia.Skia.UnitTests
} }
} }
[Fact]
public void Should_Produce_Fixed_Height_Lines()
{
using (Start())
{
var layout = new TextLayout(
s_multiLineText,
Typeface.Default,
12,
Brushes.Black,
lineHeight: 50);
foreach (var line in layout.TextLines)
{
Assert.Equal(50, line.LineMetrics.Size.Height);
}
}
}
private const string Text = "日本でTest一番読まれている英字新聞・ジャパンタイムズが発信する国内外ニュースと、様々なジャンルの特集記事。"; private const string Text = "日本でTest一番読まれている英字新聞・ジャパンタイムズが発信する国内外ニュースと、様々なジャンルの特集記事。";
[Fact(Skip= "Only used for profiling.")] [Fact(Skip = "Only used for profiling.")]
public void Should_Wrap() public void Should_Wrap()
{ {
using (Start()) using (Start())
@ -546,12 +575,12 @@ namespace Avalonia.Skia.UnitTests
} }
} }
public static IDisposable Start() private static IDisposable Start()
{ {
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null), .With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl(), textShaperImpl: new TextShaperImpl(),
fontManagerImpl : new CustomFontManagerImpl())); fontManagerImpl: new CustomFontManagerImpl()));
return disposable; return disposable;
} }

175
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@ -0,0 +1,175 @@
using System;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
public class TextLineTests
{
[InlineData("𐐷𐐷𐐷𐐷𐐷")]
[InlineData("𐐷1234")]
[Theory]
public void Should_Get_Next_Caret_CharacterHit(string text)
{
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));
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters)
.ToArray();
var nextCharacterHit = new CharacterHit(0);
for (var i = 1; i < clusters.Length; i++)
{
nextCharacterHit = textLine.GetNextCaretCharacterHit(nextCharacterHit);
Assert.Equal(clusters[i], nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength);
}
}
}
[InlineData("𐐷𐐷𐐷𐐷𐐷")]
[InlineData("𐐷1234")]
[Theory]
public void Should_Get_Previous_Caret_CharacterHit(string text)
{
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));
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters)
.ToArray();
var previousCharacterHit = new CharacterHit(clusters[^1]);
for (var i = clusters.Length - 2; i > 0; i--)
{
previousCharacterHit = textLine.GetPreviousCaretCharacterHit(previousCharacterHit);
Assert.Equal(clusters[i], previousCharacterHit.FirstCharacterIndex);
}
}
}
[Fact]
public void Should_Get_Distance_From_CharacterHit()
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new MultiBufferTextSource(defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var currentDistance = 0.0;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextCharacters)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
Assert.Equal(currentDistance, distance);
currentDistance += advance;
}
}
Assert.Equal(currentDistance,
textLine.GetDistanceFromCharacterHit(new CharacterHit(MultiBufferTextSource.TextRange.Length)));
}
}
[Fact]
public void Should_Get_CharacterHit_From_Distance()
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new MultiBufferTextSource(defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var currentDistance = 0.0;
CharacterHit characterHit;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextCharacters)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
characterHit = textLine.GetCharacterHitFromDistance(currentDistance);
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
currentDistance += advance;
}
}
characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width);
Assert.Equal(MultiBufferTextSource.TextRange.End, characterHit.FirstCharacterIndex);
}
}
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl(),
fontManagerImpl: new CustomFontManagerImpl()));
return disposable;
}
}
}

373
tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs

@ -1,373 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests;
using Avalonia.Utility;
using Xunit;
namespace Avalonia.Skia.UnitTests
{
public class SimpleTextFormatterTests
{
[Fact]
public void Should_Format_TextRuns_With_Default_Style()
{
using (Start())
{
const string text = "0123456789";
var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black);
var textSource = new SimpleTextSource(text, defaultTextRunStyle);
var formatter = new SimpleTextFormatter();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Single(textLine.TextRuns);
var textRun = textLine.TextRuns[0];
Assert.Equal(defaultTextRunStyle.TextFormat, textRun.Style.TextFormat);
Assert.Equal(defaultTextRunStyle.Foreground, textRun.Style.Foreground);
Assert.Equal(text.Length, textRun.Text.Length);
}
}
[Fact]
public void Should_Format_TextRuns_With_Multiple_Buffers()
{
using (Start())
{
var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black);
var textSource = new MultiBufferTextSource(defaultTextRunStyle);
var formatter = new SimpleTextFormatter();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new TextParagraphProperties(defaultTextRunStyle));
Assert.Equal(5, textLine.TextRuns.Count);
Assert.Equal(50, textLine.Text.Length);
}
}
private class MultiBufferTextSource : ITextSource
{
private readonly string[] _runTexts;
private readonly TextStyle _defaultStyle;
public MultiBufferTextSource(TextStyle defaultStyle)
{
_defaultStyle = defaultStyle;
_runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" };
}
public TextPointer TextPointer => new TextPointer(0, 50);
public TextRun GetTextRun(int textSourceIndex)
{
if (textSourceIndex == 50)
{
return new TextEndOfParagraph();
}
var index = textSourceIndex / 10;
var runText = _runTexts[index];
return new TextCharacters(
new ReadOnlySlice<char>(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle);
}
}
[Fact]
public void Should_Format_TextRuns_With_TextRunStyles()
{
using (Start())
{
const string text = "0123456789";
var defaultStyle = new TextStyle(Typeface.Default, 12, Brushes.Black);
var textStyleRuns = new[]
{
new TextStyleRun(new TextPointer(0, 3), defaultStyle ),
new TextStyleRun(new TextPointer(3, 3), new TextStyle(Typeface.Default, 13, Brushes.Black) ),
new TextStyleRun(new TextPointer(6, 3), new TextStyle(Typeface.Default, 14, Brushes.Black) ),
new TextStyleRun(new TextPointer(9, 1), defaultStyle )
};
var textSource = new FormattableTextSource(text, defaultStyle, textStyleRuns);
var formatter = new SimpleTextFormatter();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Equal(text.Length, textLine.Text.Length);
for (var i = 0; i < textStyleRuns.Length; i++)
{
var textStyleRun = textStyleRuns[i];
var textRun = textLine.TextRuns[i];
Assert.Equal(textStyleRun.TextPointer.Length, textRun.Text.Length);
}
}
}
private class FormattableTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextStyle _defaultStyle;
private ReadOnlySlice<TextStyleRun> _textStyleRuns;
public FormattableTextSource(string text, TextStyle defaultStyle, ReadOnlySlice<TextStyleRun> textStyleRuns)
{
_text = text.AsMemory();
_defaultStyle = defaultStyle;
_textStyleRuns = textStyleRuns;
}
public TextRun GetTextRun(int textSourceIndex)
{
if (_textStyleRuns.IsEmpty)
{
return new TextEndOfParagraph();
}
var styleRun = _textStyleRuns[0];
_textStyleRuns = _textStyleRuns.Skip(1);
return new TextCharacters(_text.AsSlice(styleRun.TextPointer.Start, styleRun.TextPointer.Length),
_defaultStyle);
}
}
[Theory]
[InlineData("0123", 1)]
[InlineData("\r\n", 1)]
[InlineData("👍b", 2)]
[InlineData("a👍b", 3)]
[InlineData("a👍子b", 4)]
public void Should_Produce_Unique_Runs(string text, int numberOfRuns)
{
using (Start())
{
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Equal(numberOfRuns, textLine.TextRuns.Count);
}
}
private class SimpleTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextStyle _defaultTextStyle;
public SimpleTextSource(string text, TextStyle defaultText)
{
_text = text.AsMemory();
_defaultTextStyle = defaultText;
}
public TextRun GetTextRun(int textSourceIndex)
{
var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty)
{
return new TextEndOfParagraph();
}
return new TextCharacters(runText, _defaultTextStyle);
}
}
[Fact]
public void Should_Split_Run_On_Script()
{
using (Start())
{
const string text = "1234الدولي";
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Equal(4, textLine.TextRuns[0].Text.Length);
}
}
[Fact]
public void Should_Get_Distance_From_CharacterHit()
{
using (Start())
{
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
var currentDistance = 0.0;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextRun)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
Assert.Equal(currentDistance, distance);
currentDistance += advance;
}
}
Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(textSource.TextPointer.Length)));
}
}
[Fact]
public void Should_Get_CharacterHit_From_Distance()
{
using (Start())
{
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
var currentDistance = 0.0;
CharacterHit characterHit;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextRun)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
characterHit = textLine.GetCharacterHitFromDistance(currentDistance);
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
currentDistance += advance;
}
}
characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width);
Assert.Equal(textSource.TextPointer.End, characterHit.FirstCharacterIndex);
}
}
[InlineData("Whether to turn off HTTPS. This option only applies if Individual, " +
"IndividualB2C, SingleOrg, or MultiOrg aren't used for &#8209;&#8209;auth."
, "Noto Sans", 40)]
[InlineData("01234 56789 01234 56789", "Noto Mono", 7)]
[Theory]
public void Should_Wrap_Text(string text, string familyName, int numberOfCharactersPerLine)
{
using (Start())
{
var lineBreaker = new LineBreakEnumerator(text.AsMemory());
var expected = new List<int>();
while (lineBreaker.MoveNext())
{
expected.Add(lineBreaker.Current.PositionWrap - 1);
}
var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#" +
familyName);
var defaultStyle = new TextStyle(typeface);
var textSource = new SimpleTextSource(text, defaultStyle);
var formatter = new SimpleTextFormatter();
var glyph = typeface.GlyphTypeface.GetGlyph('a');
var advance = typeface.GlyphTypeface.GetGlyphAdvance(glyph) *
(12.0 / typeface.GlyphTypeface.DesignEmHeight);
var paragraphWidth = advance * numberOfCharactersPerLine;
var currentPosition = 0;
while (currentPosition < text.Length)
{
var textLine =
formatter.FormatLine(textSource, currentPosition, paragraphWidth,
new TextParagraphProperties(defaultStyle, textWrapping: TextWrapping.Wrap));
Assert.True(expected.Contains(textLine.Text.End));
var index = expected.IndexOf(textLine.Text.End);
for (var i = 0; i <= index; i++)
{
expected.RemoveAt(0);
}
currentPosition += textLine.Text.Length;
}
}
}
public static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl()));
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().ToConstant(new FontManager(new CustomFontManagerImpl()));
return disposable;
}
}
}

19
tests/Avalonia.UnitTests/MockTextShaperImpl.cs

@ -1,19 +1,19 @@
using Avalonia.Media; using System;
using Avalonia.Media.TextFormatting; using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Utility; using Avalonia.Utilities;
namespace Avalonia.UnitTests namespace Avalonia.UnitTests
{ {
public class MockTextShaperImpl : ITextShaperImpl public class MockTextShaperImpl : ITextShaperImpl
{ {
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat) public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
{ {
var glyphTypeface = textFormat.Typeface.GlyphTypeface; var glyphTypeface = typeface.GlyphTypeface;
var glyphIndices = new ushort[text.Length]; var glyphIndices = new ushort[text.Length];
var height = textFormat.FontMetrics.LineHeight; var glyphCount = 0;
var width = 0.0;
for (var i = 0; i < text.Length;) for (var i = 0; i < text.Length;)
{ {
@ -27,10 +27,11 @@ namespace Avalonia.UnitTests
glyphIndices[index] = glyph; glyphIndices[index] = glyph;
width += glyphTypeface.GetGlyphAdvance(glyph); glyphCount++;
} }
return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize, glyphIndices, characters: text); return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices.AsMemory(0, glyphCount)), characters: text);
} }
} }
} }

2
tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs

@ -1,7 +1,7 @@
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Avalonia.Utility; using Avalonia.Utilities;
using Xunit; using Xunit;
namespace Avalonia.Visuals.UnitTests.Media namespace Avalonia.Visuals.UnitTests.Media

2
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs

@ -1,6 +1,6 @@
using System; using System;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utility; using Avalonia.Utilities;
using Xunit; using Xunit;
namespace Avalonia.Visuals.UnitTests.Media.TextFormatting namespace Avalonia.Visuals.UnitTests.Media.TextFormatting

2
tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs

@ -9,7 +9,7 @@ namespace Avalonia.Visuals.UnitTests.Media
[Fact] [Fact]
public void Exception_Should_Be_Thrown_If_FontWeight_LessThanEqualTo_Zero() public void Exception_Should_Be_Thrown_If_FontWeight_LessThanEqualTo_Zero()
{ {
Assert.Throws<ArgumentException>(() => new Typeface("foo", 0, (FontStyle)12)); Assert.Throws<ArgumentException>(() => new Typeface("foo", (FontStyle)12, 0));
} }
[Fact] [Fact]

Loading…
Cancel
Save