From 2a181d9acb71b481e83d90f896c85d45013b6202 Mon Sep 17 00:00:00 2001 From: Benedikt Schroeder Date: Thu, 25 Jun 2020 11:47:14 +0200 Subject: [PATCH] Introduce TextRunProperties --- .../ControlCatalog/Pages/TextBlockPage.xaml | 69 +-- .../Primitives/AccessText.cs | 15 +- src/Avalonia.Controls/TextBlock.cs | 35 +- src/Avalonia.Visuals/Media/FontManager.cs | 4 +- src/Avalonia.Visuals/Media/GlyphRun.cs | 112 +++- src/Avalonia.Visuals/Media/TextDecoration.cs | 182 ++++-- .../Media/TextDecorationCollection.cs | 17 - .../Media/TextDecorationUnit.cs | 2 +- .../Media/TextFormatting/DrawableTextRun.cs | 6 +- .../GenericTextParagraphProperties.cs | 69 +++ .../GenericTextRunProperties.cs | 40 ++ .../TextFormatting/ShapeableTextCharacters.cs | 23 + .../TextFormatting/ShapedTextCharacters.cs | 164 ++++++ .../Media/TextFormatting/ShapedTextRun.cs | 212 ------- .../TextFormatting/SimpleTextFormatter.cs | 395 ------------- .../Media/TextFormatting/SimpleTextLine.cs | 259 --------- .../Media/TextFormatting/TextCharacters.cs | 181 +++++- .../Media/TextFormatting/TextEndOfSegment.cs | 17 + .../Media/TextFormatting/TextFormat.cs | 71 --- .../Media/TextFormatting/TextFormatter.cs | 148 +---- .../Media/TextFormatting/TextFormatterImpl.cs | 544 ++++++++++++++++++ .../Media/TextFormatting/TextHidden.cs | 9 + .../Media/TextFormatting/TextLayout.cs | 192 +++---- .../Media/TextFormatting/TextLine.cs | 17 +- .../Media/TextFormatting/TextLineBreak.cs | 17 + .../Media/TextFormatting/TextLineImpl.cs | 235 ++++++++ .../Media/TextFormatting/TextLineMetrics.cs | 69 +-- .../Media/TextFormatting/TextModifier.cs | 19 + .../TextFormatting/TextParagraphProperties.cs | 33 +- .../{TextPointer.cs => TextRange.cs} | 16 +- .../Media/TextFormatting/TextRun.cs | 17 +- .../Media/TextFormatting/TextRunProperties.cs | 90 +++ .../Media/TextFormatting/TextShaper.cs | 8 +- .../Media/TextFormatting/TextStyle.cs | 39 -- .../Media/TextFormatting/TextStyleRun.cs | 24 - .../Media/TextFormatting/Unicode/Codepoint.cs | 2 +- .../Unicode/CodepointEnumerator.cs | 2 +- .../Media/TextFormatting/Unicode/Grapheme.cs | 2 +- .../Unicode/GraphemeEnumerator.cs | 2 +- .../Unicode/LineBreakEnumerator.cs | 2 +- src/Avalonia.Visuals/Media/TextWrapping.cs | 9 +- src/Avalonia.Visuals/Media/Typeface.cs | 12 +- .../Platform/ITextShaperImpl.cs | 12 +- .../{Utility => Utilities}/ReadOnlySlice.cs | 5 +- src/Avalonia.Visuals/Utilities/ValueSpan.cs | 30 + src/Skia/Avalonia.Skia/FormattedTextImpl.cs | 2 +- src/Skia/Avalonia.Skia/TextShaperImpl.cs | 14 +- .../Media/TextShaperImpl.cs | 15 +- .../Media/FontManagerImplTests.cs | 4 +- .../{ => Media}/CustomFontManagerImpl.cs | 2 +- .../{ => Media}/FontManagerImplTests.cs | 8 +- .../SKTypefaceCollectionCacheTests.cs | 4 +- .../TextFormatting/FormattableTextSource.cs | 38 ++ .../TextFormatting/MultiBufferTextSource.cs | 36 ++ .../TextFormatting/SingleBufferTextSource.cs | 30 + .../TextFormatting/TextFormatterTests.cs | 275 +++++++++ .../TextFormatting}/TextLayoutTests.cs | 145 +++-- .../Media/TextFormatting/TextLineTests.cs | 175 ++++++ .../SimpleTextFormatterTests.cs | 373 ------------ .../Avalonia.UnitTests/MockTextShaperImpl.cs | 19 +- .../Media/GlyphRunTests.cs | 2 +- .../Media/TextFormatting/LineBreakerTests.cs | 2 +- .../Media/TypefaceTests.cs | 2 +- 63 files changed, 2614 insertions(+), 1960 deletions(-) create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs delete mode 100644 src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs delete mode 100644 src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs delete mode 100644 src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextEndOfSegment.cs delete mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextHidden.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextModifier.cs rename src/Avalonia.Visuals/Media/TextFormatting/{TextPointer.cs => TextRange.cs} (73%) create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs delete mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs delete mode 100644 src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs rename src/Avalonia.Visuals/{Utility => Utilities}/ReadOnlySlice.cs (98%) create mode 100644 src/Avalonia.Visuals/Utilities/ValueSpan.cs rename tests/Avalonia.Skia.UnitTests/{ => Media}/CustomFontManagerImpl.cs (98%) rename tests/Avalonia.Skia.UnitTests/{ => Media}/FontManagerImplTests.cs (95%) rename tests/Avalonia.Skia.UnitTests/{ => Media}/SKTypefaceCollectionCacheTests.cs (89%) create mode 100644 tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs create mode 100644 tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs create mode 100644 tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs create mode 100644 tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs rename tests/Avalonia.Skia.UnitTests/{ => Media/TextFormatting}/TextLayoutTests.cs (77%) create mode 100644 tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs delete mode 100644 tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs diff --git a/samples/ControlCatalog/Pages/TextBlockPage.xaml b/samples/ControlCatalog/Pages/TextBlockPage.xaml index f73ef9b4fb..4b8edcf98c 100644 --- a/samples/ControlCatalog/Pages/TextBlockPage.xaml +++ b/samples/ControlCatalog/Pages/TextBlockPage.xaml @@ -64,51 +64,42 @@ - - - - - - - - - - - - + StrokeThicknessUnit="Pixel" + StrokeThickness="2"> + + + + + + + + - - - - - - - - - - - - + StrokeThicknessUnit="Pixel" + StrokeThickness="1"> + + + + + + + + - - - - - - - - - - - - + StrokeThicknessUnit="Pixel" + StrokeThickness="2"> + + + + + + + + diff --git a/src/Avalonia.Controls/Primitives/AccessText.cs b/src/Avalonia.Controls/Primitives/AccessText.cs index 4208a2f2f7..dd33023e38 100644 --- a/src/Avalonia.Controls/Primitives/AccessText.cs +++ b/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); } } diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 13bc4ed124..2361ea9011 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -70,6 +70,15 @@ namespace Avalonia.Controls Brushes.Black, inherits: true); + /// + /// Defines the property. + /// + public static readonly StyledProperty LineHeightProperty = + AvaloniaProperty.Register( + nameof(LineHeight), + double.NaN, + validate: IsValidLineHeight); + /// /// Defines the property. /// @@ -122,19 +131,19 @@ namespace Avalonia.Controls { ClipToBoundsProperty.OverrideDefaultValue(true); - AffectsRender(BackgroundProperty, ForegroundProperty, + AffectsRender(BackgroundProperty, ForegroundProperty, TextAlignmentProperty, TextDecorationsProperty); - AffectsMeasure(FontSizeProperty, FontWeightProperty, - FontStyleProperty, TextWrappingProperty, FontFamilyProperty, - TextTrimmingProperty, TextProperty, PaddingProperty); + AffectsMeasure(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((x, _) => x.InvalidateTextLayout()); } @@ -230,6 +239,15 @@ namespace Avalonia.Controls set { SetValue(ForegroundProperty, value); } } + /// + /// Gets or sets the height of each line of content. + /// + public double LineHeight + { + get => GetValue(LineHeightProperty); + set => SetValue(LineHeightProperty, value); + } + /// /// Gets or sets the maximum number of text lines. /// @@ -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)); } /// @@ -422,7 +440,8 @@ namespace Avalonia.Controls TextDecorations, constraint.Width, constraint.Height, - MaxLines); + maxLines: MaxLines, + lineHeight: LineHeight); } /// @@ -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; } } diff --git a/src/Avalonia.Visuals/Media/FontManager.cs b/src/Avalonia.Visuals/Media/FontManager.cs index f9410afe6a..bc979c15ee 100644 --- a/src/Avalonia.Visuals/Media/FontManager.cs +++ b/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; diff --git a/src/Avalonia.Visuals/Media/GlyphRun.cs b/src/Avalonia.Visuals/Media/GlyphRun.cs index 29c9d93560..a32a3e1b6c 100644 --- a/src/Avalonia.Visuals/Media/GlyphRun.cs +++ b/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 /// 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); + } + + /// + /// Gets a glyph's width. + /// + /// The glyph index. + /// The glyph's width. + 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]; } /// diff --git a/src/Avalonia.Visuals/Media/TextDecoration.cs b/src/Avalonia.Visuals/Media/TextDecoration.cs index a83555946b..681fc5d499 100644 --- a/src/Avalonia.Visuals/Media/TextDecoration.cs +++ b/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(nameof(Location)); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty PenProperty = - AvaloniaProperty.Register(nameof(Pen)); + public static readonly StyledProperty StrokeProperty = + AvaloniaProperty.Register(nameof(Stroke)); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty PenThicknessUnitProperty = - AvaloniaProperty.Register(nameof(PenThicknessUnit)); + public static readonly StyledProperty StrokeThicknessUnitProperty = + AvaloniaProperty.Register(nameof(StrokeThicknessUnit)); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty PenOffsetProperty = - AvaloniaProperty.Register(nameof(PenOffset)); + public static readonly StyledProperty> StrokeDashArrayProperty = + AvaloniaProperty.Register>(nameof(StrokeDashArray)); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty PenOffsetUnitProperty = - AvaloniaProperty.Register(nameof(PenOffsetUnit)); + public static readonly StyledProperty StrokeDashOffsetProperty = + AvaloniaProperty.Register(nameof(StrokeDashOffset)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty StrokeThicknessProperty = + AvaloniaProperty.Register(nameof(StrokeThickness), 1); + + /// + /// Defines the property. + /// + public static readonly StyledProperty StrokeLineCapProperty = + AvaloniaProperty.Register(nameof(StrokeLineCap)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty StrokeOffsetProperty = + AvaloniaProperty.Register(nameof(StrokeOffset)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty StrokeOffsetUnitProperty = + AvaloniaProperty.Register(nameof(StrokeOffsetUnit)); /// /// Gets or sets the location. @@ -50,54 +75,139 @@ namespace Avalonia.Media } /// - /// Gets or sets the pen. + /// Gets or sets the that specifies how the is painted. /// - /// - /// The pen. - /// - public IPen Pen + public IBrush Stroke + { + get { return GetValue(StrokeProperty); } + set { SetValue(StrokeProperty, value); } + } + + /// + /// Gets the units in which the thickness of the is expressed. + /// + public TextDecorationUnit StrokeThicknessUnit + { + get => GetValue(StrokeThicknessUnitProperty); + set => SetValue(StrokeThicknessUnitProperty, value); + } + + /// + /// Gets or sets a collection of values that indicate the pattern of dashes and gaps + /// that is used to draw the . + /// + public AvaloniaList StrokeDashArray + { + get { return GetValue(StrokeDashArrayProperty); } + set { SetValue(StrokeDashArrayProperty, value); } + } + + /// + /// Gets or sets a value that specifies the distance within the dash pattern where a dash begins. + /// + public double StrokeDashOffset + { + get { return GetValue(StrokeDashOffsetProperty); } + set { SetValue(StrokeDashOffsetProperty, value); } + } + + /// + /// Gets or sets the thickness of the . + /// + public double StrokeThickness { - get => GetValue(PenProperty); - set => SetValue(PenProperty, value); + get { return GetValue(StrokeThicknessProperty); } + set { SetValue(StrokeThicknessProperty, value); } } /// - /// Gets the units in which the Thickness of the text decoration's is expressed. + /// Gets or sets a enumeration value that describes the shape at the ends of a line. /// - public TextDecorationUnit PenThicknessUnit + public PenLineCap StrokeLineCap { - get => GetValue(PenThicknessUnitProperty); - set => SetValue(PenThicknessUnitProperty, value); + get { return GetValue(StrokeLineCapProperty); } + set { SetValue(StrokeLineCapProperty, value); } } /// - /// Gets or sets the pen offset. + /// The stroke's offset. /// /// /// The pen offset. /// - public double PenOffset + public double StrokeOffset { - get => GetValue(PenOffsetProperty); - set => SetValue(PenOffsetProperty, value); + get => GetValue(StrokeOffsetProperty); + set => SetValue(StrokeOffsetProperty, value); } /// - /// Gets the units in which the value is expressed. + /// Gets the units in which the value is expressed. /// - public TextDecorationUnit PenOffsetUnit + public TextDecorationUnit StrokeOffsetUnit { - get => GetValue(PenOffsetUnitProperty); - set => SetValue(PenOffsetUnitProperty, value); + get => GetValue(StrokeOffsetUnitProperty); + set => SetValue(StrokeOffsetUnitProperty, value); } /// - /// Creates an immutable clone of the . + /// Draws the at given origin. /// - /// The immutable clone. - public ImmutableTextDecoration ToImmutable() + /// The drawing context. + /// The shaped characters that are decorated. + /// The origin. + 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)); } } } diff --git a/src/Avalonia.Visuals/Media/TextDecorationCollection.cs b/src/Avalonia.Visuals/Media/TextDecorationCollection.cs index 21e2e2484c..2dced2252e 100644 --- a/src/Avalonia.Visuals/Media/TextDecorationCollection.cs +++ b/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 /// public class TextDecorationCollection : AvaloniaList { - /// - /// Creates an immutable clone of the . - /// - /// The immutable clone. - public ImmutableTextDecoration[] ToImmutable() - { - var immutable = new ImmutableTextDecoration[Count]; - - for (var i = 0; i < Count; i++) - { - immutable[i] = this[i].ToImmutable(); - } - - return immutable; - } - /// /// Parses a string. /// diff --git a/src/Avalonia.Visuals/Media/TextDecorationUnit.cs b/src/Avalonia.Visuals/Media/TextDecorationUnit.cs index dde425bb94..a61983e8d5 100644 --- a/src/Avalonia.Visuals/Media/TextDecorationUnit.cs +++ b/src/Avalonia.Visuals/Media/TextDecorationUnit.cs @@ -1,7 +1,7 @@ namespace Avalonia.Media { /// - /// Specifies the unit type of either a or a thickness value. + /// Specifies the unit type of either a or a value. /// public enum TextDecorationUnit { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs index 4903342cea..56790cc0db 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs @@ -1,6 +1,4 @@ -using Avalonia.Platform; - -namespace Avalonia.Media.TextFormatting +namespace Avalonia.Media.TextFormatting { /// /// A text run that supports drawing content. @@ -17,6 +15,6 @@ namespace Avalonia.Media.TextFormatting /// /// The drawing context. /// The origin. - public abstract void Draw(IDrawingContextImpl drawingContext, Point origin); + public abstract void Draw(DrawingContext drawingContext, Point origin); } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs new file mode 100644 index 0000000000..c4302aecec --- /dev/null +++ b/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; + + /// + /// Set text alignment + /// + internal void SetTextAlignment(TextAlignment textAlignment) + { + _textAlignment = textAlignment; + } + + /// + /// Set text wrap + /// + internal void SetTextWrapping(TextWrapping textWrapping) + { + _textWrapping = textWrapping; + } + /// + /// Set text trimming + /// + internal void SetTextTrimming(TextTrimming textTrimming) + { + _textTrimming = textTrimming; + } + + /// + /// Set line height + /// + internal void SetLineHeight(double lineHeight) + { + _lineHeight = lineHeight; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs new file mode 100644 index 0000000000..3db3589498 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs @@ -0,0 +1,40 @@ +using System.Globalization; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Generic implementation of TextRunProperties + /// + 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; + } + + /// + public override Typeface Typeface { get; } + + /// + public override double FontRenderingEmSize { get; } + + /// + public override TextDecorationCollection TextDecorations { get; } + + /// + public override IBrush ForegroundBrush { get; } + + /// + public override IBrush BackgroundBrush { get; } + + /// + public override CultureInfo CultureInfo { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs new file mode 100644 index 0000000000..0c6c722941 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs @@ -0,0 +1,23 @@ +using Avalonia.Utilities; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// A group of characters that can be shaped. + /// + public sealed class ShapeableTextCharacters : TextRun + { + public ShapeableTextCharacters(ReadOnlySlice text, TextRunProperties properties) + { + TextSourceLength = text.Length; + Text = text; + Properties = properties; + } + + public override int TextSourceLength { get; } + + public override ReadOnlySlice Text { get; } + + public override TextRunProperties Properties { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs new file mode 100644 index 0000000000..2e7e7aceb1 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs @@ -0,0 +1,164 @@ +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Utilities; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// A text run that holds shaped characters. + /// + 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; + } + + /// + public override ReadOnlySlice Text { get; } + + /// + public override TextRunProperties Properties { get; } + + /// + public override int TextSourceLength { get; } + + /// + public override Rect Bounds => GlyphRun.Bounds; + + /// + /// Gets the font metrics. + /// + /// + /// The font metrics. + /// + public FontMetrics FontMetrics { get; } + + /// + /// Gets the glyph run. + /// + /// + /// The glyphs. + /// + public GlyphRun GlyphRun { get; } + + /// + 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); + } + } + + /// + /// Splits the at specified length. + /// + /// The length. + /// The split result. + 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; + } + + /// + /// Gets the first text run. + /// + /// + /// The first text run. + /// + public ShapedTextCharacters First { get; } + + /// + /// Gets the second text run. + /// + /// + /// The second text run. + /// + public ShapedTextCharacters Second { get; } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs deleted file mode 100644 index 00f9b918cb..0000000000 --- a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs +++ /dev/null @@ -1,212 +0,0 @@ -using Avalonia.Media.Immutable; -using Avalonia.Media.TextFormatting.Unicode; -using Avalonia.Platform; -using Avalonia.Utility; - -namespace Avalonia.Media.TextFormatting -{ - /// - /// A text run that holds a shaped glyph run. - /// - public sealed class ShapedTextRun : DrawableTextRun - { - public ShapedTextRun(ReadOnlySlice text, TextStyle style) : this( - TextShaper.Current.ShapeText(text, style.TextFormat), style) - { - } - - public ShapedTextRun(GlyphRun glyphRun, TextStyle style) - { - Text = glyphRun.Characters; - Style = style; - GlyphRun = glyphRun; - } - - /// - public override Rect Bounds => GlyphRun.Bounds; - - /// - /// Gets the glyph run. - /// - /// - /// The glyphs. - /// - public GlyphRun GlyphRun { get; } - - /// - 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); - } - } - - /// - /// Draws the at given origin. - /// - /// The drawing context. - /// The text decoration. - /// The origin. - 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)); - } - - /// - /// Splits the at specified length. - /// - /// The length. - /// The split result. - 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; - } - - /// - /// Gets the first text run. - /// - /// - /// The first text run. - /// - public ShapedTextRun First { get; } - - /// - /// Gets the second text run. - /// - /// - /// The second text run. - /// - public ShapedTextRun Second { get; } - } - } -} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs deleted file mode 100644 index f84e45d4c6..0000000000 --- a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs +++ /dev/null @@ -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 s_ellipsis = new ReadOnlySlice(new[] { '\u2026' }); - - /// - 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; - } - - /// - /// Formats text runs with optional text style overrides. - /// - /// The text source. - /// The first text source index. - /// The text pointer that covers the formatted text runs. - /// - /// The formatted text runs. - /// - private List FormatTextRuns(ITextSource textSource, int firstTextSourceIndex, out TextPointer textPointer) - { - var start = -1; - var length = 0; - - var textRuns = new List(); - - 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; - } - - /// - /// Performs text trimming and returns a trimmed line. - /// - /// A value that specifies the width of the paragraph that the line fills. - /// A value that represents paragraph properties, - /// such as TextWrapping, TextAlignment, or TextStyle. - /// The text runs to perform the trimming on. - /// The text that was used to construct the text runs. - /// - private static TextLine PerformTextTrimming(TextPointer text, IReadOnlyList 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(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)); - } - - /// - /// Performs text wrapping returns a list of text lines. - /// - /// The text paragraph properties. - /// The text run'S. - /// The text to analyze for break opportunities. - /// - /// - private static TextLine PerformTextWrapping(TextPointer text, IReadOnlyList 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)); - } - - /// - /// Measures the number of characters that fits into available width. - /// - /// The text run. - /// The available width. - /// - 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; - } - - /// - /// Creates an ellipsis. - /// - /// The text style. - /// - private static ShapedTextRun CreateEllipsisRun(TextStyle textStyle) - { - var formatterImpl = AvaloniaLocator.Current.GetService(); - - var glyphRun = formatterImpl.ShapeText(s_ellipsis, textStyle.TextFormat); - - return new ShapedTextRun(glyphRun, textStyle); - } - - private readonly struct SplitTextRunsResult - { - public SplitTextRunsResult(IReadOnlyList first, IReadOnlyList second) - { - First = first; - - Second = second; - } - - /// - /// Gets the first text runs. - /// - /// - /// The first text runs. - /// - public IReadOnlyList First { get; } - - /// - /// Gets the second text runs. - /// - /// - /// The second text runs. - /// - public IReadOnlyList Second { get; } - } - - /// - /// Split a sequence of runs into two segments at specified length. - /// - /// The text run's. - /// The length to split at. - /// - private static SplitTextRunsResult SplitTextRuns(IReadOnlyList 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); - } - } -} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs deleted file mode 100644 index 11d241bc34..0000000000 --- a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia.Platform; - -namespace Avalonia.Media.TextFormatting -{ - internal class SimpleTextLine : TextLine - { - private readonly IReadOnlyList _textRuns; - - public SimpleTextLine(TextPointer textPointer, IReadOnlyList textRuns, TextLineMetrics lineMetrics) - { - Text = textPointer; - _textRuns = textRuns; - LineMetrics = lineMetrics; - } - - /// - public override TextPointer Text { get; } - - /// - public override IReadOnlyList TextRuns => _textRuns; - - /// - public override TextLineMetrics LineMetrics { get; } - - /// - 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; - } - } - - /// - 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; - } - - /// - public override double GetDistanceFromCharacterHit(CharacterHit characterHit) - { - return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0)); - } - - /// - 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; - } - - /// - 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; - } - - /// - public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit) - { - // same operation as move-to-previous - return GetPreviousCaretCharacterHit(characterHit); - } - - /// - /// Get distance from line start to the specified codepoint index - /// - 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; - } - - /// - /// 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. - /// - 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; - } - - /// - /// 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. - /// - 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; - } - } - } -} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs index d9b27958ab..b35882fc0e 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs +++ b/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 /// public class TextCharacters : TextRun { - protected TextCharacters() + public TextCharacters(ReadOnlySlice text, TextRunProperties properties) { - + TextSourceLength = text.Length; + Text = text; + Properties = properties; } - public TextCharacters(ReadOnlySlice text, TextStyle style) + /// + public override int TextSourceLength { get; } + + /// + public override ReadOnlySlice Text { get; } + + /// + public override TextRunProperties Properties { get; } + + /// + /// Gets a list of . + /// + /// The shapeable text characters. + internal IList GetShapeableCharacters() { - Text = text; - Style = style; + var shapeableCharacters = new List(2); + + var runText = Text; + + while (!runText.IsEmpty) + { + var shapeableRun = CreateShapeableRun(runText, Properties); + + shapeableCharacters.Add(shapeableRun); + + runText = runText.Skip(shapeableRun.Text.Length); + } + + return shapeableCharacters; + } + + /// + /// Creates a shapeable text run with unique properties. + /// + /// The text to create text runs from. + /// The default text run properties. + /// A list of shapeable text runs. + private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice 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)); + } + + /// + /// Tries to get run properties. + /// + /// + /// + /// The typeface that is used to find matching characters. + /// + /// + protected bool TryGetRunProperties(ReadOnlySlice 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; } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfSegment.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfSegment.cs new file mode 100644 index 0000000000..306fa288ee --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfSegment.cs @@ -0,0 +1,17 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Specialized text run used to mark the end of a segment, i.e., to end + /// the scope affected by a preceding TextModifier run. + /// + public class TextEndOfSegment : TextRun + { + public TextEndOfSegment(int textSourceLength) + { + TextSourceLength = textSourceLength; + } + + /// + public override int TextSourceLength { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs deleted file mode 100644 index 18dd6c7c10..0000000000 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; - -namespace Avalonia.Media.TextFormatting -{ - /// - /// Unique text formatting properties that are used by the . - /// - public readonly struct TextFormat : IEquatable - { - public TextFormat(Typeface typeface, double fontRenderingEmSize) - { - Typeface = typeface; - FontRenderingEmSize = fontRenderingEmSize; - FontMetrics = new FontMetrics(typeface, fontRenderingEmSize); - } - - /// - /// Gets the typeface. - /// - /// - /// The typeface. - /// - public Typeface Typeface { get; } - - /// - /// Gets the font rendering em size. - /// - /// - /// The em rendering size of the font. - /// - public double FontRenderingEmSize { get; } - - /// - /// Gets the font metrics. - /// - /// - /// The metrics of the font. - /// - 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; - } - } - } -} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs index 7da39dc5dc..e4c898e2b8 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs +++ b/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().ToConstant(current); @@ -38,149 +37,10 @@ namespace Avalonia.Media.TextFormatting /// A value that specifies the width of the paragraph that the line fills. /// A value that represents paragraph properties, /// such as TextWrapping, TextAlignment, or TextStyle. + /// A value that specifies the text formatter state, + /// in terms of where the previous line in the paragraph was broken by the text formatting process. /// The formatted line. public abstract TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, - TextParagraphProperties paragraphProperties); - - /// - /// Creates a text style run with unique properties. - /// - /// The text to create text runs from. - /// - /// A list of text runs. - protected TextStyleRun CreateShapableTextStyleRun(ReadOnlySlice 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)); - } - - /// - /// Tries to get run properties. - /// - /// - /// - /// The typeface that is used to find matching characters. - /// - /// - protected bool TryGetRunProperties(ReadOnlySlice 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); } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs new file mode 100644 index 0000000000..793707d0b2 --- /dev/null +++ b/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 s_ellipsis = new ReadOnlySlice(new[] { '\u2026' }); + + /// + public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, + TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null) + { + var textTrimming = paragraphProperties.TextTrimming; + var textWrapping = paragraphProperties.TextWrapping; + TextLine textLine = null; + + var textRuns = FetchTextRuns(textSource, firstTextSourceIndex, previousLineBreak, out var nextLineBreak); + + var textRange = GetTextRange(textRuns); + + if (textTrimming != TextTrimming.None) + { + textLine = PerformTextTrimming(textRuns, textRange, paragraphWidth, paragraphProperties); + } + else + { + 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; + } + + /// + /// Fetches text runs. + /// + /// The text source. + /// The first text source index. + /// Previous line break. Can be null. + /// Next line break. Can be null. + /// + /// The formatted text runs. + /// + private static IReadOnlyList FetchTextRuns(ITextSource textSource, + int firstTextSourceIndex, TextLineBreak previousLineBreak, out TextLineBreak nextLineBreak) + { + nextLineBreak = default; + + var currentLength = 0; + + var textRuns = new List(); + + 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; + } + + /// + /// Performs text trimming and returns a trimmed line. + /// + /// The text runs to perform the trimming on. + /// The text range that is covered by the text runs. + /// A value that specifies the width of the paragraph that the line fills. + /// A value that represents paragraph properties, + /// such as TextWrapping, TextAlignment, or TextStyle. + /// + private static TextLine PerformTextTrimming(IReadOnlyList textRuns, TextRange textRange, + double paragraphWidth, TextParagraphProperties paragraphProperties) + { + var textTrimming = paragraphProperties.TextTrimming; + var availableWidth = paragraphWidth; + var currentWidth = 0.0; + var runIndex = 0; + + while (runIndex < textRuns.Count) + { + var currentRun = textRuns[runIndex]; + + currentWidth += currentRun.GlyphRun.Bounds.Width; + + if (currentWidth > availableWidth) + { + var ellipsisRun = CreateEllipsisRun(currentRun.Properties); + + var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width); + + if (textTrimming == TextTrimming.WordEllipsis) + { + if (measuredLength < textRange.End) + { + var currentBreakPosition = 0; + + var lineBreaker = new LineBreakEnumerator(currentRun.Text); + + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) + { + var nextBreakPosition = lineBreaker.Current.PositionWrap; + + if (nextBreakPosition == 0) + { + break; + } + + if (nextBreakPosition > measuredLength) + { + break; + } + + currentBreakPosition = nextBreakPosition; + } + + measuredLength = currentBreakPosition; + } + } + + var splitResult = SplitTextRuns(textRuns, measuredLength); + + var trimmedRuns = new List(splitResult.First.Count + 1); + + trimmedRuns.AddRange(splitResult.First); + + trimmedRuns.Add(ellipsisRun); + + var textLineMetrics = + TextLineMetrics.Create(trimmedRuns, textRange, paragraphWidth, paragraphProperties); + + return new TextLineImpl(trimmedRuns, textLineMetrics); + } + + availableWidth -= currentRun.GlyphRun.Bounds.Width; + + runIndex++; + } + + return new TextLineImpl(textRuns, + TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties)); + } + + /// + /// Performs text wrapping returns a list of text lines. + /// + /// The text run's. + /// The text range that is covered by the text runs. + /// The paragraph width. + /// The text paragraph properties. + /// The wrapped text line. + private static TextLine PerformTextWrapping(IReadOnlyList 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)); + } + + /// + /// Measures the number of characters that fits into available width. + /// + /// The text run. + /// The available width. + /// + 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; + } + + /// + /// Creates an ellipsis. + /// + /// The text run properties. + /// + private static ShapedTextCharacters CreateEllipsisRun(TextRunProperties properties) + { + var formatterImpl = AvaloniaLocator.Current.GetService(); + + var glyphRun = formatterImpl.ShapeText(s_ellipsis, properties.Typeface, properties.FontRenderingEmSize, + properties.CultureInfo); + + return new ShapedTextCharacters(glyphRun, properties); + } + + /// + /// Gets the text range that is covered by the text runs. + /// + /// The text runs. + /// The text range that is covered by the text runs. + private static TextRange GetTextRange(IReadOnlyList 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); + } + + /// + /// Split a sequence of runs into two segments at specified length. + /// + /// The text run's. + /// The length to split at. + /// The split text runs. + private static SplitTextRunsResult SplitTextRuns(IReadOnlyList 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 first, IReadOnlyList second) + { + First = first; + + Second = second; + } + + /// + /// Gets the first text runs. + /// + /// + /// The first text runs. + /// + public IReadOnlyList First { get; } + + /// + /// Gets the second text runs. + /// + /// + /// The second text runs. + /// + public IReadOnlyList 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); + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextHidden.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextHidden.cs new file mode 100644 index 0000000000..83c5a640eb --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextHidden.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Specialized text run used to mark a range of hidden characters + /// + public class TextHidden : TextRun + { + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 0292398782..2e2e4a8c68 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/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 /// public class TextLayout { - private static readonly ReadOnlySlice s_empty = new ReadOnlySlice(new[] { '\u200B' }); + private static readonly char[] s_empty = { '\u200B' }; private readonly ReadOnlySlice _text; private readonly TextParagraphProperties _paragraphProperties; - private readonly IReadOnlyList _textStyleOverrides; + private readonly IReadOnlyList> _textStyleOverrides; /// /// Initializes a new instance of the class. @@ -33,6 +31,7 @@ namespace Avalonia.Media.TextFormatting /// The text decorations. /// The maximum width. /// The maximum height. + /// The height of each line of text. /// The maximum number of text lines. /// The text style overrides. public TextLayout( @@ -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 textStyleOverrides = null) + IReadOnlyList> textStyleOverrides = null) { _text = string.IsNullOrEmpty(text) ? new ReadOnlySlice() : new ReadOnlySlice(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(); } + /// + /// Gets or sets the height of each line of text. + /// + /// + /// A value of NaN (equivalent to an attribute value of "Auto") indicates that the line height + /// is determined automatically from the current font characteristics. The default is NaN. + /// + public double LineHeight { get; } + /// /// Gets the maximum width. /// public double MaxWidth { get; } - /// /// Gets the maximum height. /// public double MaxHeight { get; } - /// /// Gets the maximum number of text lines. /// - public double MaxLines { get; } + public int MaxLines { get; } /// /// Gets the text lines. @@ -105,7 +115,7 @@ namespace Avalonia.Media.TextFormatting /// /// The drawing context. /// The origin. - 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 /// The text wrapping. /// The text trimming. /// The text decorations. + /// The height of each line of text. /// private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize, IBrush foreground, TextAlignment textAlignment, TextWrapping textWrapping, TextTrimming textTrimming, - 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); } /// @@ -170,14 +182,15 @@ namespace Avalonia.Media.TextFormatting /// The empty text line. 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(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)); } /// @@ -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 _text; - private readonly TextStyle _defaultStyle; - private readonly IReadOnlyList _textStyleOverrides; + private readonly TextRunProperties _defaultProperties; + private readonly IReadOnlyList> _textModifier; - public FormattedTextSource(ReadOnlySlice text, TextStyle defaultStyle, - IReadOnlyList textStyleOverrides) + public FormattedTextSource(ReadOnlySlice text, TextRunProperties defaultProperties, + IReadOnlyList> 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); } /// - /// Creates a text style run that has overrides applied. Only overrides with equal TextStyle. - /// If optimizeForShaping is true Foreground is ignored. + /// Creates a span of text run properties that has modifier applied. /// - /// The text to create the run for. - /// The default text style for segments that don't have an override. - /// The text style overrides. + /// The text to create the properties for. + /// The default text properties. + /// The text properties modifier. /// /// The created text style run. /// - private static TextStyleRun CreateTextStyleRunWithOverride(ReadOnlySlice text, - TextStyle defaultTextStyle, IReadOnlyList textStyleOverrides) + private static ValueSpan CreateTextStyleRun(ReadOnlySlice text, + TextRunProperties defaultProperties, IReadOnlyList> 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(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(text.Start, length, currentProperties); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs index a0f7b44882..c3b7dfc77a 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs +++ b/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 { /// - /// Gets the text. + /// Gets the text range that is covered by the line. /// /// - /// The text pointer. + /// The text range that is covered by the line. /// - public abstract TextPointer Text { get; } + public abstract TextRange TextRange { get; } /// /// Gets the text runs. @@ -32,12 +31,20 @@ namespace Avalonia.Media.TextFormatting /// public abstract TextLineMetrics LineMetrics { get; } + /// + /// Gets the state of the line when broken by line breaking process. + /// + /// + /// A value that represents the line break. + /// + public abstract TextLineBreak LineBreak { get; } + /// /// Draws the at the given origin. /// /// The drawing context. /// The origin. - public abstract void Draw(IDrawingContextImpl drawingContext, Point origin); + public abstract void Draw(DrawingContext drawingContext, Point origin); /// /// Client to get the character hit corresponding to the specified diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs new file mode 100644 index 0000000000..c24454cb76 --- /dev/null +++ b/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 remainingCharacters) + { + RemainingCharacters = remainingCharacters; + } + + /// + /// Get the remaining shaped characters that were split up by the during the formatting process. + /// + public IReadOnlyList RemainingCharacters { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs new file mode 100644 index 0000000000..cf00399b8a --- /dev/null +++ b/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 _textRuns; + + public TextLineImpl(IReadOnlyList textRuns, TextLineMetrics lineMetrics, + TextLineBreak lineBreak = null) + { + _textRuns = textRuns; + LineMetrics = lineMetrics; + LineBreak = lineBreak; + } + + /// + public override TextRange TextRange => LineMetrics.TextRange; + + /// + public override IReadOnlyList TextRuns => _textRuns; + + /// + public override TextLineMetrics LineMetrics { get; } + + /// + public override TextLineBreak LineBreak { get; } + + /// + 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; + } + } + + /// + 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; + } + + /// + public override double GetDistanceFromCharacterHit(CharacterHit characterHit) + { + return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0)); + } + + /// + 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 + } + + /// + 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 + } + + /// + public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit) + { + // same operation as move-to-previous + return GetPreviousCaretCharacterHit(characterHit); + } + + /// + /// Get distance from line start to the specified codepoint index. + /// + 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; + } + + /// + /// Tries to find the next character hit. + /// + /// The current character hit. + /// The next character hit. + /// + 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; + } + + /// + /// Tries to find the previous character hit. + /// + /// The current character hit. + /// The previous character hit. + /// + 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; + } + + /// + /// Gets the run index of the specified codepoint index. + /// + /// The codepoint index. + /// The text run index. + 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; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs index 096305c09c..d47cc0c394 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs +++ b/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 /// 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; } /// - /// Gets the overall recommended distance above the baseline. + /// Gets the text range that is covered by the text line. /// /// - /// The ascent. + /// The text range that is covered by the text line. /// - public double Ascent { get; } - - /// - /// Gets the overall recommended distance under the baseline. - /// - /// - /// The descent. - /// - public double Descent { get; } - - /// - /// Gets the overall recommended additional space between two lines of text. - /// - /// - /// The leading. - /// - public double LineGap { get; } + public TextRange TextRange { get; } /// /// Gets the size of the text line. @@ -61,10 +44,12 @@ namespace Avalonia.Media.TextFormatting /// Creates the text line metrics. /// /// The text runs. + /// The text range that is covered by the text line. /// The paragraph width. - /// The text alignment. + /// The text alignment. /// - public static TextLineMetrics Create(IEnumerable textRuns, double paragraphWidth, TextAlignment textAlignment) + public static TextLineMetrics Create(IEnumerable 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); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextModifier.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextModifier.cs new file mode 100644 index 0000000000..06e93aaa8b --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextModifier.cs @@ -0,0 +1,19 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Specialized text run used to modify properties of text runs in its scope. + /// The scope extends to the next matching EndOfSegment text run (matching + /// because text modifiers may be nested), or to the next EndOfParagraph. + /// + public abstract class TextModifier : TextRun + { + /// + /// Modifies the properties of a text run. + /// + /// Properties of a text run or the return value of + /// ModifyProperties for a nested text modifier. + /// Returns the actual text run properties to be used for formatting, + /// subject to further modification by text modifiers at outer scopes. + public abstract TextRunProperties ModifyProperties(TextRunProperties properties); + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs index 1368f1777a..39eb695404 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs @@ -3,38 +3,37 @@ /// /// Provides a set of properties that are used during the paragraph layout. /// - 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; - } + /// + /// Gets the text alignment. + /// + public abstract TextAlignment TextAlignment { get; } /// /// Gets the default text style. /// - public TextStyle DefaultTextStyle { get; } + public abstract TextRunProperties DefaultTextRunProperties { get; } /// - /// 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. /// - public TextAlignment TextAlignment { get; } + public virtual TextDecorationCollection TextDecorations => null; /// /// Gets the text wrapping. /// - public TextWrapping TextWrapping { get; } + public abstract TextWrapping TextWrapping { get; } /// /// Gets the text trimming. /// - public TextTrimming TextTrimming { get; } + public abstract TextTrimming TextTrimming { get; } + + /// + /// Paragraph's line height + /// + public abstract double LineHeight { get; } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs similarity index 73% rename from src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs rename to src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs index 65d5c04b4c..1177c758f4 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs @@ -5,9 +5,9 @@ namespace Avalonia.Media.TextFormatting /// /// References a portion of a text buffer. /// - 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. /// /// The number of elements to return. - /// A that contains the specified number of elements from the start of this slice. - public TextPointer Take(int length) + /// A that contains the specified number of elements from the start of this slice. + public TextRange Take(int length) { if (length > Length) { throw new ArgumentOutOfRangeException(nameof(length)); } - return new TextPointer(Start, length); + return new TextRange(Start, length); } /// /// Bypasses a specified number of elements in the slice and then returns the remaining elements. /// /// The number of elements to skip before returning the remaining elements. - /// A that contains the elements that occur after the specified index in this slice. - public TextPointer Skip(int length) + /// A that contains the elements that occur after the specified index in this slice. + 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); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs index 28b83333b9..c15a771755 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs +++ b/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; + + /// + /// Gets the text source length. + /// + public virtual int TextSourceLength => DefaultTextSourceLength; + /// /// Gets the text run's text. /// - public ReadOnlySlice Text { get; protected set; } + public virtual ReadOnlySlice Text => default; /// - /// Gets the text run's style. + /// A set of properties shared by every characters in the run /// - 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; } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs new file mode 100644 index 0000000000..bbcdfe2d8e --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs @@ -0,0 +1,90 @@ +using System; +using System.Globalization; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Properties that can change from one run to the next, such as typeface or foreground brush. + /// + /// + /// 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. + /// + public abstract class TextRunProperties : IEquatable + { + /// + /// Run typeface + /// + public abstract Typeface Typeface { get; } + + /// + /// Em size of font used to format and display text + /// + public abstract double FontRenderingEmSize { get; } + + /// + /// Run TextDecorations. + /// + public abstract TextDecorationCollection TextDecorations { get; } + + /// + /// Brush used to fill text. + /// + public abstract IBrush ForegroundBrush { get; } + + /// + /// Brush used to paint background of run. + /// + public abstract IBrush BackgroundBrush { get; } + + /// + /// Run text culture. + /// + 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); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs index eb3a4129bc..a02ace408f 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs +++ b/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 } /// - public GlyphRun ShapeText(ReadOnlySlice text, TextFormat textFormat) + public GlyphRun ShapeText(ReadOnlySlice text, Typeface typeface, double fontRenderingEmSize, + CultureInfo culture) { - return _platformImpl.ShapeText(text, textFormat); + return _platformImpl.ShapeText(text, typeface, fontRenderingEmSize, culture); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs deleted file mode 100644 index cf52c3ca17..0000000000 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Avalonia.Media.Immutable; - -namespace Avalonia.Media.TextFormatting -{ - /// - /// Unique text formatting properties that effect the styling of a text. - /// - 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; - } - - /// - /// Gets the text format. - /// - public TextFormat TextFormat { get; } - - /// - /// Gets the foreground. - /// - public IBrush Foreground { get; } - - /// - /// Gets the text decorations. - /// - public ImmutableTextDecoration[] TextDecorations { get; } - } -} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs deleted file mode 100644 index 55f8999182..0000000000 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Avalonia.Media.TextFormatting -{ - /// - /// Represents a text run's style and is used during the layout process of the . - /// - public readonly struct TextStyleRun - { - public TextStyleRun(TextPointer textPointer, TextStyle style) - { - TextPointer = textPointer; - Style = style; - } - - /// - /// Gets the text pointer. - /// - public TextPointer TextPointer { get; } - - /// - /// Gets the text style. - /// - public TextStyle Style { get; } - } -} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs index 94171b7324..20fe345d93 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs @@ -1,4 +1,4 @@ -using Avalonia.Utility; +using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting.Unicode { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs index 2ff4952cab..9e1f748ebb 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs @@ -1,4 +1,4 @@ -using Avalonia.Utility; +using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting.Unicode { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs index a6791b4a53..f268340eb9 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs @@ -1,4 +1,4 @@ -using Avalonia.Utility; +using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting.Unicode { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs index fd7831dfe6..1e4ac8fe0f 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs +++ b/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 { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs index a11c008409..82257136cd 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs +++ b/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 { diff --git a/src/Avalonia.Visuals/Media/TextWrapping.cs b/src/Avalonia.Visuals/Media/TextWrapping.cs index 56df3670bd..d649bda23f 100644 --- a/src/Avalonia.Visuals/Media/TextWrapping.cs +++ b/src/Avalonia.Visuals/Media/TextWrapping.cs @@ -5,6 +5,13 @@ namespace Avalonia.Media /// public enum TextWrapping { + /// + /// Line-breaking occurs if the line overflows the available block width. + /// However, a line may overflow the block width if the line breaking algorithm + /// cannot determine a break opportunity, as in the case of a very long word. + /// + WrapWithOverflow, + /// /// Text should not wrap. /// @@ -15,4 +22,4 @@ namespace Avalonia.Media /// Wrap } -} \ No newline at end of file +} diff --git a/src/Avalonia.Visuals/Media/Typeface.cs b/src/Avalonia.Visuals/Media/Typeface.cs index 7618598a3f..677e930804 100644 --- a/src/Avalonia.Visuals/Media/Typeface.cs +++ b/src/Avalonia.Visuals/Media/Typeface.cs @@ -16,11 +16,11 @@ namespace Avalonia.Media /// Initializes a new instance of the class. /// /// The font family. - /// The font weight. /// The font style. + /// The font weight. 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 /// The font style. /// The font weight. 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) { } diff --git a/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs b/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs index 4d770a6c6e..d915da2603 100644 --- a/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs +++ b/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. /// /// The text. - /// The text format. + /// The typeface. + /// The font rendering em size. + /// The culture. /// A shaped glyph run. - GlyphRun ShapeText(ReadOnlySlice text, TextFormat textFormat); + GlyphRun ShapeText(ReadOnlySlice text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture); } } diff --git a/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs b/src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs similarity index 98% rename from src/Avalonia.Visuals/Utility/ReadOnlySlice.cs rename to src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs index ff2b3b9363..5feaa88e26 100644 --- a/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs +++ b/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 { /// /// 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; } /// - /// Gets a value that indicates whether this instance of is Empty. + /// Gets a value that indicates whether this instance of is Empty. /// public bool IsEmpty => Length == 0; diff --git a/src/Avalonia.Visuals/Utilities/ValueSpan.cs b/src/Avalonia.Visuals/Utilities/ValueSpan.cs new file mode 100644 index 0000000000..7a10d865ef --- /dev/null +++ b/src/Avalonia.Visuals/Utilities/ValueSpan.cs @@ -0,0 +1,30 @@ +namespace Avalonia.Utilities +{ + /// + /// Pairing of value and positions sharing that value. + /// + public readonly struct ValueSpan + { + public ValueSpan(int start, int length, T value) + { + Start = start; + Length = length; + Value = value; + } + + /// + /// Get's the start of the span. + /// + public int Start { get; } + + /// + /// Get's the length of the span. + /// + public int Length { get; } + + /// + /// Get's the value of the span. + /// + public T Value { get; } + } +} diff --git a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs index 5f876464e2..ade659f5eb 100644 --- a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs +++ b/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) diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index 7a0823a223..786af7726c 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/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 text, TextFormat textFormat) + public GlyphRun ShapeText(ReadOnlySlice 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(glyphIndices), new ReadOnlySlice(glyphAdvances), new ReadOnlySlice(glyphOffsets), diff --git a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs index 2d2865e2b9..254b5684a4 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs +++ b/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 text, TextFormat textFormat) + public GlyphRun ShapeText(ReadOnlySlice 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(glyphIndices), new ReadOnlySlice(glyphAdvances), new ReadOnlySlice(glyphOffsets), diff --git a/tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs b/tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs index 572749a58a..c6ecc0a7e5 100644 --- a/tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs +++ b/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; diff --git a/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs b/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs similarity index 98% rename from tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs rename to tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs index 8d64190ebd..feed1179ef 100644 --- a/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs +++ b/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 { diff --git a/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs similarity index 95% rename from tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs rename to tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs index 8f80d89ac6..df286d709e 100644 --- a/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs +++ b/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; diff --git a/tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs b/tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs similarity index 89% rename from tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs rename to tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs index 726052351b..f9f924e782 100644 --- a/tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs +++ b/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); diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs new file mode 100644 index 0000000000..6a5065939e --- /dev/null +++ b/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 _text; + private readonly TextRunProperties _defaultStyle; + private ReadOnlySlice> _styleSpans; + + public FormattableTextSource(string text, TextRunProperties defaultStyle, + ReadOnlySlice> 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); + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs new file mode 100644 index 0000000000..40aa862906 --- /dev/null +++ b/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(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle); + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs new file mode 100644 index 0000000000..045deacd0b --- /dev/null +++ b/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 _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); + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs new file mode 100644 index 0000000000..697cc4fec7 --- /dev/null +++ b/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(0, 3, defaultProperties), + new ValueSpan(3, 3, + new GenericTextRunProperties(Typeface.Default, 13, foregroundBrush: Brushes.Black)), + new ValueSpan(6, 3, + new GenericTextRunProperties(Typeface.Default, 14, foregroundBrush: Brushes.Black)), + new ValueSpan(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 ‑‑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(); + + 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().ToConstant(new FontManager(new CustomFontManagerImpl())); + + return disposable; + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs similarity index 77% rename from tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs rename to tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index a2c9f8b8cd..5d9aa2cf97 100644 --- a/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs +++ b/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(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(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(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(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(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(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(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(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; } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs new file mode 100644 index 0000000000..ed00d6aaed --- /dev/null +++ b/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().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().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; + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs deleted file mode 100644 index 8e695a11c8..0000000000 --- a/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs +++ /dev/null @@ -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(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 _text; - private readonly TextStyle _defaultStyle; - private ReadOnlySlice _textStyleRuns; - - public FormattableTextSource(string text, TextStyle defaultStyle, ReadOnlySlice 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 _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 ‑‑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(); - - 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().ToConstant(new FontManager(new CustomFontManagerImpl())); - - return disposable; - } - } -} diff --git a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs b/tests/Avalonia.UnitTests/MockTextShaperImpl.cs index 0772e0e9bd..fe1c34385f 100644 --- a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs +++ b/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 text, TextFormat textFormat) + public GlyphRun ShapeText(ReadOnlySlice 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(glyphIndices.AsMemory(0, glyphCount)), characters: text); } } } diff --git a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs index 028caa35c6..219c7ece46 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs +++ b/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 diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs index fe7d7adc17..91e6482abf 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs +++ b/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.Text diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs index 0e43c76da1..e526172622 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs +++ b/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(() => new Typeface("foo", 0, (FontStyle)12)); + Assert.Throws(() => new Typeface("foo", (FontStyle)12, 0)); } [Fact]