Sfoglia il codice sorgente

Merge branch 'feature/experimental-acrylic-brush' of https://github.com/AvaloniaUI/Avalonia into feature/experimental-acrylic-brush

Dan Walmsley 5 anni fa
parent
commit
d06325d76d
61 ha cambiato i file con 2569 aggiunte e 1964 eliminazioni
  1. 30 39
      samples/ControlCatalog/Pages/TextBlockPage.xaml
  2. 8 7
      src/Avalonia.Controls/Primitives/AccessText.cs
  3. 28 7
      src/Avalonia.Controls/TextBlock.cs
  4. 0 4
      src/Avalonia.Desktop/AppBuilderDesktopExtensions.cs
  5. 2 2
      src/Avalonia.Visuals/Media/FontManager.cs
  6. 85 27
      src/Avalonia.Visuals/Media/GlyphRun.cs
  7. 146 36
      src/Avalonia.Visuals/Media/TextDecoration.cs
  8. 0 17
      src/Avalonia.Visuals/Media/TextDecorationCollection.cs
  9. 1 1
      src/Avalonia.Visuals/Media/TextDecorationUnit.cs
  10. 2 4
      src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs
  11. 69 0
      src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs
  12. 40 0
      src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs
  13. 23 0
      src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs
  14. 164 0
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs
  15. 0 212
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs
  16. 0 395
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs
  17. 0 259
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs
  18. 175 6
      src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs
  19. 0 71
      src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs
  20. 4 144
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs
  21. 544 0
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs
  22. 84 108
      src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs
  23. 12 5
      src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs
  24. 17 0
      src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs
  25. 235 0
      src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs
  26. 31 38
      src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs
  27. 16 17
      src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs
  28. 8 8
      src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs
  29. 12 5
      src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs
  30. 90 0
      src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs
  31. 5 3
      src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs
  32. 0 39
      src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs
  33. 0 24
      src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs
  34. 1 1
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs
  35. 1 1
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs
  36. 1 1
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs
  37. 1 1
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs
  38. 1 1
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
  39. 8 1
      src/Avalonia.Visuals/Media/TextWrapping.cs
  40. 6 6
      src/Avalonia.Visuals/Media/Typeface.cs
  41. 7 5
      src/Avalonia.Visuals/Platform/ITextShaperImpl.cs
  42. 2 3
      src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs
  43. 30 0
      src/Avalonia.Visuals/Utilities/ValueSpan.cs
  44. 1 1
      src/Skia/Avalonia.Skia/FormattedTextImpl.cs
  45. 8 6
      src/Skia/Avalonia.Skia/TextShaperImpl.cs
  46. 9 6
      src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs
  47. 2 2
      tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs
  48. 1 1
      tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs
  49. 3 5
      tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs
  50. 2 2
      tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs
  51. 38 0
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs
  52. 36 0
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs
  53. 30 0
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs
  54. 275 0
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  55. 87 58
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  56. 175 0
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
  57. 0 373
      tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs
  58. 10 9
      tests/Avalonia.UnitTests/MockTextShaperImpl.cs
  59. 1 1
      tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
  60. 1 1
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs
  61. 1 1
      tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs

+ 30 - 39
samples/ControlCatalog/Pages/TextBlockPage.xaml

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

+ 8 - 7
src/Avalonia.Controls/Primitives/AccessText.cs

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

+ 28 - 7
src/Avalonia.Controls/TextBlock.cs

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

+ 0 - 4
src/Avalonia.Desktop/AppBuilderDesktopExtensions.cs

@@ -46,10 +46,6 @@ namespace Avalonia
             where TAppBuilder : AppBuilderBase<TAppBuilder>, new()
              => builder.UseX11();
 
-        static void LoadDirect2D1<TAppBuilder>(TAppBuilder builder)
-            where TAppBuilder : AppBuilderBase<TAppBuilder>, new()
-             => builder.UseDirect2D1();
-
         static void LoadSkia<TAppBuilder>(TAppBuilder builder)
             where TAppBuilder : AppBuilderBase<TAppBuilder>, new()
              => builder.UseSkia();

+ 2 - 2
src/Avalonia.Visuals/Media/FontManager.cs

@@ -100,7 +100,7 @@ namespace Avalonia.Media
                     return typeface;
                 }
 
-                typeface = new Typeface(fontFamily, fontWeight, fontStyle);
+                typeface = new Typeface(fontFamily, fontStyle, fontWeight);
 
                 if (_typefaceCache.TryAdd(key, typeface))
                 {
@@ -143,7 +143,7 @@ namespace Avalonia.Media
             }
 
             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;
 
             return matchedTypeface;

+ 85 - 27
src/Avalonia.Visuals/Media/GlyphRun.cs

@@ -1,7 +1,7 @@
 using System;
 using System.Collections.Generic;
 using Avalonia.Platform;
-using Avalonia.Utility;
+using Avalonia.Utilities;
 
 namespace Avalonia.Media
 {
@@ -205,13 +205,16 @@ namespace Avalonia.Media
 
             var glyphIndex = FindGlyphIndex(characterHit.FirstCharacterIndex);
 
-            var currentCluster = _glyphClusters[glyphIndex];
-
-            if (characterHit.TrailingLength > 0)
+            if (!GlyphClusters.IsEmpty)
             {
-                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));
 
@@ -370,26 +373,31 @@ namespace Avalonia.Media
         /// </returns>
         public int FindGlyphIndex(int characterIndex)
         {
+            if (GlyphClusters.IsEmpty)
+            {
+                return characterIndex;
+            }
+
             if (IsLeftToRight)
             {
-                if (characterIndex < _glyphClusters[0])
+                if (characterIndex < GlyphClusters[0])
                 {
                     return 0;
                 }
 
-                if (characterIndex > _glyphClusters[_glyphClusters.Length - 1])
+                if (characterIndex > GlyphClusters[GlyphClusters.Length - 1])
                 {
                     return _glyphClusters.End;
                 }
             }
             else
             {
-                if (characterIndex < _glyphClusters[_glyphClusters.Length - 1])
+                if (characterIndex < GlyphClusters[GlyphClusters.Length - 1])
                 {
                     return _glyphClusters.End;
                 }
 
-                if (characterIndex > _glyphClusters[0])
+                if (characterIndex > GlyphClusters[0])
                 {
                     return 0;
                 }
@@ -397,7 +405,7 @@ namespace Avalonia.Media
 
             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.
             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;
@@ -440,34 +458,74 @@ namespace Avalonia.Media
 
             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
                 {
-                    width += GlyphAdvances[start];
+                    currentIndex--;
+
+                    if (currentIndex < 0)
+                    {
+                        break;
+                    }
                 }
 
-                trailingLength++;
-                start++;
+                nextCluster = GlyphClusters[currentIndex];
+            }
+
+            int trailingLength;
+
+            if (nextCluster == cluster)
+            {
+                trailingLength = Characters.Start + Characters.Length - cluster;
+            }
+            else
+            {
+                trailingLength = nextCluster - cluster;
             }
 
-            if (start == _glyphClusters.Length &&
-                currentCluster + trailingLength != Characters.Start + Characters.Length)
+            return new CharacterHit(cluster, trailingLength);
+        }
+
+        /// <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>

+ 146 - 36
src/Avalonia.Visuals/Media/TextDecoration.cs

@@ -1,4 +1,5 @@
-using Avalonia.Media.Immutable;
+using Avalonia.Collections;
+using Avalonia.Media.TextFormatting;
 
 namespace Avalonia.Media
 {
@@ -14,28 +15,52 @@ namespace Avalonia.Media
             AvaloniaProperty.Register<TextDecoration, TextDecorationLocation>(nameof(Location));
 
         /// <summary>
-        /// Defines the <see cref="Pen"/> property.
+        /// Defines the <see cref="Stroke"/> property.
         /// </summary>
-        public static readonly StyledProperty<IPen> PenProperty =
-            AvaloniaProperty.Register<TextDecoration, IPen>(nameof(Pen));
+        public static readonly StyledProperty<IBrush> StrokeProperty =
+            AvaloniaProperty.Register<TextDecoration, IBrush>(nameof(Stroke));
 
         /// <summary>
-        /// Defines the <see cref="PenThicknessUnit"/> property.
+        /// Defines the <see cref="StrokeThicknessUnit"/> property.
         /// </summary>
-        public static readonly StyledProperty<TextDecorationUnit> PenThicknessUnitProperty =
-            AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(PenThicknessUnit));
+        public static readonly StyledProperty<TextDecorationUnit> StrokeThicknessUnitProperty =
+            AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(StrokeThicknessUnit));
 
         /// <summary>
-        /// Defines the <see cref="PenOffset"/> property.
+        /// Defines the <see cref="StrokeDashArray"/> property.
         /// </summary>
-        public static readonly StyledProperty<double> PenOffsetProperty =
-            AvaloniaProperty.Register<TextDecoration, double>(nameof(PenOffset));
+        public static readonly StyledProperty<AvaloniaList<double>> StrokeDashArrayProperty =
+            AvaloniaProperty.Register<TextDecoration, AvaloniaList<double>>(nameof(StrokeDashArray));
 
         /// <summary>
-        /// Defines the <see cref="PenOffsetUnit"/> property.
+        /// Defines the <see cref="StrokeDashOffset"/> property.
         /// </summary>
-        public static readonly StyledProperty<TextDecorationUnit> PenOffsetUnitProperty =
-            AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(PenOffsetUnit));
+        public static readonly StyledProperty<double> StrokeDashOffsetProperty =
+            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>
         /// Gets or sets the location.
@@ -50,54 +75,139 @@ namespace Avalonia.Media
         }
 
         /// <summary>
-        /// Gets or sets the pen.
+        /// Gets or sets the <see cref="IBrush"/> that specifies how the <see cref="TextDecoration"/> is painted.
         /// </summary>
-        /// <value>
-        ///     The pen.
-        /// </value>
-        public IPen Pen
+        public IBrush Stroke
+        {
+            get { return GetValue(StrokeProperty); }
+            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);
-            set => SetValue(PenProperty, value);
+            get { return GetValue(StrokeThicknessProperty); }
+            set { SetValue(StrokeThicknessProperty, value); }
         }
 
         /// <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>
-        public TextDecorationUnit PenThicknessUnit
+        public PenLineCap StrokeLineCap
         {
-            get => GetValue(PenThicknessUnitProperty);
-            set => SetValue(PenThicknessUnitProperty, value);
+            get { return GetValue(StrokeLineCapProperty); }
+            set { SetValue(StrokeLineCapProperty, value); }
         }
 
         /// <summary>
-        /// Gets or sets the pen offset.
+        /// The stroke's offset.
         /// </summary>
         /// <value>
         /// The pen offset.
         /// </value>
-        public double PenOffset
+        public double StrokeOffset
         {
-            get => GetValue(PenOffsetProperty);
-            set => SetValue(PenOffsetProperty, value);
+            get => GetValue(StrokeOffsetProperty);
+            set => SetValue(StrokeOffsetProperty, value);
         }
 
         /// <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>
-        public TextDecorationUnit PenOffsetUnit
+        public TextDecorationUnit StrokeOffsetUnit
         {
-            get => GetValue(PenOffsetUnitProperty);
-            set => SetValue(PenOffsetUnitProperty, value);
+            get => GetValue(StrokeOffsetUnitProperty);
+            set => SetValue(StrokeOffsetUnitProperty, value);
         }
 
         /// <summary>
-        /// Creates an immutable clone of the <see cref="TextDecoration"/>.
+        /// Draws the <see cref="TextDecoration"/> at given origin.
         /// </summary>
-        /// <returns>The immutable clone.</returns>
-        public ImmutableTextDecoration ToImmutable()
+        /// <param name="drawingContext">The drawing context.</param>
+        /// <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));
         }
     }
 }

+ 0 - 17
src/Avalonia.Visuals/Media/TextDecorationCollection.cs

@@ -1,7 +1,6 @@
 using System;
 using System.Collections.Generic;
 using Avalonia.Collections;
-using Avalonia.Media.Immutable;
 using Avalonia.Utilities;
 
 namespace Avalonia.Media
@@ -11,22 +10,6 @@ namespace Avalonia.Media
     /// </summary>
     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>
         /// Parses a <see cref="TextDecorationCollection"/> string.
         /// </summary>

+ 1 - 1
src/Avalonia.Visuals/Media/TextDecorationUnit.cs

@@ -1,7 +1,7 @@
 namespace Avalonia.Media
 {
     /// <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>
     public enum TextDecorationUnit
     {

+ 2 - 4
src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs

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

+ 69 - 0
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 - 0
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 - 0
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 - 0
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; }
+        }
+    }
+}

+ 0 - 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; }
-        }
-    }
-}

+ 0 - 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);
-        }
-    }
-}

+ 0 - 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;
-            }
-        }
-    }
-}

+ 175 - 6
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
 {
@@ -7,15 +9,182 @@ namespace Avalonia.Media.TextFormatting
     /// </summary>
     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;
-            Style = style;
+            var shapeableCharacters = new List<ShapeableTextCharacters>(2);
+
+            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;
         }
     }
 }

+ 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;
-            }
-        }
-    }
-}

+ 4 - 144
src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs

@@ -1,5 +1,4 @@
 using Avalonia.Media.TextFormatting.Unicode;
-using Avalonia.Utility;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -22,7 +21,7 @@ namespace Avalonia.Media.TextFormatting
                     return current;
                 }
 
-                current = new SimpleTextFormatter();
+                current = new TextFormatterImpl();
 
                 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="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
         /// 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>
         public abstract TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
-            TextParagraphProperties paragraphProperties);
-
-        /// <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;
-        }
+            TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null);
     }
 }

+ 544 - 0
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);
+            }
+        }
+    }
+}

+ 84 - 108
src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs

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

+ 12 - 5
src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs

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

+ 17 - 0
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 - 0
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;
+        }
+    }
+}

+ 31 - 38
src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs

@@ -1,4 +1,5 @@
 using System.Collections.Generic;
+using Avalonia.Utilities;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -8,38 +9,20 @@ namespace Avalonia.Media.TextFormatting
     /// </summary>
     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;
-            Descent = descent;
-            LineGap = lineGap;
-            Size = new Size(width, descent - ascent + lineGap);
-            BaselineOrigin = new Point(xOrigin, -ascent);
+            Size = size;
+            BaselineOrigin = baselineOrigin;
+            TextRange = textRange;
         }
 
         /// <summary>
-        /// Gets the overall recommended distance above the baseline.
+        /// Gets the text range that is covered by the text line.
         /// </summary>
         /// <value>
-        /// The ascent.
+        /// The text range that is covered by the text line.
         /// </value>
-        public double Ascent { 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; }
+        public TextRange TextRange { get; }
 
         /// <summary>
         /// Gets the size of the text line.
@@ -61,10 +44,12 @@ namespace Avalonia.Media.TextFormatting
         /// Creates the text line metrics.
         /// </summary>
         /// <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="textAlignment">The text alignment.</param>
+        /// <param name="paragraphProperties">The text alignment.</param>
         /// <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 ascent = 0.0;
@@ -73,31 +58,39 @@ namespace Avalonia.Media.TextFormatting
 
             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);
         }
     }
 }

+ 16 - 17
src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs

@@ -3,38 +3,37 @@
     /// <summary>
     /// Provides a set of properties that are used during the paragraph layout.
     /// </summary>
-    public readonly struct TextParagraphProperties
+    public abstract class TextParagraphProperties
     {
-        public TextParagraphProperties(
-            TextStyle defaultTextStyle,
-            TextAlignment textAlignment = TextAlignment.Left,
-            TextWrapping textWrapping = TextWrapping.NoWrap,
-            TextTrimming textTrimming = TextTrimming.None)
-        {
-            DefaultTextStyle = defaultTextStyle;
-            TextAlignment = textAlignment;
-            TextWrapping = textWrapping;
-            TextTrimming = textTrimming;
-        }
+        /// <summary>
+        /// Gets the text alignment.
+        /// </summary>
+        public abstract TextAlignment TextAlignment { get; }
 
         /// <summary>
         /// Gets the default text style.
         /// </summary>
-        public TextStyle DefaultTextStyle { get; }
+        public abstract TextRunProperties DefaultTextRunProperties { get; }
 
         /// <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>
-        public TextAlignment TextAlignment { get; }
+        public virtual TextDecorationCollection TextDecorations => null;
 
         /// <summary>
         /// Gets the text wrapping.
         /// </summary>
-        public TextWrapping TextWrapping { get; }
+        public abstract TextWrapping TextWrapping { get; }
 
         /// <summary>
         /// Gets the text trimming.
         /// </summary>
-        public TextTrimming TextTrimming { get; }
+        public abstract TextTrimming TextTrimming { get; }
+
+        /// <summary>
+        /// Paragraph's line height
+        /// </summary>
+        public abstract double LineHeight { get; }
     }
 }

+ 8 - 8
src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs → src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs

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

+ 12 - 5
src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs

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

+ 90 - 0
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);
+        }
+    }
+}

+ 5 - 3
src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs

@@ -1,6 +1,7 @@
 using System;
+using System.Globalization;
 using Avalonia.Platform;
-using Avalonia.Utility;
+using Avalonia.Utilities;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -44,9 +45,10 @@ namespace Avalonia.Media.TextFormatting
         }
 
         /// <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);
         }
     }
 }

+ 0 - 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; }
-    }
-}

+ 0 - 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; }
-    }
-}

+ 1 - 1
src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs

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

+ 1 - 1
src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs

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

+ 1 - 1
src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs

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

+ 1 - 1
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.
 
 using System.Runtime.InteropServices;
-using Avalonia.Utility;
+using Avalonia.Utilities;
 
 namespace Avalonia.Media.TextFormatting.Unicode
 {

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

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

+ 8 - 1
src/Avalonia.Visuals/Media/TextWrapping.cs

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

+ 6 - 6
src/Avalonia.Visuals/Media/Typeface.cs

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

+ 7 - 5
src/Avalonia.Visuals/Platform/ITextShaperImpl.cs

@@ -1,6 +1,6 @@
-using Avalonia.Media;
-using Avalonia.Media.TextFormatting;
-using Avalonia.Utility;
+using System.Globalization;
+using Avalonia.Media;
+using Avalonia.Utilities;
 
 namespace Avalonia.Platform
 {
@@ -13,8 +13,10 @@ namespace Avalonia.Platform
         /// Shapes the specified region within the text and returns a resulting glyph run.
         /// </summary>
         /// <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>
-        GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat);
+        GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture);
     }
 }

+ 2 - 3
src/Avalonia.Visuals/Utility/ReadOnlySlice.cs → src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs

@@ -2,9 +2,8 @@
 using System.Collections;
 using System.Collections.Generic;
 using System.Diagnostics;
-using Avalonia.Utilities;
 
-namespace Avalonia.Utility
+namespace Avalonia.Utilities
 {
     /// <summary>
     ///     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; }
 
         /// <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>
         public bool IsEmpty => Length == 0;
 

+ 30 - 0
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; }
+    }
+}

+ 1 - 1
src/Skia/Avalonia.Skia/FormattedTextImpl.cs

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

+ 8 - 6
src/Skia/Avalonia.Skia/TextShaperImpl.cs

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

+ 9 - 6
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.Unicode;
 using Avalonia.Platform;
-using Avalonia.Utility;
+using Avalonia.Utilities;
 using HarfBuzzSharp;
 using Buffer = HarfBuzzSharp.Buffer;
 
@@ -10,7 +11,7 @@ namespace Avalonia.Direct2D1.Media
 {
     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())
             {
@@ -62,15 +63,17 @@ namespace Avalonia.Direct2D1.Media
 
                 buffer.GuessSegmentProperties();
 
-                var glyphTypeface = textFormat.Typeface.GlyphTypeface;
+                var glyphTypeface = typeface.GlyphTypeface;
 
                 var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
 
+                buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
+
                 font.Shape(buffer);
 
                 font.GetScale(out var scaleX, out _);
 
-                var textScale = textFormat.FontRenderingEmSize / scaleX;
+                var textScale = fontRenderingEmSize / scaleX;
 
                 var len = buffer.Length;
 
@@ -104,7 +107,7 @@ namespace Avalonia.Direct2D1.Media
                     glyphOffsets[i] = new Vector(offsetX, offsetY);
                 }
 
-                return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize,
+                return new GlyphRun(glyphTypeface, fontRenderingEmSize,
                     new ReadOnlySlice<ushort>(glyphIndices),
                     new ReadOnlySlice<double>(glyphAdvances),
                     new ReadOnlySlice<Vector>(glyphOffsets),

+ 2 - 2
tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs

@@ -41,7 +41,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media
                 var fontManager = new FontManagerImpl();
 
                 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;
 
@@ -105,7 +105,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media
                 var fontManager = new FontManagerImpl();
 
                 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;
 

+ 1 - 1
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 SkiaSharp;
 
-namespace Avalonia.Skia.UnitTests
+namespace Avalonia.Skia.UnitTests.Media
 {
     public class CustomFontManagerImpl : IFontManagerImpl
     {

+ 3 - 5
tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs → tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs

@@ -1,13 +1,11 @@
 using System;
 using System.Linq;
-using System.Reflection;
 using Avalonia.Media;
-using Avalonia.Platform;
 using Avalonia.UnitTests;
 using SkiaSharp;
 using Xunit;
 
-namespace Avalonia.Skia.UnitTests
+namespace Avalonia.Skia.UnitTests.Media
 {
     public class FontManagerImplTests
     {
@@ -39,7 +37,7 @@ namespace Avalonia.Skia.UnitTests
             string fontName = fontManager.GetInstalledFontFamilyNames().First();
 
             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;
 
@@ -88,7 +86,7 @@ namespace Avalonia.Skia.UnitTests
                 var fontManager = new FontManagerImpl();
 
                 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;
 

+ 2 - 2
tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs → tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs

@@ -2,7 +2,7 @@
 using Avalonia.UnitTests;
 using Xunit;
 
-namespace Avalonia.Skia.UnitTests
+namespace Avalonia.Skia.UnitTests.Media
 {
     public class SKTypefaceCollectionCacheTests
     {
@@ -19,7 +19,7 @@ namespace Avalonia.Skia.UnitTests
 
                 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);
 

+ 38 - 0
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 - 0
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 - 0
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 - 0
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;
+        }
+    }
+}

+ 87 - 58
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.Unicode;
 using Avalonia.UnitTests;
+using Avalonia.Utilities;
 using Xunit;
 
-namespace Avalonia.Skia.UnitTests
+namespace Avalonia.Skia.UnitTests.Media.TextFormatting
 {
     public class TextLayoutTests
     {
         private static readonly string s_singleLineText = "0123456789";
         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]
         public void Should_Apply_TextStyleSpan_To_Text_In_Between()
         {
@@ -22,17 +40,16 @@ namespace Avalonia.Skia.UnitTests
 
                 var spans = new[]
                 {
-                    new TextStyleRun(
-                        new TextPointer(1, 2),
-                        new TextStyle(Typeface.Default, 12, foreground))
+                    new ValueSpan<TextRunProperties>(1, 2,
+                        new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
                 };
 
                 var layout = new TextLayout(
                     s_multiLineText,
-                    Typeface.Default, 
+                    Typeface.Default,
                     12.0f,
                     Brushes.Black.ToImmutable(),
-                    textStyleOverrides : spans);
+                    textStyleOverrides: spans);
 
                 var textLine = layout.TextLines[0];
 
@@ -46,7 +63,7 @@ namespace Avalonia.Skia.UnitTests
 
                 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[]
                     {
-                        new TextStyleRun(
-                            new TextPointer(0, i),
-                            new TextStyle(Typeface.Default, 12, foreground))
+                        new ValueSpan<TextRunProperties>(0, i,
+                            new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
                     };
 
                     var expected = new TextLayout(
@@ -72,22 +88,22 @@ namespace Avalonia.Skia.UnitTests
                         12.0f,
                         Brushes.Black.ToImmutable(),
                         textWrapping: TextWrapping.Wrap,
-                        maxWidth : 25);
+                        maxWidth: 25);
 
                     var actual = new TextLayout(
                         s_multiLineText,
                         Typeface.Default,
                         12.0f,
                         Brushes.Black.ToImmutable(),
-                        textWrapping : TextWrapping.Wrap,
-                        maxWidth : 25,
-                        textStyleOverrides : spans);
+                        textWrapping: TextWrapping.Wrap,
+                        maxWidth: 25,
+                        textStyleOverrides: spans);
 
                     Assert.Equal(expected.TextLines.Count, actual.TextLines.Count);
 
                     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),
                             actual.TextLines[j].TextRuns.Sum(x => x.Text.Length));
@@ -105,9 +121,8 @@ namespace Avalonia.Skia.UnitTests
 
                 var spans = new[]
                 {
-                    new TextStyleRun(
-                        new TextPointer(0, 2),
-                        new TextStyle(Typeface.Default, 12, foreground))
+                    new ValueSpan<TextRunProperties>(0, 2,
+                        new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
                 };
 
                 var layout = new TextLayout(
@@ -115,7 +130,7 @@ namespace Avalonia.Skia.UnitTests
                     Typeface.Default,
                     12.0f,
                     Brushes.Black.ToImmutable(),
-                    textStyleOverrides : spans);
+                    textStyleOverrides: spans);
 
                 var textLine = layout.TextLines[0];
 
@@ -130,7 +145,7 @@ namespace Avalonia.Skia.UnitTests
 
                 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[]
                 {
-                    new TextStyleRun(
-                        new TextPointer(8, 2),
-                        new TextStyle(Typeface.Default, 12, foreground))
+                    new ValueSpan<TextRunProperties>(8, 2,
+                        new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground)),
                 };
 
                 var layout = new TextLayout(
@@ -153,7 +167,7 @@ namespace Avalonia.Skia.UnitTests
                     Typeface.Default,
                     12.0f,
                     Brushes.Black.ToImmutable(),
-                    textStyleOverrides : spans);
+                    textStyleOverrides: spans);
 
                 var textLine = layout.TextLines[0];
 
@@ -167,7 +181,7 @@ namespace Avalonia.Skia.UnitTests
 
                 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[]
                 {
-                    new TextStyleRun(
-                        new TextPointer(0, 1),
-                        new TextStyle(Typeface.Default, 12, foreground))
+                    new ValueSpan<TextRunProperties>(0, 1,
+                        new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
                 };
 
                 var layout = new TextLayout(
@@ -190,7 +203,7 @@ namespace Avalonia.Skia.UnitTests
                     Typeface.Default,
                     12.0f,
                     Brushes.Black.ToImmutable(),
-                    textStyleOverrides : spans);
+                    textStyleOverrides: spans);
 
                 var textLine = layout.TextLines[0];
 
@@ -200,7 +213,7 @@ namespace Avalonia.Skia.UnitTests
 
                 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[]
                 {
-                    new TextStyleRun(
-                        new TextPointer(2, 2),
-                        new TextStyle(Typeface.Default, 12, foreground))
+                    new ValueSpan<TextRunProperties>(2, 2,
+                        new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
                 };
 
                 var layout = new TextLayout(
@@ -239,7 +251,7 @@ namespace Avalonia.Skia.UnitTests
 
                 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,
                     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[]
                 {
-                    new TextStyleRun(
-                        new TextPointer(0, 24),
-                        new TextStyle(Typeface.Default, 12, foreground))
+                    new ValueSpan<TextRunProperties>(0, 24,
+                        new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
                 };
 
                 var layout = new TextLayout(
@@ -301,8 +312,8 @@ namespace Avalonia.Skia.UnitTests
                     Typeface.Default,
                     12.0f,
                     Brushes.Black.ToImmutable(),
-                    textWrapping : TextWrapping.Wrap,
-                    maxWidth : 180,
+                    textWrapping: TextWrapping.Wrap,
+                    maxWidth: 180,
                     textStyleOverrides: spans);
 
                 Assert.Equal(
@@ -322,9 +333,8 @@ namespace Avalonia.Skia.UnitTests
 
                 var spans = new[]
                 {
-                    new TextStyleRun(
-                        new TextPointer(5, 20),
-                        new TextStyle(Typeface.Default, 12, foreground))
+                    new ValueSpan<TextRunProperties>(5, 20,
+                        new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
                 };
 
                 var layout = new TextLayout(
@@ -332,13 +342,13 @@ namespace Avalonia.Skia.UnitTests
                     Typeface.Default,
                     12.0f,
                     Brushes.Black.ToImmutable(),
-                    maxWidth : 200,
-                    maxHeight : 125,
+                    maxWidth: 200,
+                    maxHeight: 125,
                     textStyleOverrides: spans);
 
-                Assert.Equal(foreground, layout.TextLines[0].TextRuns[1].Style.Foreground);
-                Assert.Equal(foreground, layout.TextLines[1].TextRuns[0].Style.Foreground);
-                Assert.Equal(foreground, layout.TextLines[2].TextRuns[0].Style.Foreground);
+                Assert.Equal(foreground, layout.TextLines[0].TextRuns[1].Properties.ForegroundBrush);
+                Assert.Equal(foreground, layout.TextLines[1].TextRuns[0].Properties.ForegroundBrush);
+                Assert.Equal(foreground, layout.TextLines[2].TextRuns[0].Properties.ForegroundBrush);
             }
         }
 
@@ -355,7 +365,7 @@ namespace Avalonia.Skia.UnitTests
                     12.0f,
                     Brushes.Black.ToImmutable());
 
-                var shapedRun = (ShapedTextRun)layout.TextLines[0].TextRuns[0];
+                var shapedRun = (ShapedTextCharacters)layout.TextLines[0].TextRuns[0];
 
                 var glyphRun = shapedRun.GlyphRun;
 
@@ -390,7 +400,7 @@ namespace Avalonia.Skia.UnitTests
 
                 foreach (var textRun in textLine.TextRuns)
                 {
-                    var shapedRun = (ShapedTextRun)textRun;
+                    var shapedRun = (ShapedTextCharacters)textRun;
 
                     var glyphRun = shapedRun.GlyphRun;
 
@@ -426,13 +436,13 @@ namespace Avalonia.Skia.UnitTests
 
                 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 textRun = (ShapedTextRun)textLine.TextRuns[0];
+                var textRun = (ShapedTextCharacters)textLine.TextRuns[0];
 
                 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一番読まれている英字新聞・ジャパンタイムズが発信する国内外ニュースと、様々なジャンルの特集記事。";
 
-        [Fact(Skip= "Only used for profiling.")]
+        [Fact(Skip = "Only used for profiling.")]
         public void Should_Wrap()
         {
             using (Start())
@@ -546,12 +575,12 @@ namespace Avalonia.Skia.UnitTests
             }
         }
 
-        public static IDisposable Start()
+        private static IDisposable Start()
         {
             var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
                 .With(renderInterface: new PlatformRenderInterface(null),
                     textShaperImpl: new TextShaperImpl(),
-                    fontManagerImpl : new CustomFontManagerImpl()));
+                    fontManagerImpl: new CustomFontManagerImpl()));
 
             return disposable;
         }

+ 175 - 0
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;
+        }
+    }
+}

+ 0 - 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;
-        }
-    }
-}

+ 10 - 9
tests/Avalonia.UnitTests/MockTextShaperImpl.cs

@@ -1,19 +1,19 @@
-using Avalonia.Media;
-using Avalonia.Media.TextFormatting;
+using System;
+using System.Globalization;
+using Avalonia.Media;
 using Avalonia.Media.TextFormatting.Unicode;
 using Avalonia.Platform;
-using Avalonia.Utility;
+using Avalonia.Utilities;
 
 namespace Avalonia.UnitTests
 {
     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 height = textFormat.FontMetrics.LineHeight;
-            var width = 0.0;
+            var glyphCount = 0;
 
             for (var i = 0; i < text.Length;)
             {
@@ -27,10 +27,11 @@ namespace Avalonia.UnitTests
 
                 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);
         }
     }
 }

+ 1 - 1
tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs

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

+ 1 - 1
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs

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

+ 1 - 1
tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs

@@ -9,7 +9,7 @@ namespace Avalonia.Visuals.UnitTests.Media
         [Fact]
         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]