Browse Source

Port WPF's FormattedText to Avalonia and rework TextPresenter

pull/4893/head
Benedikt Stebner 4 years ago
parent
commit
b815a22d5a
  1. 37
      samples/ControlCatalog/Pages/ScreenPage.cs
  2. 3
      samples/RenderDemo/MainWindow.xaml
  3. 13
      samples/RenderDemo/Pages/CustomSkiaPage.cs
  4. 7
      samples/RenderDemo/Pages/FormattedTextPage.axaml
  5. 60
      samples/RenderDemo/Pages/FormattedTextPage.axaml.cs
  6. 7
      samples/RenderDemo/Pages/GlyphRunPage.xaml.cs
  7. 2
      src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs
  8. 6
      src/Avalonia.Controls/ApiCompatBaseline.txt
  9. 19
      src/Avalonia.Controls/Control.cs
  10. 487
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  11. 52
      src/Avalonia.Controls/TextBlock.cs
  12. 241
      src/Avalonia.Controls/TextBox.cs
  13. 24
      src/Avalonia.Controls/TextBoxTextInputMethodClient.cs
  14. 53
      src/Avalonia.Controls/Utils/StringUtils.cs
  15. 36
      src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs
  16. 12
      src/Avalonia.Headless/HeadlessPlatformStubs.cs
  17. 61
      src/Avalonia.Visuals/ApiCompatBaseline.txt
  18. BIN
      src/Avalonia.Visuals/Assets/BiDi.trie
  19. BIN
      src/Avalonia.Visuals/Assets/UnicodeData.trie
  20. 10
      src/Avalonia.Visuals/Media/DrawingContext.cs
  21. 1413
      src/Avalonia.Visuals/Media/FormattedText.cs
  22. 29
      src/Avalonia.Visuals/Media/FormattedTextLine.cs
  23. 39
      src/Avalonia.Visuals/Media/FormattedTextStyleSpan.cs
  24. 280
      src/Avalonia.Visuals/Media/GlyphRun.cs
  25. 25
      src/Avalonia.Visuals/Media/TextDecoration.cs
  26. 8
      src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs
  27. 24
      src/Avalonia.Visuals/Media/TextFormatting/FormattedTextSource.cs
  28. 5
      src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs
  29. 293
      src/Avalonia.Visuals/Media/TextFormatting/ShapedBuffer.cs
  30. 200
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs
  31. 28
      src/Avalonia.Visuals/Media/TextFormatting/SplitResult.cs
  32. 72
      src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs
  33. 9
      src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs
  34. 668
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs
  35. 534
      src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs
  36. 28
      src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs
  37. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs
  38. 626
      src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs
  39. 2
      src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs
  40. 2
      src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs
  41. 6
      src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs
  42. 1717
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiAlgorithm.cs
  43. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiClass.cs
  44. 182
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiData.cs
  45. 9
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiPairedBracketType.cs
  46. 58
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs
  47. 331
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/PropertyValueAliasHelper.cs
  48. 71
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs
  49. 27
      src/Avalonia.Visuals/Media/TextHitTestResult.cs
  50. 2
      src/Avalonia.Visuals/Media/Typeface.cs
  51. 7
      src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs
  52. 59
      src/Avalonia.Visuals/Platform/IFormattedTextImpl.cs
  53. 21
      src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs
  54. 6
      src/Avalonia.Visuals/Platform/ITextShaperImpl.cs
  55. 5
      src/Avalonia.Visuals/Rendering/DeferredRenderer.cs
  56. 2
      src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs
  57. 30
      src/Avalonia.Visuals/Rendering/RendererBase.cs
  58. 15
      src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs
  59. 89
      src/Avalonia.Visuals/Rendering/SceneGraph/TextNode.cs
  60. 184
      src/Avalonia.Visuals/Utilities/ArrayBuilder.cs
  61. 197
      src/Avalonia.Visuals/Utilities/ArraySlice.cs
  62. 93
      src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs
  63. 2360
      src/Avalonia.Visuals/Utilities/FrugalList.cs
  64. 58
      src/Avalonia.Visuals/Utilities/MappedArraySlice.cs
  65. 80
      src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs
  66. 596
      src/Avalonia.Visuals/Utilities/Span.cs
  67. 12
      src/Skia/Avalonia.Skia/DrawingContextImpl.cs
  68. 838
      src/Skia/Avalonia.Skia/FormattedTextImpl.cs
  69. 44
      src/Skia/Avalonia.Skia/PlatformRenderInterface.cs
  70. 135
      src/Skia/Avalonia.Skia/TextShaperImpl.cs
  71. 25
      src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs
  72. 23
      src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs
  73. 129
      src/Windows/Avalonia.Direct2D1/Media/FormattedTextImpl.cs
  74. 137
      src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs
  75. 4
      tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs
  76. 36
      tests/Avalonia.Benchmarks/NullFormattedTextImpl.cs
  77. 6
      tests/Avalonia.Benchmarks/NullRenderingPlatform.cs
  78. 1
      tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj
  79. 14
      tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs
  80. 17
      tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs
  81. 53
      tests/Avalonia.Controls.UnitTests/TextBoxTests.cs
  82. 4
      tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs
  83. 267
      tests/Avalonia.RenderTests/Media/FormattedTextImplTests.cs
  84. 249
      tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs
  85. 223
      tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs
  86. 144
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  87. 306
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  88. 245
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
  89. 16
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs
  90. 1
      tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj
  91. 133
      tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs
  92. 12
      tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs
  93. 27
      tests/Avalonia.UnitTests/MockTextShaperImpl.cs
  94. 8
      tests/Avalonia.UnitTests/TestServices.cs
  95. 97
      tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
  96. 85
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs
  97. 111
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTestDataGenerator.cs
  98. 94
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTests.cs
  99. 7
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs
  100. 148
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiTestDataGenerator.cs

37
samples/ControlCatalog/Pages/ScreenPage.cs

@ -1,4 +1,5 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
@ -49,25 +50,33 @@ namespace ControlCatalog.Pages
context.DrawRectangle(p, boundsRect);
context.DrawRectangle(p, workingAreaRect);
var text = new FormattedText() { Typeface = new Typeface("Arial"), FontSize = 18 };
text.Text = $"Bounds: {screen.Bounds.TopLeft} {screen.Bounds.Width}:{screen.Bounds.Height}";
context.DrawText(drawBrush, boundsRect.Position.WithY(boundsRect.Size.Height), text);
text.Text = $"WorkArea: {screen.WorkingArea.TopLeft} {screen.WorkingArea.Width}:{screen.WorkingArea.Height}";
context.DrawText(drawBrush, boundsRect.Position.WithY(boundsRect.Size.Height + 20), text);
var formattedText = CreateFormattedText($"Bounds: {screen.Bounds.Width}:{screen.Bounds.Height}");
context.DrawText(formattedText, boundsRect.Position.WithY(boundsRect.Size.Height));
text.Text = $"Scaling: {screen.PixelDensity * 100}%";
context.DrawText(drawBrush, boundsRect.Position.WithY(boundsRect.Size.Height + 40), text);
text.Text = $"Primary: {screen.Primary}";
context.DrawText(drawBrush, boundsRect.Position.WithY(boundsRect.Size.Height + 60), text);
text.Text = $"Current: {screen.Equals(w.Screens.ScreenFromBounds(new PixelRect(w.Position, PixelSize.FromSize(w.Bounds.Size, scaling))))}";
context.DrawText(drawBrush, boundsRect.Position.WithY(boundsRect.Size.Height + 80), text);
formattedText =
CreateFormattedText($"WorkArea: {screen.WorkingArea.Width}:{screen.WorkingArea.Height}");
context.DrawText(formattedText, boundsRect.Position.WithY(boundsRect.Size.Height + 20));
formattedText = CreateFormattedText($"Scaling: {screen.PixelDensity * 100}%");
context.DrawText(formattedText, boundsRect.Position.WithY(boundsRect.Size.Height + 40));
formattedText = CreateFormattedText($"Primary: {screen.Primary}");
context.DrawText(formattedText, boundsRect.Position.WithY(boundsRect.Size.Height + 60));
formattedText =
CreateFormattedText(
$"Current: {screen.Equals(w.Screens.ScreenFromBounds(new PixelRect(w.Position, PixelSize.FromSize(w.Bounds.Size, scaling))))}");
context.DrawText(formattedText, boundsRect.Position.WithY(boundsRect.Size.Height + 80));
}
context.DrawRectangle(p, new Rect(w.Position.X / 10f + Math.Abs(_leftMost), w.Position.Y / 10f, w.Bounds.Width / 10, w.Bounds.Height / 10));
}
private FormattedText CreateFormattedText(string textToFormat)
{
return new FormattedText(textToFormat, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
Typeface.Default, 12, Brushes.Green);
}
}
}

3
samples/RenderDemo/MainWindow.xaml

@ -57,6 +57,9 @@
<TabItem Header="GlyphRun">
<pages:GlyphRunPage />
</TabItem>
<TabItem Header="FormattedText">
<pages:FormattedTextPage />
</TabItem>
<TabItem Header="LineBounds">
<pages:LineBoundsPage />
</TabItem>

13
samples/RenderDemo/Pages/CustomSkiaPage.cs

@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
@ -41,7 +42,10 @@ namespace RenderDemo.Pages
{
var canvas = (context as ISkiaDrawingContextImpl)?.SkCanvas;
if (canvas == null)
context.DrawText(Brushes.Black, new Point(), _noSkia.PlatformImpl);
using (var c = new DrawingContext(context, false))
{
c.DrawText(_noSkia, new Point());
}
else
{
canvas.Save();
@ -108,10 +112,9 @@ namespace RenderDemo.Pages
public override void Render(DrawingContext context)
{
var noSkia = new FormattedText()
{
Text = "Current rendering API is not Skia"
};
var noSkia = new FormattedText("Current rendering API is not Skia", CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, Typeface.Default, 12, Brushes.Black);
context.Custom(new CustomDrawOp(new Rect(0, 0, Bounds.Width, Bounds.Height), noSkia));
Dispatcher.UIThread.InvokeAsync(InvalidateVisual, DispatcherPriority.Background);
}

7
samples/RenderDemo/Pages/FormattedTextPage.axaml

@ -0,0 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="RenderDemo.Pages.FormattedTextPage">
</UserControl>

60
samples/RenderDemo/Pages/FormattedTextPage.axaml.cs

@ -0,0 +1,60 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
namespace RenderDemo.Pages
{
public class FormattedTextPage : UserControl
{
public FormattedTextPage()
{
this.InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
public override void Render(DrawingContext context)
{
const string testString = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor";
// Create the initial formatted text string.
var formattedText = new FormattedText(
testString,
CultureInfo.GetCultureInfo("en-us"),
FlowDirection.LeftToRight,
new Typeface("Verdana"),
32,
Brushes.Black) { MaxTextWidth = 300, MaxTextHeight = 240 };
// Set a maximum width and height. If the text overflows these values, an ellipsis "..." appears.
// Use a larger font size beginning at the first (zero-based) character and continuing for 5 characters.
// The font size is calculated in terms of points -- not as device-independent pixels.
formattedText.SetFontSize(36 * (96.0 / 72.0), 0, 5);
// Use a Bold font weight beginning at the 6th character and continuing for 11 characters.
formattedText.SetFontWeight(FontWeight.Bold, 6, 11);
var gradient = new LinearGradientBrush
{
GradientStops =
new GradientStops { new GradientStop(Colors.Orange, 0), new GradientStop(Colors.Teal, 1) },
StartPoint = new RelativePoint(0,0, RelativeUnit.Relative),
EndPoint = new RelativePoint(0,1, RelativeUnit.Relative)
};
// Use a linear gradient brush beginning at the 6th character and continuing for 11 characters.
formattedText.SetForegroundBrush(gradient, 6, 11);
// Use an Italic font style beginning at the 28th character and continuing for 28 characters.
formattedText.SetFontStyle(FontStyle.Italic, 28, 28);
context.DrawText(formattedText, new Point(10, 0));
}
}
}

7
samples/RenderDemo/Pages/GlyphRunPage.xaml.cs

@ -13,6 +13,7 @@ namespace RenderDemo.Pages
private GlyphTypeface _glyphTypeface = Typeface.Default.GlyphTypeface;
private readonly Random _rand = new Random();
private ushort[] _glyphIndices = new ushort[1];
private char[] _characters = new char[1];
private float _fontSize = 20;
private int _direction = 10;
@ -38,7 +39,7 @@ namespace RenderDemo.Pages
private void UpdateGlyphRun()
{
var c = (uint)_rand.Next(65, 90);
var c = (char)_rand.Next(65, 90);
if (_fontSize + _direction > 200)
{
@ -54,6 +55,8 @@ namespace RenderDemo.Pages
_glyphIndices[0] = _glyphTypeface.GetGlyph(c);
_characters[0] = c;
var scale = (double)_fontSize / _glyphTypeface.DesignEmHeight;
var drawingGroup = new DrawingGroup();
@ -61,7 +64,7 @@ namespace RenderDemo.Pages
var glyphRunDrawing = new GlyphRunDrawing
{
Foreground = Brushes.Black,
GlyphRun = new GlyphRun(_glyphTypeface, _fontSize, _glyphIndices),
GlyphRun = new GlyphRun(_glyphTypeface, _fontSize, _characters, _glyphIndices)
};
drawingGroup.Children.Add(glyphRunDrawing);

2
src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs

@ -3,7 +3,7 @@ using System.Collections.Generic;
namespace Avalonia.Utilities
{
public struct ImmutableReadOnlyListStructEnumerator<T> : IEnumerator, IEnumerator<T>
public struct ImmutableReadOnlyListStructEnumerator<T> : IEnumerator<T>
{
private readonly IReadOnlyList<T> _readOnlyList;
private int _pos;

6
src/Avalonia.Controls/ApiCompatBaseline.txt

@ -43,6 +43,10 @@ MembersMustExist : Member 'public void Avalonia.Controls.Embedding.Offscreen.Off
MembersMustExist : Member 'public void Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetCursor(Avalonia.Platform.IPlatformHandle)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.AvaloniaProperty Avalonia.AvaloniaProperty Avalonia.Controls.Notifications.NotificationCard.CloseOnClickProperty' does not exist in the implementation but it does exist in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Controls.Platform.ITopLevelNativeMenuExporter.SetNativeMenu(Avalonia.Controls.NativeMenu)' is present in the contract but not in the implementation.
MembersMustExist : Member 'protected Avalonia.Media.FormattedText Avalonia.Controls.Presenters.TextPresenter.CreateFormattedText()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Media.FormattedText Avalonia.Controls.Presenters.TextPresenter.FormattedText.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public System.Int32 Avalonia.Controls.Presenters.TextPresenter.GetCaretIndex(Avalonia.Point)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'protected void Avalonia.Controls.Presenters.TextPresenter.InvalidateFormattedText()' does not exist in the implementation but it does exist in the contract.
CannotRemoveBaseTypeOrInterface : Type 'Avalonia.Controls.Primitives.PopupRoot' does not implement interface 'Avalonia.Utilities.IWeakSubscriber<Avalonia.Controls.ResourcesChangedEventArgs>' in the implementation but it does in the contract.
EnumValuesMustMatch : Enum value 'Avalonia.Platform.ExtendClientAreaChromeHints Avalonia.Platform.ExtendClientAreaChromeHints.Default' is (System.Int32)2 in the implementation but (System.Int32)1 in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.Nullable<Avalonia.Size> Avalonia.Platform.ITopLevelImpl.FrameSize' is present in the implementation but not in the contract.
@ -63,4 +67,4 @@ InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platfor
MembersMustExist : Member 'public void Avalonia.Platform.IWindowImpl.Resize(Avalonia.Size)' does not exist in the implementation but it does exist in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IWindowImpl.Resize(Avalonia.Size, Avalonia.Platform.PlatformResizeReason)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.ITrayIconImpl Avalonia.Platform.IWindowingPlatform.CreateTrayIcon()' is present in the implementation but not in the contract.
Total Issues: 64
Total Issues: 68

19
src/Avalonia.Controls/Control.cs

@ -1,10 +1,12 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Rendering;
using Avalonia.Styling;
using Avalonia.VisualTree;
@ -60,7 +62,13 @@ namespace Avalonia.Controls
public static readonly RoutedEvent<ContextRequestedEventArgs> ContextRequestedEvent =
RoutedEvent.Register<Control, ContextRequestedEventArgs>(nameof(ContextRequested),
RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="FlowDirection"/> property.
/// </summary>
public static readonly AttachedProperty<FlowDirection> FlowDirectionProperty =
AvaloniaProperty.RegisterAttached<Control, Control, FlowDirection>(nameof(FlowDirection), inherits: true);
private DataTemplates? _dataTemplates;
private IControl? _focusAdorner;
@ -108,6 +116,15 @@ namespace Avalonia.Controls
get => GetValue(TagProperty);
set => SetValue(TagProperty, value);
}
/// <summary>
/// Gets or sets the text flow direction.
/// </summary>
public FlowDirection FlowDirection
{
get => GetValue(FlowDirectionProperty);
set => SetValue(FlowDirectionProperty, value);
}
/// <summary>
/// Occurs when the user has completed a context input gesture, such as a right-click.

487
src/Avalonia.Controls/Presenters/TextPresenter.cs

@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reactive.Linq;
using Avalonia.Input.TextInput;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Metadata;
using Avalonia.Threading;
using Avalonia.Utilities;
using Avalonia.VisualTree;
using Avalonia.Layout;
using Avalonia.Media.Immutable;
@ -75,28 +78,18 @@ namespace Avalonia.Controls.Presenters
private int _selectionEnd;
private bool _caretBlink;
private string _text;
private FormattedText _formattedText;
private Size _constraint;
private TextLayout _textLayout;
private Size _constraint = Size.Infinity;
static TextPresenter()
{
AffectsRender<TextPresenter>(SelectionBrushProperty, TextBlock.ForegroundProperty,
SelectionForegroundBrushProperty, CaretBrushProperty,
SelectionStartProperty, SelectionEndProperty);
AffectsMeasure<TextPresenter>(TextProperty, PasswordCharProperty, RevealPasswordProperty,
TextAlignmentProperty, TextWrappingProperty, TextBlock.FontSizeProperty,
TextBlock.FontStyleProperty, TextBlock.FontWeightProperty, TextBlock.FontFamilyProperty);
private CharacterHit _lastCharacterHit;
private Rect _caretBounds;
private Point _navigationPosition;
Observable.Merge<AvaloniaPropertyChangedEventArgs>(TextProperty.Changed, TextBlock.ForegroundProperty.Changed,
TextAlignmentProperty.Changed, TextWrappingProperty.Changed,
TextBlock.FontSizeProperty.Changed, TextBlock.FontStyleProperty.Changed,
TextBlock.FontWeightProperty.Changed, TextBlock.FontFamilyProperty.Changed,
SelectionStartProperty.Changed, SelectionEndProperty.Changed,
SelectionForegroundBrushProperty.Changed, PasswordCharProperty.Changed, RevealPasswordProperty.Changed
).AddClassHandler<TextPresenter>((x, _) => x.InvalidateFormattedText());
private ScrollViewer _scrollViewer;
CaretIndexProperty.Changed.AddClassHandler<TextPresenter>((x, e) => x.CaretIndexChanged((int)e.NewValue));
static TextPresenter()
{
AffectsRender<TextPresenter>(CaretBrushProperty, SelectionBrushProperty);
}
public TextPresenter()
@ -106,6 +99,8 @@ namespace Avalonia.Controls.Presenters
_caretTimer.Tick += CaretTimerTick;
}
public event EventHandler CaretBoundsChanged;
/// <summary>
/// Gets or sets a brush used to paint the control's background.
/// </summary>
@ -189,13 +184,22 @@ namespace Avalonia.Controls.Presenters
}
/// <summary>
/// Gets the <see cref="FormattedText"/> used to render the text.
/// Gets the <see cref="TextLayout"/> used to render the text.
/// </summary>
public FormattedText FormattedText
public TextLayout TextLayout
{
get
{
return _formattedText ?? (_formattedText = CreateFormattedText());
if (_textLayout != null)
{
return _textLayout;
}
_textLayout = CreateTextLayout();
UpdateCaret(_lastCharacterHit);
return _textLayout;
}
}
@ -205,11 +209,12 @@ namespace Avalonia.Controls.Presenters
{
return _caretIndex;
}
set
{
value = CoerceCaretIndex(value);
SetAndRaise(CaretIndexProperty, ref _caretIndex, value);
if (value != _caretIndex)
{
MoveCaretToTextPosition(value);
}
}
}
@ -271,37 +276,25 @@ namespace Avalonia.Controls.Presenters
}
}
public int GetCaretIndex(Point point)
{
var hit = FormattedText.HitTestPoint(point);
return hit.TextPosition + (hit.IsTrailing ? 1 : 0);
}
/// <summary>
/// Creates the <see cref="FormattedText"/> used to render the text.
/// Creates the <see cref="TextLayout"/> used to render the text.
/// </summary>
/// <param name="constraint">The constraint of the text.</param>
/// <param name="text">The text to format.</param>
/// <returns>A <see cref="FormattedText"/> object.</returns>
private FormattedText CreateFormattedTextInternal(Size constraint, string text)
{
return new FormattedText
{
Constraint = constraint,
Typeface = new Typeface(FontFamily, FontStyle, FontWeight),
FontSize = FontSize,
Text = text ?? string.Empty,
TextAlignment = TextAlignment,
TextWrapping = TextWrapping,
};
}
/// <param name="typeface"></param>
/// <param name="textStyleOverrides"></param>
/// <returns>A <see cref="TextLayout"/> object.</returns>
private TextLayout CreateTextLayoutInternal(Size constraint, string text, Typeface typeface,
IReadOnlyList<ValueSpan<TextRunProperties>> textStyleOverrides)
{
var maxWidth = MathUtilities.IsZero(constraint.Width) ? double.PositiveInfinity : constraint.Width;
var maxHeight = MathUtilities.IsZero(constraint.Height) ? double.PositiveInfinity : constraint.Height;
var textLayout = new TextLayout(text ?? string.Empty, typeface, FontSize, Foreground, TextAlignment,
TextWrapping, maxWidth: maxWidth, maxHeight: maxHeight, textStyleOverrides: textStyleOverrides,
flowDirection: FlowDirection);
/// <summary>
/// Invalidates <see cref="FormattedText"/>.
/// </summary>
protected void InvalidateFormattedText()
{
_formattedText = null;
return textLayout;
}
/// <summary>
@ -317,31 +310,36 @@ namespace Avalonia.Controls.Presenters
context.FillRectangle(background, new Rect(Bounds.Size));
}
double top = 0;
var textSize = FormattedText.Bounds.Size;
var top = 0d;
var left = 0.0;
var (_, textHeight) = TextLayout.Size;
if (Bounds.Height < textSize.Height)
if (Bounds.Height < textHeight)
{
switch (VerticalAlignment)
{
case VerticalAlignment.Center:
top += (Bounds.Height - textSize.Height) / 2;
top += (Bounds.Height - textHeight) / 2;
break;
case VerticalAlignment.Bottom:
top += (Bounds.Height - textSize.Height);
top += (Bounds.Height - textHeight);
break;
}
}
context.DrawText(Foreground, new Point(0, top), FormattedText);
TextLayout.Draw(context, new Point(left, top));
}
public override void Render(DrawingContext context)
{
FormattedText.Constraint = Bounds.Size;
_constraint = Bounds.Size;
if (double.IsPositiveInfinity (_constraint.Width))
{
_constraint = _scrollViewer?.Viewport ?? Size.Infinity;
InvalidateTextLayout();
}
var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd;
@ -351,7 +349,7 @@ namespace Avalonia.Controls.Presenters
var start = Math.Min(selectionStart, selectionEnd);
var length = Math.Max(selectionStart, selectionEnd) - start;
var rects = FormattedText.HitTestTextRange(start, length);
var rects = TextLayout.HitTestTextRange(start, length);
foreach (var rect in rects)
{
@ -361,40 +359,54 @@ namespace Avalonia.Controls.Presenters
RenderInternal(context);
if (selectionStart == selectionEnd && _caretBlink)
if (selectionStart != selectionEnd || !_caretBlink)
{
return;
}
var caretBrush = CaretBrush?.ToImmutable();
if (caretBrush is null)
{
var caretBrush = CaretBrush?.ToImmutable();
var backgroundColor = (Background as ISolidColorBrush)?.Color;
if (caretBrush is null)
if (backgroundColor.HasValue)
{
var backgroundColor = (Background as ISolidColorBrush)?.Color;
if (backgroundColor.HasValue)
{
byte red = (byte)~(backgroundColor.Value.R);
byte green = (byte)~(backgroundColor.Value.G);
byte blue = (byte)~(backgroundColor.Value.B);
var red = (byte)~(backgroundColor.Value.R);
var green = (byte)~(backgroundColor.Value.G);
var blue = (byte)~(backgroundColor.Value.B);
caretBrush = new ImmutableSolidColorBrush(Color.FromRgb(red, green, blue));
}
else
{
caretBrush = Brushes.Black;
}
caretBrush = new ImmutableSolidColorBrush(Color.FromRgb(red, green, blue));
}
else
{
caretBrush = Brushes.Black;
}
var (p1, p2) = GetCaretPoints();
context.DrawLine(
new ImmutablePen(caretBrush, 1),
p1, p2);
}
}
(Point, Point) GetCaretPoints()
var (p1, p2) = GetCaretPoints();
context.DrawLine(
new ImmutablePen(caretBrush, 1),
p1, p2);
}
private (Point, Point) GetCaretPoints()
{
var charPos = FormattedText.HitTestTextPosition(CaretIndex);
var x = Math.Floor(charPos.X) + 0.5;
var y = Math.Floor(charPos.Y) + 0.5;
var b = Math.Ceiling(charPos.Bottom) - 0.5;
var x = Math.Floor(_caretBounds.X) + 0.5;
var y = Math.Floor(_caretBounds.Y) + 0.5;
var b = Math.Ceiling(_caretBounds.Bottom) - 0.5;
var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(_caretIndex);
var textLine = TextLayout.TextLines[lineIndex];
var posX = textLine.Start + x;
if (posX >= Bounds.Width)
{
x = Math.Floor(_caretBounds.X - 1) + 0.5;
}
return (new Point(x, y), new Point(x, b));
}
@ -412,7 +424,7 @@ namespace Avalonia.Controls.Presenters
InvalidateVisual();
}
internal void CaretIndexChanged(int caretIndex)
internal void CaretChanged()
{
if (this.GetVisualParent() != null)
{
@ -432,8 +444,9 @@ namespace Avalonia.Controls.Presenters
if (IsMeasureValid)
{
var rect = FormattedText.HitTestTextPosition(caretIndex);
this.BringIntoView(rect);
//var rect = TextLayout.HitTestTextPosition(caretIndex);
//_caretPosition = rect;
this.BringIntoView(_caretBounds);
}
else
{
@ -443,8 +456,8 @@ namespace Avalonia.Controls.Presenters
Dispatcher.UIThread.Post(
() =>
{
var rect = FormattedText.HitTestTextPosition(caretIndex);
this.BringIntoView(rect);
//var rect = TextLayout.HitTestTextPosition(caretIndex);
this.BringIntoView(_caretBounds);
},
DispatcherPriority.Render);
}
@ -452,104 +465,294 @@ namespace Avalonia.Controls.Presenters
}
/// <summary>
/// Creates the <see cref="FormattedText"/> used to render the text.
/// Creates the <see cref="TextLayout"/> used to render the text.
/// </summary>
/// <returns>A <see cref="FormattedText"/> object.</returns>
protected virtual FormattedText CreateFormattedText()
/// <returns>A <see cref="TextLayout"/> object.</returns>
protected virtual TextLayout CreateTextLayout()
{
FormattedText result = null;
TextLayout result;
var text = Text;
if (PasswordChar != default(char) && !RevealPassword)
{
result = CreateFormattedTextInternal(_constraint, new string(PasswordChar, text?.Length ?? 0));
}
else
{
result = CreateFormattedTextInternal(_constraint, text);
}
var typeface = new Typeface(FontFamily, FontStyle, FontWeight);
var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd;
var start = Math.Min(selectionStart, selectionEnd);
var length = Math.Max(selectionStart, selectionEnd) - start;
IReadOnlyList<ValueSpan<TextRunProperties>> textStyleOverrides = null;
if (length > 0)
{
result.Spans = new[]
textStyleOverrides = new[]
{
new FormattedTextStyleSpan(start, length, SelectionForegroundBrush),
new ValueSpan<TextRunProperties>(start, length,
new GenericTextRunProperties(typeface, FontSize,
foregroundBrush: SelectionForegroundBrush ?? Brushes.White))
};
}
if (PasswordChar != default(char) && !RevealPassword)
{
result = CreateTextLayoutInternal(_constraint, new string(PasswordChar, text?.Length ?? 0), typeface,
textStyleOverrides);
}
else
{
result = CreateTextLayoutInternal(_constraint, text, typeface, textStyleOverrides);
}
return result;
}
/// <summary>
/// Measures the control.
/// </summary>
/// <param name="availableSize">The available size for the control.</param>
/// <returns>The desired size.</returns>
private Size MeasureInternal(Size availableSize)
protected virtual void InvalidateTextLayout()
{
_textLayout = null;
InvalidateMeasure();
}
protected override Size MeasureOverride(Size availableSize)
{
if (availableSize != Size.Infinity)
{
_constraint = availableSize;
}
return TextLayout.Size;
}
private int CoerceCaretIndex(int value)
{
var text = Text;
var length = text?.Length ?? 0;
return Math.Max(0, Math.Min(length, value));
}
private void CaretTimerTick(object sender, EventArgs e)
{
_caretBlink = !_caretBlink;
InvalidateVisual();
}
public void MoveCaretToTextPosition(int textPosition, bool trailingEdge = false)
{
var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(textPosition);
var textLine = TextLayout.TextLines[lineIndex];
var characterHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(textPosition));
var nextCaretCharacterHit = textLine.GetNextCaretCharacterHit(characterHit);
if (nextCaretCharacterHit.FirstCharacterIndex <= textPosition)
{
characterHit = nextCaretCharacterHit;
}
if (textPosition == characterHit.FirstCharacterIndex + characterHit.TrailingLength)
{
UpdateCaret(characterHit);
}
else
{
UpdateCaret(trailingEdge ? characterHit : new CharacterHit(characterHit.FirstCharacterIndex));
}
}
public void MoveCaretToPoint(Point point)
{
if (!string.IsNullOrEmpty(Text))
var hit = TextLayout.HitTestPoint(point);
UpdateCaret(hit.CharacterHit);
_navigationPosition = _caretBounds.Position;
}
public void MoveCaretVertical(LogicalDirection direction = LogicalDirection.Forward)
{
var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(CaretIndex);
if (lineIndex < 0)
{
if (TextWrapping == TextWrapping.Wrap)
return;
}
var currentX = _navigationPosition.X;
var currentY = _navigationPosition.Y;
if (direction == LogicalDirection.Forward)
{
if (lineIndex + 1 > TextLayout.TextLines.Count - 1)
{
_constraint = new Size(availableSize.Width, double.PositiveInfinity);
return;
}
else
var textLine = TextLayout.TextLines[lineIndex];
currentY += textLine.Height;
}
else
{
if (lineIndex - 1 < 0)
{
_constraint = Size.Infinity;
return;
}
_formattedText = null;
var textLine = TextLayout.TextLines[--lineIndex];
return FormattedText.Bounds.Size;
currentY -= textLine.Height;
}
return new Size();
MoveCaretToPoint(new Point(currentX, currentY));
_navigationPosition = _navigationPosition.WithY(_caretBounds.Y);
}
protected override Size MeasureOverride(Size availableSize)
public void MoveCaretHorizontal(LogicalDirection direction = LogicalDirection.Forward)
{
var text = Text;
var characterHit = _lastCharacterHit;
var caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(caretIndex);
if (!string.IsNullOrEmpty(text))
if (lineIndex < 0)
{
return MeasureInternal(availableSize);
return;
}
if (direction == LogicalDirection.Forward)
{
while (lineIndex < TextLayout.TextLines.Count)
{
var textLine = TextLayout.TextLines[lineIndex];
characterHit = textLine.GetNextCaretCharacterHit(characterHit);
caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (caretIndex - textLine.TrailingWhitespaceLength == textLine.TextRange.End)
{
break;
}
if (caretIndex <= CaretIndex)
{
lineIndex++;
continue;
}
break;
}
}
else
{
return new FormattedText
while (lineIndex >= 0)
{
Text = "X",
Typeface = new Typeface(FontFamily, FontStyle, FontWeight),
FontSize = FontSize,
TextAlignment = TextAlignment,
Constraint = availableSize,
}.Bounds.Size;
var textLine = TextLayout.TextLines[lineIndex];
characterHit = textLine.GetPreviousCaretCharacterHit(characterHit);
caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (caretIndex >= CaretIndex)
{
lineIndex--;
continue;
}
break;
}
}
UpdateCaret(characterHit);
_navigationPosition = _caretBounds.Position;
}
private int CoerceCaretIndex(int value)
private void UpdateCaret(CharacterHit characterHit)
{
var text = Text;
var length = text?.Length ?? 0;
return Math.Max(0, Math.Min(length, value));
_lastCharacterHit = characterHit;
var caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(caretIndex);
var textLine = TextLayout.TextLines[lineIndex];
var distanceX = textLine.GetDistanceFromCharacterHit(characterHit);
var distanceY = 0d;
for (var i = 0; i < lineIndex; i++)
{
var currentLine = TextLayout.TextLines[i];
distanceY += currentLine.Height;
}
var caretBounds = new Rect(distanceX, distanceY, 0, textLine.Height);
if (caretBounds != _caretBounds)
{
_caretBounds = caretBounds;
CaretBoundsChanged?.Invoke(this, EventArgs.Empty);
}
CaretChanged();
SetAndRaise(CaretIndexProperty, ref _caretIndex, caretIndex);
}
private void CaretTimerTick(object sender, EventArgs e)
internal Rect GetCursorRectangle()
{
_caretBlink = !_caretBlink;
InvalidateVisual();
return _caretBounds;
}
internal Rect GetCursorRectangle()
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
var (p1, p2) = GetCaretPoints();
return new Rect(p1, p2);
base.OnAttachedToVisualTree(e);
_scrollViewer = this.FindAncestorOfType<ScrollViewer>();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_scrollViewer = null;
_caretTimer.Stop();
_caretTimer.Tick -= CaretTimerTick;
}
protected override void OnPropertyChanged<T>(AvaloniaPropertyChangedEventArgs<T> change)
{
base.OnPropertyChanged(change);
switch (change.Property.Name)
{
case nameof (TextBlock.Foreground):
case nameof (TextBlock.FontSize):
case nameof (TextBlock.FontStyle):
case nameof (TextBlock.FontWeight):
case nameof (TextBlock.FontFamily):
case nameof (Text):
case nameof (TextAlignment):
case nameof (TextWrapping):
case nameof (SelectionStart):
case nameof (SelectionEnd):
case nameof (SelectionForegroundBrush):
case nameof (PasswordChar):
case nameof (RevealPassword):
{
InvalidateTextLayout();
break;
}
}
}
}
}

52
src/Avalonia.Controls/TextBlock.cs

@ -131,21 +131,8 @@ namespace Avalonia.Controls
static TextBlock()
{
ClipToBoundsProperty.OverrideDefaultValue<TextBlock>(true);
AffectsRender<TextBlock>(BackgroundProperty, ForegroundProperty,
TextAlignmentProperty, TextDecorationsProperty);
AffectsMeasure<TextBlock>(FontSizeProperty, FontWeightProperty,
FontStyleProperty, TextWrappingProperty, FontFamilyProperty,
TextTrimmingProperty, TextProperty, PaddingProperty, LineHeightProperty, MaxLinesProperty);
Observable.Merge<AvaloniaPropertyChangedEventArgs>(TextProperty.Changed, ForegroundProperty.Changed,
TextAlignmentProperty.Changed, TextWrappingProperty.Changed,
TextTrimmingProperty.Changed, FontSizeProperty.Changed,
FontStyleProperty.Changed, FontWeightProperty.Changed,
FontFamilyProperty.Changed, TextDecorationsProperty.Changed,
PaddingProperty.Changed, MaxLinesProperty.Changed, LineHeightProperty.Changed
).AddClassHandler<TextBlock>((x, _) => x.InvalidateTextLayout());
AffectsRender<TextBlock>(BackgroundProperty, ForegroundProperty);
}
/// <summary>
@ -460,6 +447,7 @@ namespace Avalonia.Controls
TextWrapping,
TextTrimming,
TextDecorations,
FlowDirection,
constraint.Width,
constraint.Height,
maxLines: MaxLines,
@ -472,6 +460,8 @@ namespace Avalonia.Controls
protected void InvalidateTextLayout()
{
_textLayout = null;
InvalidateMeasure();
}
/// <summary>
@ -507,12 +497,40 @@ namespace Avalonia.Controls
base.OnAttachedToLogicalTree(e);
InvalidateTextLayout();
InvalidateMeasure();
}
private static bool IsValidMaxLines(int maxLines) => maxLines >= 0;
private static bool IsValidLineHeight(double lineHeight) => double.IsNaN(lineHeight) || lineHeight > 0;
protected override void OnPropertyChanged<T>(AvaloniaPropertyChangedEventArgs<T> change)
{
base.OnPropertyChanged(change);
switch (change.Property.Name)
{
case nameof (FontSize):
case nameof (FontWeight):
case nameof (FontStyle):
case nameof (FontFamily):
case nameof (TextWrapping):
case nameof (TextTrimming):
case nameof (TextAlignment):
case nameof (FlowDirection):
case nameof (Padding):
case nameof (LineHeight):
case nameof (MaxLines):
case nameof (Text):
case nameof (TextDecorations):
case nameof (Foreground):
{
InvalidateTextLayout();
break;
}
}
}
}
}

241
src/Avalonia.Controls/TextBox.cs

@ -14,6 +14,8 @@ using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Utilities;
using Avalonia.Controls.Metadata;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Controls
{
@ -250,6 +252,7 @@ namespace Avalonia.Controls
{
value = CoerceCaretIndex(value);
SetAndRaise(CaretIndexProperty, ref _caretIndex, value);
UndoRedoState state;
if (IsUndoEnabled && _undoRedoHelper.TryGetLastState(out state) && state.Text == Text)
_undoRedoHelper.UpdateLastState();
@ -301,9 +304,10 @@ namespace Avalonia.Controls
{
UpdateCommandStates();
}
if (SelectionStart == SelectionEnd)
{
CaretIndex = SelectionStart;
CaretIndex = SelectionEnd;
}
}
}
@ -319,10 +323,12 @@ namespace Avalonia.Controls
{
value = CoerceCaretIndex(value);
var changed = SetAndRaise(SelectionEndProperty, ref _selectionEnd, value);
if (changed)
{
UpdateCommandStates();
}
if (SelectionStart == SelectionEnd)
{
CaretIndex = SelectionEnd;
@ -345,6 +351,7 @@ namespace Avalonia.Controls
if (!_ignoreTextChanges)
{
var caretIndex = CaretIndex;
SelectionStart = CoerceCaretIndex(SelectionStart, value);
SelectionEnd = CoerceCaretIndex(SelectionEnd, value);
CaretIndex = CoerceCaretIndex(caretIndex, value);
@ -533,13 +540,24 @@ namespace Avalonia.Controls
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
_presenter = e.NameScope.Get<TextPresenter>("PART_TextPresenter");
_imClient.SetPresenter(_presenter, this);
if (IsFocused)
{
_presenter?.ShowCaret();
}
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_imClient.SetPresenter(null, null);
_presenter = null;
}
protected override void OnPropertyChanged<T>(AvaloniaPropertyChangedEventArgs<T> change)
{
base.OnPropertyChanged(change);
@ -631,9 +649,8 @@ namespace Avalonia.Controls
_selectedTextChangesMadeSinceLastUndoSnapshot++;
SnapshotUndoRedo(ignoreChangeCount: false);
string text = Text ?? string.Empty;
int caretIndex = CaretIndex;
int newLength = input.Length + text.Length - Math.Abs(SelectionStart - SelectionEnd);
var text = Text ?? string.Empty;
var newLength = input.Length + text.Length - Math.Abs(SelectionStart - SelectionEnd);
if (MaxLength > 0 && newLength > MaxLength)
{
@ -649,11 +666,11 @@ namespace Avalonia.Controls
try
{
DeleteSelection(false);
caretIndex = CaretIndex;
var caretIndex = CaretIndex;
text = Text ?? string.Empty;
SetTextInternal(text.Substring(0, caretIndex) + input + text.Substring(caretIndex));
CaretIndex += input.Length;
ClearSelection();
if (IsUndoEnabled)
{
_undoRedoHelper.DiscardRedo();
@ -663,6 +680,8 @@ namespace Avalonia.Controls
{
RaisePropertyChanged(TextProperty, oldText, _text);
}
CaretIndex = caretIndex + input.Length;
}
finally
{
@ -684,6 +703,7 @@ namespace Avalonia.Controls
public async void Cut()
{
var text = GetSelection();
if (string.IsNullOrEmpty(text))
{
return;
@ -703,6 +723,7 @@ namespace Avalonia.Controls
public async void Copy()
{
var text = GetSelection();
if (string.IsNullOrEmpty(text))
{
return;
@ -739,11 +760,11 @@ namespace Avalonia.Controls
protected override void OnKeyDown(KeyEventArgs e)
{
string text = Text ?? string.Empty;
int caretIndex = CaretIndex;
bool movement = false;
bool selection = false;
bool handled = false;
var text = Text ?? string.Empty;
var caretIndex = CaretIndex;
var movement = false;
var selection = false;
var handled = false;
var modifiers = e.KeyModifiers;
var keymap = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
@ -884,12 +905,20 @@ namespace Avalonia.Controls
break;
case Key.Up:
movement = MoveVertical(-1);
_presenter?.MoveCaretVertical(LogicalDirection.Backward);
if (caretIndex != CaretIndex)
{
movement = true;
}
selection = DetectSelection();
break;
case Key.Down:
movement = MoveVertical(1);
_presenter?.MoveCaretVertical(LogicalDirection.Forward);
if (caretIndex != CaretIndex)
{
movement = true;
}
selection = DetectSelection();
break;
@ -900,22 +929,30 @@ namespace Avalonia.Controls
SetSelectionForControlBackspace();
}
if (!DeleteSelection() && CaretIndex > 0)
if (!DeleteSelection() && caretIndex > 0)
{
var removedCharacters = 1;
// handle deleting /r/n
// you don't ever want to leave a dangling /r around. So, if deleting /n, check to see if
// a /r should also be deleted.
if (CaretIndex > 1 &&
text[CaretIndex - 1] == '\n' &&
text[CaretIndex - 2] == '\r')
var removedCharacters = 0;
// \r\n needs special treatment here
if (caretIndex - 1 > 0 && text[caretIndex - 1] == '\n' && text[caretIndex - 2] == '\r')
{
removedCharacters = 2;
}
else
{
Codepoint.ReadAt(text.AsMemory(), caretIndex - 1, out removedCharacters);
}
SetTextInternal(text.Substring(0, caretIndex - removedCharacters) +
if (removedCharacters == 0)
{
return;
}
var length = Math.Max(0, caretIndex - removedCharacters);
SetTextInternal(text.Substring(0, length) +
text.Substring(caretIndex));
CaretIndex -= removedCharacters;
CaretIndex = caretIndex - removedCharacters;
ClearSelection();
}
@ -931,20 +968,17 @@ namespace Avalonia.Controls
if (!DeleteSelection() && caretIndex < text.Length)
{
var removedCharacters = 1;
// handle deleting /r/n
// you don't ever want to leave a dangling /r around. So, if deleting /n, check to see if
// a /r should also be deleted.
if (CaretIndex < text.Length - 1 &&
text[caretIndex + 1] == '\n' &&
text[caretIndex] == '\r')
{
removedCharacters = 2;
}
_presenter.MoveCaretHorizontal();
var removedCharacters = Math.Max(0, _presenter.CaretIndex - caretIndex);
SetTextInternal(text.Substring(0, caretIndex) +
text.Substring(caretIndex + removedCharacters));
SetTextInternal(text.Substring(0, caretIndex) +
text.Substring(caretIndex + removedCharacters));
CaretIndex = caretIndex;
}
SnapshotUndoRedo();
handled = true;
break;
@ -1006,14 +1040,17 @@ namespace Avalonia.Controls
if (text != null && clickInfo.Properties.IsLeftButtonPressed && !(clickInfo.Pointer?.Captured is Border))
{
var point = e.GetPosition(_presenter);
var index = _presenter.GetCaretIndex(point);
var clickToSelect = index != CaretIndex && e.KeyModifiers.HasFlag(KeyModifiers.Shift);
if (!clickToSelect)
{
CaretIndex = index;
}
var oldIndex = CaretIndex;
_presenter.MoveCaretToPoint(point);
var index = _presenter.CaretIndex;
var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
SetAndRaise(CaretIndexProperty, ref _caretIndex, index);
#pragma warning disable CS0618 // Type or member is obsolete
switch (e.ClickCount)
#pragma warning restore CS0618 // Type or member is obsolete
@ -1021,8 +1058,8 @@ namespace Avalonia.Controls
case 1:
if (clickToSelect)
{
SelectionStart = Math.Min(index, CaretIndex);
SelectionEnd = Math.Max(index, CaretIndex);
SelectionStart = Math.Min(oldIndex, index);
SelectionEnd = Math.Max(oldIndex, index);
}
else
{
@ -1058,7 +1095,9 @@ namespace Avalonia.Controls
MathUtilities.Clamp(point.X, 0, Math.Max(_presenter.Bounds.Width - 1, 0)),
MathUtilities.Clamp(point.Y, 0, Math.Max(_presenter.Bounds.Height - 1, 0)));
CaretIndex = SelectionEnd = _presenter.GetCaretIndex(point);
_presenter?.MoveCaretToPoint(point);
SelectionEnd = _presenter.CaretIndex;
}
}
@ -1069,7 +1108,8 @@ namespace Avalonia.Controls
if (e.InitialPressMouseButton == MouseButton.Right)
{
var point = e.GetPosition(_presenter);
var caretIndex = _presenter.GetCaretIndex(point);
_presenter?.MoveCaretToPoint(point);
var caretIndex = _presenter.CaretIndex;
// see if mouse clicked inside current selection
// if it did not, we change the selection to where the user clicked
@ -1127,29 +1167,6 @@ namespace Avalonia.Controls
Text = string.Empty;
}
private int DeleteCharacter(int index)
{
var start = index + 1;
var text = Text;
var c = text[index];
var result = 1;
if (c == '\n' && index > 0 && text[index - 1] == '\r')
{
--index;
++result;
}
else if (c == '\r' && index < text.Length - 1 && text[index + 1] == '\n')
{
++start;
++result;
}
Text = text.Substring(0, index) + text.Substring(start);
return result;
}
private void MoveHorizontal(int direction, bool wholeWord, bool isSelecting)
{
var text = Text ?? string.Empty;
@ -1165,68 +1182,25 @@ namespace Avalonia.Controls
return;
}
var index = caretIndex + direction;
if (index < 0 || index > text.Length)
{
return;
}
else if (index == text.Length)
{
CaretIndex = index;
return;
}
var c = text[index];
if (direction > 0)
{
CaretIndex += (c == '\r' && index < text.Length - 1 && text[index + 1] == '\n') ? 2 : 1;
}
else
{
CaretIndex -= (c == '\n' && index > 0 && text[index - 1] == '\r') ? 2 : 1;
}
_presenter.MoveCaretHorizontal(direction > 0 ? LogicalDirection.Forward : LogicalDirection.Backward);
}
else
{
if (direction > 0)
{
CaretIndex += StringUtils.NextWord(text, caretIndex) - caretIndex;
var offset = StringUtils.NextWord(text, caretIndex) - caretIndex;
CaretIndex += offset;
}
else
{
CaretIndex += StringUtils.PreviousWord(text, caretIndex) - caretIndex;
var offset = StringUtils.PreviousWord(text, caretIndex) - caretIndex;
CaretIndex += offset;
}
}
}
private bool MoveVertical(int count)
{
if (_presenter is null)
{
return false;
}
var formattedText = _presenter.FormattedText;
var lines = formattedText.GetLines().ToList();
var caretIndex = CaretIndex;
var lineIndex = GetLine(caretIndex, lines) + count;
if (lineIndex >= 0 && lineIndex < lines.Count)
{
var line = lines[lineIndex];
var rect = formattedText.HitTestTextPosition(caretIndex);
var y = count < 0 ? rect.Y : rect.Bottom;
var point = new Point(rect.X, y + (count * (line.Height / 2)));
var hit = formattedText.HitTestPoint(point);
CaretIndex = hit.TextPosition + (hit.IsTrailing ? 1 : 0);
return true;
}
return false;
}
private void MoveHome(bool document)
{
if (_presenter is null)
@ -1243,17 +1217,17 @@ namespace Avalonia.Controls
}
else
{
var lines = _presenter.FormattedText.GetLines();
var lines = _presenter.TextLayout.TextLines;
var pos = 0;
foreach (var line in lines)
{
if (pos + line.Length > caretIndex || pos + line.Length == text.Length)
if (pos + line.TextRange.Length > caretIndex || pos + line.TextRange.Length == text.Length)
{
break;
}
pos += line.Length;
pos += line.TextRange.Length;
}
caretIndex = pos;
@ -1278,12 +1252,12 @@ namespace Avalonia.Controls
}
else
{
var lines = _presenter.FormattedText.GetLines();
var lines = _presenter.TextLayout.TextLines;
var pos = 0;
foreach (var line in lines)
{
pos += line.Length;
pos += line.TextRange.Length;
if (pos > caretIndex)
{
@ -1303,7 +1277,7 @@ namespace Avalonia.Controls
caretIndex = pos;
}
CaretIndex = caretIndex;
CaretIndex = text.Length;
}
/// <summary>
@ -1360,25 +1334,6 @@ namespace Avalonia.Controls
return text.Substring(start, end - start);
}
private int GetLine(int caretIndex, IList<FormattedTextLine> lines)
{
int pos = 0;
int i;
for (i = 0; i < lines.Count - 1; ++i)
{
var line = lines[i];
pos += line.Length;
if (pos > caretIndex)
{
break;
}
}
return i;
}
private void SetTextInternal(string value, bool raiseTextChanged = true)
{
if (raiseTextChanged)

24
src/Avalonia.Controls/TextBoxTextInputMethodClient.cs

@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using Avalonia.Controls.Presenters;
using Avalonia.Input;
using Avalonia.Input.TextInput;
@ -10,7 +11,7 @@ namespace Avalonia.Controls
{
private InputElement _parent;
private TextPresenter _presenter;
private IDisposable _subscription;
public Rect CursorRectangle
{
get
@ -20,11 +21,15 @@ namespace Avalonia.Controls
return default;
}
var transform = _presenter.TransformToVisual(_parent);
if (transform == null)
{
return default;
}
return _presenter.GetCursorRectangle().TransformToAABB(transform.Value);
var rect = _presenter.GetCursorRectangle().TransformToAABB(transform.Value);
return rect;
}
}
@ -40,20 +45,25 @@ namespace Avalonia.Controls
public string TextBeforeCursor => null;
public string TextAfterCursor => null;
private void OnCaretIndexChanged(int index) => CursorRectangleChanged?.Invoke(this, EventArgs.Empty);
private void OnCaretBoundsChanged(object sender, EventArgs e) => CursorRectangleChanged?.Invoke(this, EventArgs.Empty);
public void SetPresenter(TextPresenter presenter, InputElement parent)
{
_parent = parent;
_subscription?.Dispose();
_subscription = null;
if (_presenter != null)
{
_presenter.CaretBoundsChanged -= OnCaretBoundsChanged;
}
_presenter = presenter;
if (_presenter != null)
{
_subscription = _presenter.GetObservable(TextPresenter.CaretIndexProperty)
.Subscribe(OnCaretIndexChanged);
_presenter.CaretBoundsChanged += OnCaretBoundsChanged;
}
TextViewVisualChanged?.Invoke(this, EventArgs.Empty);
CursorRectangleChanged?.Invoke(this, EventArgs.Empty);
}

53
src/Avalonia.Controls/Utils/StringUtils.cs

@ -1,4 +1,5 @@
using System.Globalization;
using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Controls.Utils
{
@ -23,26 +24,38 @@ namespace Avalonia.Controls.Utils
return false;
}
var codepoint = new Codepoint(text[index]);
// A 'word' starts with an AlphaNumeric or some punctuation symbols immediately
// preceeded by lwsp.
if (index > 0 && !char.IsWhiteSpace(text[index - 1]))
if (index > 0)
{
return false;
var previousCodepoint = new Codepoint(text[index - 1]);
if (!previousCodepoint.IsWhiteSpace)
{
return false;
}
if (previousCodepoint.IsBreakChar)
{
return true;
}
}
switch (CharUnicodeInfo.GetUnicodeCategory(text[index]))
switch (codepoint.GeneralCategory)
{
case UnicodeCategory.LowercaseLetter:
case UnicodeCategory.TitlecaseLetter:
case UnicodeCategory.UppercaseLetter:
case UnicodeCategory.DecimalDigitNumber:
case UnicodeCategory.LetterNumber:
case UnicodeCategory.OtherNumber:
case UnicodeCategory.DashPunctuation:
case UnicodeCategory.InitialQuotePunctuation:
case UnicodeCategory.OpenPunctuation:
case UnicodeCategory.CurrencySymbol:
case UnicodeCategory.MathSymbol:
case GeneralCategory.LowercaseLetter:
case GeneralCategory.TitlecaseLetter:
case GeneralCategory.UppercaseLetter:
case GeneralCategory.DecimalNumber:
case GeneralCategory.LetterNumber:
case GeneralCategory.OtherNumber:
case GeneralCategory.DashPunctuation:
case GeneralCategory.InitialPunctuation:
case GeneralCategory.OpenPunctuation:
case GeneralCategory.CurrencySymbol:
case GeneralCategory.MathSymbol:
return true;
// TODO: How do you do this in .NET?
@ -56,6 +69,11 @@ namespace Avalonia.Controls.Utils
public static int PreviousWord(string text, int cursor)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int begin;
int i;
int cr;
@ -107,7 +125,12 @@ namespace Avalonia.Controls.Utils
cr = LineEnd(text, cursor);
if (cr < text.Length && text[cr] == '\r' && text[cr + 1] == '\n')
if (cursor >= text.Length)
{
return cursor;
}
if (cr < text.Length && text[cr] == '\r' && cr + 1 < text.Length && text[cr + 1] == '\n')
{
lf = cr + 1;
}

36
src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs

@ -27,11 +27,6 @@ namespace Avalonia.Headless
public PixelFormat DefaultPixelFormat => PixelFormat.Rgba8888;
public IFormattedTextImpl CreateFormattedText(string text, Typeface typeface, double fontSize, TextAlignment textAlignment, TextWrapping wrapping, Size constraint, IReadOnlyList<FormattedTextStyleSpan> spans)
{
return new HeadlessFormattedTextStub(text, constraint);
}
public IGeometryImpl CreateEllipseGeometry(Rect rect) => new HeadlessGeometryStub(rect);
public IGeometryImpl CreateLineGeometry(Point p1, Point p2)
@ -354,11 +349,6 @@ namespace Avalonia.Headless
}
public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text)
{
}
public IDrawingContextLayerImpl CreateLayer(Size size)
{
return new HeadlessBitmapStub(size, new Vector(96, 96));
@ -474,31 +464,5 @@ namespace Avalonia.Headless
return new HeadlessDrawingContextStub();
}
}
class HeadlessFormattedTextStub : IFormattedTextImpl
{
public HeadlessFormattedTextStub(string text, Size constraint)
{
Text = text;
Constraint = constraint;
Bounds = new Rect(Constraint.Constrain(new Size(50, 50)));
}
public Size Constraint { get; }
public Rect Bounds { get; }
public string Text { get; }
public IEnumerable<FormattedTextLine> GetLines()
{
return new[] { new FormattedTextLine(Text.Length, 10) };
}
public TextHitTestResult HitTestPoint(Point point) => new TextHitTestResult();
public Rect HitTestTextPosition(int index) => new Rect();
public IEnumerable<Rect> HitTestTextRange(int index, int length) => new Rect[length];
}
}
}

12
src/Avalonia.Headless/HeadlessPlatformStubs.cs

@ -133,14 +133,10 @@ namespace Avalonia.Headless
class HeadlessTextShaperStub : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
{
return new GlyphRun(new GlyphTypeface(typeface), 10,
new ReadOnlySlice<ushort>(new ushort[] { 1, 2, 3 }),
new ReadOnlySlice<double>(new double[] { 1, 2, 3 }),
new ReadOnlySlice<Vector>(new Vector[] { new Vector(1, 1), new Vector(2, 2), new Vector(3, 3) }),
text,
new ReadOnlySlice<ushort>(new ushort[] { 1, 2, 3 }));
public ShapedBuffer ShapeText(ReadOnlySlice<char> text, GlyphTypeface typeface, double fontRenderingEmSize,
CultureInfo culture, sbyte bidiLevel)
{
return new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel);
}
}

61
src/Avalonia.Visuals/ApiCompatBaseline.txt

@ -5,11 +5,50 @@ InterfacesShouldHaveSameMembers : Interface member 'public System.Threading.Task
MembersMustExist : Member 'public System.Threading.Tasks.Task Avalonia.Animation.IPageTransition.Start(Avalonia.Visual, Avalonia.Visual, System.Boolean)' does not exist in the implementation but it does exist in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.Threading.Tasks.Task Avalonia.Animation.IPageTransition.Start(Avalonia.Visual, Avalonia.Visual, System.Boolean, System.Threading.CancellationToken)' is present in the implementation but not in the contract.
MembersMustExist : Member 'public System.Threading.Tasks.Task Avalonia.Animation.PageSlide.Start(Avalonia.Visual, Avalonia.Visual, System.Boolean)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.DrawingContext.DrawText(Avalonia.Media.IBrush, Avalonia.Point, Avalonia.Media.FormattedText)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText..ctor()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText..ctor(Avalonia.Platform.IPlatformRenderInterface)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText..ctor(System.String, Avalonia.Media.Typeface, System.Double, Avalonia.Media.TextAlignment, Avalonia.Media.TextWrapping, Avalonia.Size)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Rect Avalonia.Media.FormattedText.Bounds.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Size Avalonia.Media.FormattedText.Constraint.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText.Constraint.set(Avalonia.Size)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public System.Double Avalonia.Media.FormattedText.FontSize.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText.FontSize.set(System.Double)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public System.Collections.Generic.IEnumerable<Avalonia.Media.FormattedTextLine> Avalonia.Media.FormattedText.GetLines()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Media.TextHitTestResult Avalonia.Media.FormattedText.HitTestPoint(Avalonia.Point)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Rect Avalonia.Media.FormattedText.HitTestTextPosition(System.Int32)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public System.Collections.Generic.IEnumerable<Avalonia.Rect> Avalonia.Media.FormattedText.HitTestTextRange(System.Int32, System.Int32)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Platform.IFormattedTextImpl Avalonia.Media.FormattedText.PlatformImpl.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public System.Collections.Generic.IReadOnlyList<Avalonia.Media.FormattedTextStyleSpan> Avalonia.Media.FormattedText.Spans.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText.Spans.set(System.Collections.Generic.IReadOnlyList<Avalonia.Media.FormattedTextStyleSpan>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public System.String Avalonia.Media.FormattedText.Text.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText.Text.set(System.String)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Media.TextWrapping Avalonia.Media.FormattedText.TextWrapping.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText.TextWrapping.set(Avalonia.Media.TextWrapping)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Media.Typeface Avalonia.Media.FormattedText.Typeface.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.FormattedText.Typeface.set(Avalonia.Media.Typeface)' does not exist in the implementation but it does exist in the contract.
TypesMustExist : Type 'Avalonia.Media.FormattedTextLine' does not exist in the implementation but it does exist in the contract.
TypesMustExist : Type 'Avalonia.Media.FormattedTextStyleSpan' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.GlyphRun..ctor()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.GlyphRun..ctor(Avalonia.Media.GlyphTypeface, System.Double, Avalonia.Utilities.ReadOnlySlice<System.UInt16>, Avalonia.Utilities.ReadOnlySlice<System.Double>, Avalonia.Utilities.ReadOnlySlice<Avalonia.Vector>, Avalonia.Utilities.ReadOnlySlice<System.Char>, Avalonia.Utilities.ReadOnlySlice<System.UInt16>, System.Int32)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice<System.Double> Avalonia.Media.GlyphRun.GlyphAdvances.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.GlyphRun.GlyphAdvances.set(Avalonia.Utilities.ReadOnlySlice<System.Double>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice<System.UInt16> Avalonia.Media.GlyphRun.GlyphClusters.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.GlyphRun.GlyphClusters.set(Avalonia.Utilities.ReadOnlySlice<System.UInt16>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice<System.UInt16> Avalonia.Media.GlyphRun.GlyphIndices.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.GlyphRun.GlyphIndices.set(Avalonia.Utilities.ReadOnlySlice<System.UInt16>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice<Avalonia.Vector> Avalonia.Media.GlyphRun.GlyphOffsets.get()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.GlyphRun.GlyphOffsets.set(Avalonia.Utilities.ReadOnlySlice<Avalonia.Vector>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.GlyphRun.GlyphTypeface.set(Avalonia.Media.GlyphTypeface)' does not exist in the implementation but it does exist in the contract.
CannotSealType : Type 'Avalonia.Media.Pen' is actually (has the sealed modifier) sealed in the implementation but not sealed in the contract.
MembersMustExist : Member 'protected void Avalonia.Media.Pen.AffectsRender<T>(Avalonia.AvaloniaProperty[])' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'protected void Avalonia.Media.Pen.RaiseInvalidated(System.EventArgs)' does not exist in the implementation but it does exist in the contract.
CannotSealType : Type 'Avalonia.Media.TextHitTestResult' is actually (has the sealed modifier) sealed in the implementation but not sealed in the contract.
TypeCannotChangeClassification : Type 'Avalonia.Media.TextHitTestResult' is a 'struct' in the implementation but is a 'class' in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextHitTestResult..ctor()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextHitTestResult.IsInside.set(System.Boolean)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextHitTestResult.IsTrailing.set(System.Boolean)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextHitTestResult.TextPosition.set(System.Int32)' does not exist in the implementation but it does exist in the contract.
TypeCannotChangeClassification : Type 'Avalonia.Media.Immutable.ImmutableSolidColorBrush' is a 'class' in the implementation but is a 'struct' in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.DrawableTextRun.Draw(Avalonia.Media.DrawingContext)' does not exist in the implementation but it does exist in the contract.
CannotAddAbstractMembers : Member 'public void Avalonia.Media.TextFormatting.DrawableTextRun.Draw(Avalonia.Media.DrawingContext, Avalonia.Point)' is abstract in the implementation but is missing in the contract.
@ -23,8 +62,14 @@ CannotMakeMemberNonVirtual : Member 'public System.Double Avalonia.Media.TextFor
CannotMakeMemberNonVirtual : Member 'public Avalonia.Media.TextAlignment Avalonia.Media.TextFormatting.GenericTextParagraphProperties.TextAlignment.get()' is non-virtual in the implementation but is virtual in the contract.
CannotMakeMemberNonVirtual : Member 'public Avalonia.Media.TextWrapping Avalonia.Media.TextFormatting.GenericTextParagraphProperties.TextWrapping.get()' is non-virtual in the implementation but is virtual in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.GenericTextRunProperties..ctor(Avalonia.Media.Typeface, System.Double, Avalonia.Media.TextDecorationCollection, Avalonia.Media.IBrush, Avalonia.Media.IBrush, System.Globalization.CultureInfo)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.ShapeableTextCharacters..ctor(Avalonia.Utilities.ReadOnlySlice<System.Char>, Avalonia.Media.TextFormatting.TextRunProperties)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.ShapedTextCharacters..ctor(Avalonia.Media.GlyphRun, Avalonia.Media.TextFormatting.TextRunProperties)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.ShapedTextCharacters.Draw(Avalonia.Media.DrawingContext)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Media.TextFormatting.ShapedTextCharacters.SplitTextCharactersResult Avalonia.Media.TextFormatting.ShapedTextCharacters.Split(System.Int32)' does not exist in the implementation but it does exist in the contract.
TypesMustExist : Type 'Avalonia.Media.TextFormatting.ShapedTextCharacters.SplitTextCharactersResult' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'protected System.Boolean Avalonia.Media.TextFormatting.TextCharacters.TryGetRunProperties(Avalonia.Utilities.ReadOnlySlice<System.Char>, Avalonia.Media.Typeface, Avalonia.Media.Typeface, System.Int32)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.TextEndOfLine..ctor()' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.TextLayout..ctor(System.String, Avalonia.Media.Typeface, System.Double, Avalonia.Media.IBrush, Avalonia.Media.TextAlignment, Avalonia.Media.TextWrapping, Avalonia.Media.TextTrimming, Avalonia.Media.TextDecorationCollection, System.Double, System.Double, System.Double, System.Int32, System.Collections.Generic.IReadOnlyList<Avalonia.Utilities.ValueSpan<Avalonia.Media.TextFormatting.TextRunProperties>>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.TextLayout.Draw(Avalonia.Media.DrawingContext)' does not exist in the implementation but it does exist in the contract.
CannotAddAbstractMembers : Member 'public System.Double Avalonia.Media.TextFormatting.TextLine.Baseline' is abstract in the implementation but is missing in the contract.
CannotAddAbstractMembers : Member 'public System.Double Avalonia.Media.TextFormatting.TextLine.Extent' is abstract in the implementation but is missing in the contract.
@ -53,6 +98,7 @@ CannotAddAbstractMembers : Member 'public System.Double Avalonia.Media.TextForma
CannotAddAbstractMembers : Member 'public System.Int32 Avalonia.Media.TextFormatting.TextLine.TrailingWhitespaceLength.get()' is abstract in the implementation but is missing in the contract.
CannotAddAbstractMembers : Member 'public System.Double Avalonia.Media.TextFormatting.TextLine.Width.get()' is abstract in the implementation but is missing in the contract.
CannotAddAbstractMembers : Member 'public System.Double Avalonia.Media.TextFormatting.TextLine.WidthIncludingTrailingWhitespace.get()' is abstract in the implementation but is missing in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.TextLineBreak..ctor(System.Collections.Generic.IReadOnlyList<Avalonia.Media.TextFormatting.ShapedTextCharacters>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Media.TextFormatting.TextLineMetrics..ctor(Avalonia.Size, System.Double, Avalonia.Media.TextFormatting.TextRange, System.Boolean)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Media.TextFormatting.TextLineMetrics Avalonia.Media.TextFormatting.TextLineMetrics.Create(System.Collections.Generic.IEnumerable<Avalonia.Media.TextFormatting.TextRun>, Avalonia.Media.TextFormatting.TextRange, System.Double, Avalonia.Media.TextFormatting.TextParagraphProperties)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Size Avalonia.Media.TextFormatting.TextLineMetrics.Size.get()' does not exist in the implementation but it does exist in the contract.
@ -65,15 +111,23 @@ CannotAddAbstractMembers : Member 'public Avalonia.Media.FlowDirection Avalonia.
CannotAddAbstractMembers : Member 'public System.Double Avalonia.Media.TextFormatting.TextParagraphProperties.Indent.get()' is abstract in the implementation but is missing in the contract.
CannotAddAbstractMembers : Member 'public Avalonia.Media.BaselineAlignment Avalonia.Media.TextFormatting.TextRunProperties.BaselineAlignment' is abstract in the implementation but is missing in the contract.
CannotAddAbstractMembers : Member 'public Avalonia.Media.BaselineAlignment Avalonia.Media.TextFormatting.TextRunProperties.BaselineAlignment.get()' is abstract in the implementation but is missing in the contract.
MembersMustExist : Member 'public Avalonia.Media.GlyphRun Avalonia.Media.TextFormatting.TextShaper.ShapeText(Avalonia.Utilities.ReadOnlySlice<System.Char>, Avalonia.Media.Typeface, System.Double, System.Globalization.CultureInfo)' does not exist in the implementation but it does exist in the contract.
TypesMustExist : Type 'Avalonia.Media.TextFormatting.Unicode.BiDiClass' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.Media.TextFormatting.Unicode.BiDiClass Avalonia.Media.TextFormatting.Unicode.Codepoint.BiDiClass.get()' does not exist in the implementation but it does exist in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IDrawingContextImpl.DrawEllipse(Avalonia.Media.IBrush, Avalonia.Media.IPen, Avalonia.Rect)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IDrawingContextImpl.DrawText(Avalonia.Media.IBrush, Avalonia.Point, Avalonia.Platform.IFormattedTextImpl)' is present in the contract but not in the implementation.
MembersMustExist : Member 'public void Avalonia.Platform.IDrawingContextImpl.DrawText(Avalonia.Media.IBrush, Avalonia.Point, Avalonia.Platform.IFormattedTextImpl)' does not exist in the implementation but it does exist in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IDrawingContextImpl.PopBitmapBlendMode()' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IDrawingContextImpl.PushBitmapBlendMode(Avalonia.Visuals.Media.Imaging.BitmapBlendingMode)' is present in the implementation but not in the contract.
TypesMustExist : Type 'Avalonia.Platform.IFormattedTextImpl' does not exist in the implementation but it does exist in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.Double Avalonia.Platform.IGeometryImpl.ContourLength' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.Double Avalonia.Platform.IGeometryImpl.ContourLength.get()' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.Boolean Avalonia.Platform.IGeometryImpl.TryGetPointAndTangentAtDistance(System.Double, Avalonia.Point, Avalonia.Point)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.Boolean Avalonia.Platform.IGeometryImpl.TryGetPointAtDistance(System.Double, Avalonia.Point)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.Boolean Avalonia.Platform.IGeometryImpl.TryGetSegment(System.Double, System.Double, System.Boolean, Avalonia.Platform.IGeometryImpl)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.IGeometryImpl Avalonia.Platform.IPlatformRenderInterface.CreateCombinedGeometry(Avalonia.Media.GeometryCombineMode, Avalonia.Media.Geometry, Avalonia.Media.Geometry)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.IFormattedTextImpl Avalonia.Platform.IPlatformRenderInterface.CreateFormattedText(System.String, Avalonia.Media.Typeface, System.Double, Avalonia.Media.TextAlignment, Avalonia.Media.TextWrapping, Avalonia.Size, System.Collections.Generic.IReadOnlyList<Avalonia.Media.FormattedTextStyleSpan>)' is present in the contract but not in the implementation.
MembersMustExist : Member 'public Avalonia.Platform.IFormattedTextImpl Avalonia.Platform.IPlatformRenderInterface.CreateFormattedText(System.String, Avalonia.Media.Typeface, System.Double, Avalonia.Media.TextAlignment, Avalonia.Media.TextWrapping, Avalonia.Size, System.Collections.Generic.IReadOnlyList<Avalonia.Media.FormattedTextStyleSpan>)' does not exist in the implementation but it does exist in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.IGeometryImpl Avalonia.Platform.IPlatformRenderInterface.CreateGeometryGroup(Avalonia.Media.FillRule, System.Collections.Generic.IReadOnlyList<Avalonia.Media.Geometry>)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.IGlyphRunImpl Avalonia.Platform.IPlatformRenderInterface.CreateGlyphRun(Avalonia.Media.GlyphRun)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.IGlyphRunImpl Avalonia.Platform.IPlatformRenderInterface.CreateGlyphRun(Avalonia.Media.GlyphRun, System.Double)' is present in the contract but not in the implementation.
@ -86,4 +140,9 @@ InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Size Avaloni
InterfacesShouldHaveSameMembers : Interface member 'public System.TimeSpan Avalonia.Platform.IPlatformSettings.TouchDoubleClickTime' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Size Avalonia.Platform.IPlatformSettings.TouchDoubleClickSize.get()' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public System.TimeSpan Avalonia.Platform.IPlatformSettings.TouchDoubleClickTime.get()' is present in the implementation but not in the contract.
Total Issues: 87
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Media.TextFormatting.ShapedBuffer Avalonia.Platform.ITextShaperImpl.ShapeText(Avalonia.Utilities.ReadOnlySlice<System.Char>, Avalonia.Media.GlyphTypeface, System.Double, System.Globalization.CultureInfo, System.SByte)' is present in the implementation but not in the contract.
InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Media.GlyphRun Avalonia.Platform.ITextShaperImpl.ShapeText(Avalonia.Utilities.ReadOnlySlice<System.Char>, Avalonia.Media.Typeface, System.Double, System.Globalization.CultureInfo)' is present in the contract but not in the implementation.
MembersMustExist : Member 'public Avalonia.Media.GlyphRun Avalonia.Platform.ITextShaperImpl.ShapeText(Avalonia.Utilities.ReadOnlySlice<System.Char>, Avalonia.Media.Typeface, System.Double, System.Globalization.CultureInfo)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'protected void Avalonia.Rendering.RendererBase.RenderFps(Avalonia.Platform.IDrawingContextImpl, Avalonia.Rect, System.Nullable<System.Int32>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Utilities.ReadOnlySlice<T>..ctor(System.ReadOnlyMemory<T>, System.Int32, System.Int32)' does not exist in the implementation but it does exist in the contract.
Total Issues: 146

BIN
src/Avalonia.Visuals/Assets/BiDi.trie

Binary file not shown.

BIN
src/Avalonia.Visuals/Assets/UnicodeData.trie

Binary file not shown.

10
src/Avalonia.Visuals/Media/DrawingContext.cs

@ -226,17 +226,13 @@ namespace Avalonia.Media
/// <summary>
/// Draws text.
/// </summary>
/// <param name="foreground">The foreground brush.</param>
/// <param name="origin">The upper-left corner of the text.</param>
/// <param name="text">The text.</param>
public void DrawText(IBrush foreground, Point origin, FormattedText text)
public void DrawText(FormattedText text, Point origin)
{
_ = text ?? throw new ArgumentNullException(nameof(text));
if (foreground != null)
{
PlatformImpl.DrawText(foreground, origin, text.PlatformImpl);
}
text.Draw(this, origin);
}
/// <summary>

1413
src/Avalonia.Visuals/Media/FormattedText.cs

File diff suppressed because it is too large

29
src/Avalonia.Visuals/Media/FormattedTextLine.cs

@ -1,29 +0,0 @@
namespace Avalonia.Media
{
/// <summary>
/// Stores information about a line of <see cref="FormattedText"/>.
/// </summary>
public class FormattedTextLine
{
/// <summary>
/// Initializes a new instance of the <see cref="FormattedTextLine"/> class.
/// </summary>
/// <param name="length">The length of the line, in characters.</param>
/// <param name="height">The height of the line, in pixels.</param>
public FormattedTextLine(int length, double height)
{
Length = length;
Height = height;
}
/// <summary>
/// Gets the length of the line, in characters.
/// </summary>
public int Length { get; }
/// <summary>
/// Gets the height of the line, in pixels.
/// </summary>
public double Height { get; }
}
}

39
src/Avalonia.Visuals/Media/FormattedTextStyleSpan.cs

@ -1,39 +0,0 @@
namespace Avalonia.Media
{
/// <summary>
/// Describes the formatting for a span of text in a <see cref="FormattedText"/> object.
/// </summary>
public class FormattedTextStyleSpan
{
/// <summary>
/// Initializes a new instance of the <see cref="FormattedTextStyleSpan"/> class.
/// </summary>
/// <param name="startIndex">The index of the first character in the span.</param>
/// <param name="length">The length of the span.</param>
/// <param name="foregroundBrush">The span's foreground brush.</param>
public FormattedTextStyleSpan(
int startIndex,
int length,
IBrush? foregroundBrush = null)
{
StartIndex = startIndex;
Length = length;
ForegroundBrush = foregroundBrush;
}
/// <summary>
/// Gets the index of the first character in the span.
/// </summary>
public int StartIndex { get; }
/// <summary>
/// Gets the length of the span.
/// </summary>
public int Length { get; }
/// <summary>
/// Gets the span's foreground brush.
/// </summary>
public IBrush? ForegroundBrush { get; }
}
}

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

@ -11,8 +11,8 @@ namespace Avalonia.Media
/// </summary>
public sealed class GlyphRun : IDisposable
{
private static readonly IComparer<ushort> s_ascendingComparer = Comparer<ushort>.Default;
private static readonly IComparer<ushort> s_descendingComparer = new ReverseComparer<ushort>();
private static readonly IComparer<int> s_ascendingComparer = Comparer<int>.Default;
private static readonly IComparer<int> s_descendingComparer = new ReverseComparer<int>();
private IGlyphRunImpl? _glyphRunImpl;
private GlyphTypeface _glyphTypeface;
@ -21,12 +21,13 @@ namespace Avalonia.Media
private Point? _baselineOrigin;
private GlyphRunMetrics? _glyphRunMetrics;
private ReadOnlySlice<ushort> _glyphIndices;
private ReadOnlySlice<double> _glyphAdvances;
private ReadOnlySlice<Vector> _glyphOffsets;
private ReadOnlySlice<ushort> _glyphClusters;
private ReadOnlySlice<char> _characters;
private IReadOnlyList<ushort> _glyphIndices;
private IReadOnlyList<double>? _glyphAdvances;
private IReadOnlyList<Vector>? _glyphOffsets;
private IReadOnlyList<int>? _glyphClusters;
/// <summary>
/// Initializes a new instance of the <see cref="GlyphRun"/> class by specifying properties of the class.
/// </summary>
@ -41,25 +42,25 @@ namespace Avalonia.Media
public GlyphRun(
GlyphTypeface glyphTypeface,
double fontRenderingEmSize,
ReadOnlySlice<ushort> glyphIndices,
ReadOnlySlice<double> glyphAdvances = default,
ReadOnlySlice<Vector> glyphOffsets = default,
ReadOnlySlice<char> characters = default,
ReadOnlySlice<ushort> glyphClusters = default,
ReadOnlySlice<char> characters,
IReadOnlyList<ushort> glyphIndices,
IReadOnlyList<double>? glyphAdvances = null,
IReadOnlyList<Vector>? glyphOffsets = null,
IReadOnlyList<int>? glyphClusters = null,
int biDiLevel = 0)
{
_glyphTypeface = glyphTypeface;
_glyphTypeface = glyphTypeface;
FontRenderingEmSize = fontRenderingEmSize;
GlyphIndices = glyphIndices;
Characters = characters;
_glyphIndices = glyphIndices;
GlyphAdvances = glyphAdvances;
GlyphOffsets = glyphOffsets;
Characters = characters;
GlyphClusters = glyphClusters;
BiDiLevel = biDiLevel;
@ -114,7 +115,7 @@ namespace Avalonia.Media
/// <summary>
/// Gets or sets an array of <see cref="ushort"/> values that represent the glyph indices in the rendering physical font.
/// </summary>
public ReadOnlySlice<ushort> GlyphIndices
public IReadOnlyList<ushort> GlyphIndices
{
get => _glyphIndices;
set => Set(ref _glyphIndices, value);
@ -123,7 +124,7 @@ namespace Avalonia.Media
/// <summary>
/// Gets or sets an array of <see cref="double"/> values that represent the advances corresponding to the glyph indices.
/// </summary>
public ReadOnlySlice<double> GlyphAdvances
public IReadOnlyList<double>? GlyphAdvances
{
get => _glyphAdvances;
set => Set(ref _glyphAdvances, value);
@ -132,7 +133,7 @@ namespace Avalonia.Media
/// <summary>
/// Gets or sets an array of <see cref="Vector"/> values representing the offsets of the glyphs in the <see cref="GlyphRun"/>.
/// </summary>
public ReadOnlySlice<Vector> GlyphOffsets
public IReadOnlyList<Vector>? GlyphOffsets
{
get => _glyphOffsets;
set => Set(ref _glyphOffsets, value);
@ -150,7 +151,7 @@ namespace Avalonia.Media
/// <summary>
/// Gets or sets a list of <see cref="int"/> values representing a mapping from character index to glyph index.
/// </summary>
public ReadOnlySlice<ushort> GlyphClusters
public IReadOnlyList<int>? GlyphClusters
{
get => _glyphClusters;
set => Set(ref _glyphClusters, value);
@ -202,34 +203,73 @@ namespace Avalonia.Media
/// </returns>
public double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
var distance = 0.0;
if (characterHit.FirstCharacterIndex + characterHit.TrailingLength > Characters.End)
if (IsLeftToRight)
{
return Size.Width;
}
var glyphIndex = FindGlyphIndex(characterHit.FirstCharacterIndex);
if (GlyphClusters != null)
{
if (characterIndex < GlyphClusters[0])
{
return 0;
}
if (!GlyphClusters.IsEmpty)
{
var currentCluster = GlyphClusters[glyphIndex];
if (characterIndex > GlyphClusters[GlyphClusters.Count - 1])
{
return Metrics.WidthIncludingTrailingWhitespace;
}
}
if (characterHit.TrailingLength > 0)
var glyphIndex = FindGlyphIndex(characterIndex);
if (GlyphClusters != null)
{
while (glyphIndex < GlyphClusters.Length && GlyphClusters[glyphIndex] == currentCluster)
var currentCluster = GlyphClusters[glyphIndex];
//Move to the end of the glyph cluster
if (characterHit.TrailingLength > 0)
{
glyphIndex++;
while (glyphIndex + 1 < GlyphClusters.Count && GlyphClusters[glyphIndex + 1] == currentCluster)
{
glyphIndex++;
}
}
}
}
for (var i = 0; i < glyphIndex; i++)
{
distance += GetGlyphAdvance(i);
for (var i = 0; i < glyphIndex; i++)
{
distance += GetGlyphAdvance(i, out _);
}
return distance;
}
else
{
//RightToLeft
var glyphIndex = FindGlyphIndex(characterIndex);
if (GlyphClusters != null)
{
if (characterIndex > GlyphClusters[0])
{
return 0;
}
if (characterIndex <= GlyphClusters[GlyphClusters.Count - 1])
{
return Size.Width;
}
}
for (var i = glyphIndex + 1; i < GlyphIndices.Count; i++)
{
distance += GetGlyphAdvance(i, out _);
}
return distance;
return Size.Width - distance;
}
}
/// <summary>
@ -243,50 +283,86 @@ namespace Avalonia.Media
/// </returns>
public CharacterHit GetCharacterHitFromDistance(double distance, out bool isInside)
{
var characterIndex = 0;
// Before
if (distance < 0)
if (distance <= 0)
{
isInside = false;
var firstCharacterHit = FindNearestCharacterHit(_glyphClusters[0], out _);
if(GlyphClusters != null)
{
characterIndex = GlyphClusters[characterIndex];
}
var firstCharacterHit = FindNearestCharacterHit(characterIndex, out _);
return IsLeftToRight ? new CharacterHit(firstCharacterHit.FirstCharacterIndex) : firstCharacterHit;
}
//After
if (distance > Size.Width)
if (distance >= Size.Width)
{
isInside = false;
var lastCharacterHit = FindNearestCharacterHit(_glyphClusters[_glyphClusters.Length - 1], out _);
characterIndex = GlyphIndices.Count - 1;
if(GlyphClusters != null)
{
characterIndex = GlyphClusters[characterIndex];
}
var lastCharacterHit = FindNearestCharacterHit(characterIndex, out _);
return IsLeftToRight ? lastCharacterHit : new CharacterHit(lastCharacterHit.FirstCharacterIndex);
}
//Within
var currentX = 0.0;
var index = 0;
var currentX = 0d;
for (; index < GlyphIndices.Length - Metrics.NewlineLength; index++)
if (IsLeftToRight)
{
var advance = GetGlyphAdvance(index);
if (currentX + advance >= distance)
for (var index = 0; index < GlyphIndices.Count; index++)
{
break;
}
var advance = GetGlyphAdvance(index, out var cluster);
characterIndex = cluster;
if (distance > currentX && distance <= currentX + advance)
{
break;
}
currentX += advance;
currentX += advance;
}
}
else
{
currentX = Size.Width;
var characterHit =
FindNearestCharacterHit(GlyphClusters.IsEmpty ? index : GlyphClusters[index], out var width);
for (var index = GlyphIndices.Count - 1; index >= 0; index--)
{
var advance = GetGlyphAdvance(index, out var cluster);
var offset = GetDistanceFromCharacterHit(new CharacterHit(characterHit.FirstCharacterIndex));
characterIndex = cluster;
if (currentX - advance < distance)
{
break;
}
currentX -= advance;
}
}
isInside = true;
var isTrailing = distance > offset + width / 2;
var characterHit = FindNearestCharacterHit(characterIndex, out var width);
var delta = width / 2;
var offset = IsLeftToRight ? distance - currentX : currentX - distance;
var isTrailing = offset > delta;
return isTrailing ? characterHit : new CharacterHit(characterHit.FirstCharacterIndex);
}
@ -303,13 +379,21 @@ namespace Avalonia.Media
{
if (characterHit.TrailingLength == 0)
{
return FindNearestCharacterHit(characterHit.FirstCharacterIndex, out _);
characterHit = FindNearestCharacterHit(characterHit.FirstCharacterIndex, out _);
var textPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
return textPosition > _characters.End ?
characterHit :
new CharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength);
}
var nextCharacterHit =
FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _);
return new CharacterHit(nextCharacterHit.FirstCharacterIndex);
return nextCharacterHit == characterHit ?
characterHit :
new CharacterHit(nextCharacterHit.FirstCharacterIndex);
}
/// <summary>
@ -327,9 +411,9 @@ namespace Avalonia.Media
return new CharacterHit(characterHit.FirstCharacterIndex);
}
return characterHit.FirstCharacterIndex == Characters.Start ?
new CharacterHit(Characters.Start) :
FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _);
var previousCharacterHit = FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _);
return new CharacterHit(previousCharacterHit.FirstCharacterIndex);
}
/// <summary>
@ -341,7 +425,7 @@ namespace Avalonia.Media
/// </returns>
public int FindGlyphIndex(int characterIndex)
{
if (GlyphClusters.IsEmpty)
if (GlyphClusters == null)
{
return characterIndex;
}
@ -353,16 +437,16 @@ namespace Avalonia.Media
return 0;
}
if (characterIndex > GlyphClusters[GlyphClusters.Length - 1])
if (characterIndex > GlyphClusters[GlyphClusters.Count - 1])
{
return _glyphClusters.Length - 1;
return GlyphClusters.Count - 1;
}
}
else
{
if (characterIndex < GlyphClusters[GlyphClusters.Length - 1])
if (characterIndex < GlyphClusters[GlyphClusters.Count - 1])
{
return _glyphClusters.Length - 1;
return GlyphClusters.Count - 1;
}
if (characterIndex > GlyphClusters[0])
@ -373,10 +457,10 @@ namespace Avalonia.Media
var comparer = IsLeftToRight ? s_ascendingComparer : s_descendingComparer;
var clusters = GlyphClusters.Buffer.Span;
var clusters = GlyphClusters;
// Find the start of the cluster at the character index.
var start = clusters.BinarySearch((ushort)characterIndex, comparer);
var start = clusters.BinarySearch(characterIndex, comparer);
// No cluster found.
if (start < 0)
@ -385,7 +469,7 @@ namespace Avalonia.Media
{
characterIndex--;
start = clusters.BinarySearch((ushort)characterIndex, comparer);
start = clusters.BinarySearch(characterIndex, comparer);
}
if (start < 0)
@ -403,7 +487,7 @@ namespace Avalonia.Media
}
else
{
while (start + 1 < clusters.Length && clusters[start + 1] == clusters[start])
while (start + 1 < clusters.Count && clusters[start + 1] == clusters[start])
{
start++;
}
@ -426,9 +510,9 @@ namespace Avalonia.Media
var start = FindGlyphIndex(index);
if (GlyphClusters.IsEmpty)
if (GlyphClusters == null)
{
width = GetGlyphAdvance(index);
width = GetGlyphAdvance(index, out _);
return new CharacterHit(start, 1);
}
@ -441,13 +525,13 @@ namespace Avalonia.Media
while (nextCluster == cluster)
{
width += GetGlyphAdvance(currentIndex);
width += GetGlyphAdvance(currentIndex, out _);
if (IsLeftToRight)
{
currentIndex++;
if (currentIndex == GlyphClusters.Length)
if (currentIndex == GlyphClusters.Count)
{
break;
}
@ -483,10 +567,13 @@ namespace Avalonia.Media
/// Gets a glyph's width.
/// </summary>
/// <param name="index">The glyph index.</param>
/// <param name="cluster">The current cluster.</param>
/// <returns>The glyph's width.</returns>
private double GetGlyphAdvance(int index)
private double GetGlyphAdvance(int index, out int cluster)
{
if (!GlyphAdvances.IsEmpty)
cluster = GlyphClusters != null ? GlyphClusters[index] : index;
if (GlyphAdvances != null)
{
return GlyphAdvances[index];
}
@ -508,42 +595,51 @@ namespace Avalonia.Media
private GlyphRunMetrics CreateGlyphRunMetrics()
{
var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale;
var widthIncludingTrailingWhitespace = 0d;
var width = 0d;
var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength);
for (var index = 0; index < _glyphIndices.Length; index++)
var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength, out var glyphCount);
for (var index = 0; index < GlyphIndices.Count; index++)
{
var advance = GetGlyphAdvance(index);
var advance = GetGlyphAdvance(index, out _);
widthIncludingTrailingWhitespace += advance;
}
if (index > _glyphIndices.Length - 1 - trailingWhitespaceLength)
var width = widthIncludingTrailingWhitespace;
if (IsLeftToRight)
{
for (var index = GlyphIndices.Count - glyphCount; index <GlyphIndices.Count; index++)
{
continue;
width -= GetGlyphAdvance(index, out _);
}
}
else
{
for (var index = 0; index < glyphCount; index++)
{
width -= GetGlyphAdvance(index, out _);
}
width += advance;
}
return new GlyphRunMetrics(width, widthIncludingTrailingWhitespace, trailingWhitespaceLength, newLineLength,
height);
}
private int GetTrailingWhitespaceLength(out int newLineLength)
private int GetTrailingWhitespaceLength(out int newLineLength, out int glyphCount)
{
glyphCount = 0;
newLineLength = 0;
if (_characters.IsEmpty)
if (Characters.IsEmpty)
{
return 0;
}
var trailingWhitespaceLength = 0;
if (_glyphClusters.IsEmpty)
if (GlyphClusters == null)
{
for (var i = _characters.Length - 1; i >= 0;)
{
@ -562,13 +658,14 @@ namespace Avalonia.Media
trailingWhitespaceLength++;
i -= count;
glyphCount++;
}
}
else
{
for (var i = _glyphClusters.Length - 1; i >= 0; i--)
for (var i = GlyphClusters.Count - 1; i >= 0; i--)
{
var cluster = _glyphClusters[i];
var cluster = GlyphClusters[i];
var codepointIndex = IsLeftToRight ? cluster - _characters.Start : _characters.End - cluster;
@ -585,6 +682,7 @@ namespace Avalonia.Media
}
trailingWhitespaceLength++;
glyphCount++;
}
}
@ -610,19 +708,19 @@ namespace Avalonia.Media
/// </summary>
private void Initialize()
{
if (GlyphIndices.Length == 0)
if (GlyphIndices == null)
{
throw new InvalidOperationException();
}
var glyphCount = GlyphIndices.Length;
var glyphCount = GlyphIndices.Count;
if (GlyphAdvances.Length > 0 && GlyphAdvances.Length != glyphCount)
if (GlyphAdvances != null && GlyphAdvances.Count > 0 && GlyphAdvances.Count != glyphCount)
{
throw new InvalidOperationException();
}
if (GlyphOffsets.Length > 0 && GlyphOffsets.Length != glyphCount)
if (GlyphOffsets != null && GlyphOffsets.Count > 0 && GlyphOffsets.Count != glyphCount)
{
throw new InvalidOperationException();
}

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

@ -154,11 +154,12 @@ namespace Avalonia.Media
/// Draws the <see cref="TextDecoration"/> at given origin.
/// </summary>
/// <param name="drawingContext">The drawing context.</param>
/// <param name="shapedTextCharacters">The shaped characters that are decorated.</param>
internal void Draw(DrawingContext drawingContext, ShapedTextCharacters shapedTextCharacters)
/// <param name="glyphRun">The decorated run.</param>
/// <param name="fontMetrics">The font metrics of the decorated run.</param>
/// <param name="defaultBrush">The default brush that is used to draw the decoration.</param>
internal void Draw(DrawingContext drawingContext, GlyphRun glyphRun, FontMetrics fontMetrics, IBrush defaultBrush)
{
var fontRenderingEmSize = shapedTextCharacters.Properties.FontRenderingEmSize;
var fontMetrics = shapedTextCharacters.FontMetrics;
var baselineOrigin = glyphRun.BaselineOrigin;
var thickness = StrokeThickness;
switch (StrokeThicknessUnit)
@ -176,7 +177,7 @@ namespace Avalonia.Media
break;
case TextDecorationUnit.FontRenderingEmSize:
thickness = fontRenderingEmSize * thickness;
thickness = fontMetrics.FontRenderingEmSize * thickness;
break;
}
@ -185,32 +186,30 @@ namespace Avalonia.Media
switch (Location)
{
case TextDecorationLocation.Baseline:
origin += shapedTextCharacters.GlyphRun.BaselineOrigin;
origin += glyphRun.BaselineOrigin;
break;
case TextDecorationLocation.Strikethrough:
origin += new Point(shapedTextCharacters.GlyphRun.BaselineOrigin.X,
shapedTextCharacters.GlyphRun.BaselineOrigin.Y + fontMetrics.StrikethroughPosition);
origin += new Point(baselineOrigin.X, baselineOrigin.Y + fontMetrics.StrikethroughPosition);
break;
case TextDecorationLocation.Underline:
origin += new Point(shapedTextCharacters.GlyphRun.BaselineOrigin.X,
shapedTextCharacters.GlyphRun.BaselineOrigin.Y + fontMetrics.UnderlinePosition);
origin += new Point(baselineOrigin.X, baselineOrigin.Y + fontMetrics.UnderlinePosition);
break;
}
switch (StrokeOffsetUnit)
{
case TextDecorationUnit.FontRenderingEmSize:
origin += new Point(0, StrokeOffset * fontRenderingEmSize);
origin += new Point(0, StrokeOffset * fontMetrics.FontRenderingEmSize);
break;
case TextDecorationUnit.Pixel:
origin += new Point(0, StrokeOffset);
break;
}
var pen = new Pen(Stroke ?? shapedTextCharacters.Properties.ForegroundBrush, thickness,
var pen = new Pen(Stroke ?? defaultBrush, thickness,
new DashStyle(StrokeDashArray, StrokeDashOffset), StrokeLineCap);
drawingContext.DrawLine(pen, origin, origin + new Point(shapedTextCharacters.Size.Width, 0));
drawingContext.DrawLine(pen, origin, origin + new Point(glyphRun.Size.Width, 0));
}
}
}

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

@ -5,11 +5,13 @@
/// </summary>
public readonly struct FontMetrics
{
public FontMetrics(Typeface typeface, double fontSize)
public FontMetrics(Typeface typeface, double fontRenderingEmSize)
{
var glyphTypeface = typeface.GlyphTypeface;
var scale = fontSize / glyphTypeface.DesignEmHeight;
var scale = fontRenderingEmSize / glyphTypeface.DesignEmHeight;
FontRenderingEmSize = fontRenderingEmSize;
Ascent = glyphTypeface.Ascent * scale;
@ -28,6 +30,8 @@
StrikethroughPosition = glyphTypeface.StrikethroughPosition * scale;
}
public double FontRenderingEmSize { get; }
/// <summary>
/// Gets the recommended distance above the baseline.
/// </summary>

24
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattedTextSource.cs → src/Avalonia.Visuals/Media/TextFormatting/FormattedTextSource.cs

@ -1,27 +1,27 @@
using System;
using System.Collections.Generic;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
namespace Avalonia.Media.TextFormatting
{
internal readonly struct FormattedTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextRunProperties _defaultProperties;
private readonly IReadOnlyList<ValueSpan<TextRunProperties>> _textModifier;
private readonly IReadOnlyList<ValueSpan<TextRunProperties>>? _textModifier;
public FormattedTextSource(ReadOnlySlice<char> text, TextRunProperties defaultProperties,
IReadOnlyList<ValueSpan<TextRunProperties>> textModifier)
IReadOnlyList<ValueSpan<TextRunProperties>>? textModifier)
{
_text = text;
_defaultProperties = defaultProperties;
_textModifier = textModifier;
}
public TextRun GetTextRun(int textSourceIndex)
public TextRun? GetTextRun(int textSourceIndex)
{
if (textSourceIndex > _text.End)
if (textSourceIndex > _text.Length)
{
return null;
}
@ -48,7 +48,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
/// The created text style run.
/// </returns>
private static ValueSpan<TextRunProperties> CreateTextStyleRun(ReadOnlySlice<char> text,
TextRunProperties defaultProperties, IReadOnlyList<ValueSpan<TextRunProperties>> textModifier)
TextRunProperties defaultProperties, IReadOnlyList<ValueSpan<TextRunProperties>>? textModifier)
{
if (textModifier == null || textModifier.Count == 0)
{
@ -69,7 +69,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textRange = new TextRange(propertiesOverride.Start, propertiesOverride.Length);
if (textRange.End < text.Start)
if (textRange.Start + textRange.Length < text.Start)
{
continue;
}
@ -90,7 +90,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
length += Math.Min(text.Length - length, textRange.Length);
length += Math.Max(0, textRange.Start + textRange.Length - text.Start);
if (hasOverride)
{
@ -110,6 +110,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
if (length == 0 && currentProperties != defaultProperties)
{
currentProperties = defaultProperties;
length = text.Length;
}
if (length != text.Length)
{
text = text.Take(length);

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

@ -7,11 +7,12 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
public sealed class ShapeableTextCharacters : TextRun
{
public ShapeableTextCharacters(ReadOnlySlice<char> text, TextRunProperties properties)
public ShapeableTextCharacters(ReadOnlySlice<char> text, TextRunProperties properties, sbyte biDiLevel)
{
TextSourceLength = text.Length;
Text = text;
Properties = properties;
BidiLevel = biDiLevel;
}
public override int TextSourceLength { get; }
@ -19,5 +20,7 @@ namespace Avalonia.Media.TextFormatting
public override ReadOnlySlice<char> Text { get; }
public override TextRunProperties Properties { get; }
public sbyte BidiLevel { get; }
}
}

293
src/Avalonia.Visuals/Media/TextFormatting/ShapedBuffer.cs

@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
public sealed class ShapedBuffer : IList<GlyphInfo>
{
private static readonly IComparer<GlyphInfo> s_clusterComparer = new CompareClusters();
public ShapedBuffer(ReadOnlySlice<char> text, int length, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel)
: this(text, new GlyphInfo[length], glyphTypeface, fontRenderingEmSize, bidiLevel)
{
}
internal ShapedBuffer(ReadOnlySlice<char> text, ArraySlice<GlyphInfo> glyphInfos, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel)
{
Text = text;
GlyphInfos = glyphInfos;
GlyphTypeface = glyphTypeface;
FontRenderingEmSize = fontRenderingEmSize;
BidiLevel = bidiLevel;
}
internal ArraySlice<GlyphInfo> GlyphInfos { get; }
public ReadOnlySlice<char> Text { get; }
public int Length => GlyphInfos.Length;
public GlyphTypeface GlyphTypeface { get; }
public double FontRenderingEmSize { get; }
public sbyte BidiLevel { get; }
public bool IsLeftToRight => (BidiLevel & 1) == 0;
public IReadOnlyList<ushort> GlyphIndices => new GlyphIndexList(GlyphInfos);
public IReadOnlyList<int> GlyphClusters => new GlyphClusterList(GlyphInfos);
public IReadOnlyList<double> GlyphAdvances => new GlyphAdvanceList(GlyphInfos);
public IReadOnlyList<Vector> GlyphOffsets => new GlyphOffsetList(GlyphInfos);
/// <summary>
/// Finds a glyph index for given character index.
/// </summary>
/// <param name="characterIndex">The character index.</param>
/// <returns>
/// The glyph index.
/// </returns>
private int FindGlyphIndex(int characterIndex)
{
if (characterIndex < GlyphInfos[0].GlyphCluster)
{
return 0;
}
if (characterIndex > GlyphInfos[GlyphInfos.Length - 1].GlyphCluster)
{
return GlyphInfos.Length - 1;
}
var comparer = s_clusterComparer;
var clusters = GlyphInfos.Span;
var searchValue = new GlyphInfo(0, characterIndex);
var start = clusters.BinarySearch(searchValue, comparer);
if (start < 0)
{
while (characterIndex > 0 && start < 0)
{
characterIndex--;
searchValue = new GlyphInfo(0, characterIndex);
start = clusters.BinarySearch(searchValue, comparer);
}
if (start < 0)
{
return -1;
}
}
while (start > 0 && clusters[start - 1].GlyphCluster == clusters[start].GlyphCluster)
{
start--;
}
return start;
}
/// <summary>
/// Splits the <see cref="TextRun"/> at specified length.
/// </summary>
/// <param name="length">The length.</param>
/// <returns>The split result.</returns>
internal SplitResult<ShapedBuffer> Split(int length)
{
var glyphCount = FindGlyphIndex(Text.Start + length);
if (Text.Length == length)
{
return new SplitResult<ShapedBuffer>(this, null);
}
if (Text.Length == glyphCount)
{
return new SplitResult<ShapedBuffer>(this, null);
}
var first = new ShapedBuffer(Text.Take(length), GlyphInfos.Take(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel);
var second = new ShapedBuffer(Text.Skip(length), GlyphInfos.Skip(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel);
return new SplitResult<ShapedBuffer>(first, second);
}
int ICollection<GlyphInfo>.Count => throw new NotImplementedException();
bool ICollection<GlyphInfo>.IsReadOnly => true;
public GlyphInfo this[int index]
{
get => GlyphInfos[index];
set => GlyphInfos[index] = value;
}
int IList<GlyphInfo>.IndexOf(GlyphInfo item)
{
throw new NotImplementedException();
}
void IList<GlyphInfo>.Insert(int index, GlyphInfo item)
{
throw new NotImplementedException();
}
void IList<GlyphInfo>.RemoveAt(int index)
{
throw new NotImplementedException();
}
void ICollection<GlyphInfo>.Add(GlyphInfo item)
{
throw new NotImplementedException();
}
void ICollection<GlyphInfo>.Clear()
{
throw new NotImplementedException();
}
bool ICollection<GlyphInfo>.Contains(GlyphInfo item)
{
throw new NotImplementedException();
}
void ICollection<GlyphInfo>.CopyTo(GlyphInfo[] array, int arrayIndex)
{
throw new NotImplementedException();
}
bool ICollection<GlyphInfo>.Remove(GlyphInfo item)
{
throw new NotImplementedException();
}
public IEnumerator<GlyphInfo> GetEnumerator() => GlyphInfos.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
private class CompareClusters : IComparer<GlyphInfo>
{
private static readonly Comparer<int> s_intClusterComparer = Comparer<int>.Default;
public int Compare(GlyphInfo x, GlyphInfo y)
{
return s_intClusterComparer.Compare(x.GlyphCluster, y.GlyphCluster);
}
}
private readonly struct GlyphAdvanceList : IReadOnlyList<double>
{
private readonly ArraySlice<GlyphInfo> _glyphInfos;
public GlyphAdvanceList(ArraySlice<GlyphInfo> glyphInfos)
{
_glyphInfos = glyphInfos;
}
public double this[int index] => _glyphInfos[index].GlyphAdvance;
public int Count => _glyphInfos.Length;
public IEnumerator<double> GetEnumerator() => new ImmutableReadOnlyListStructEnumerator<double>(this);
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
private readonly struct GlyphIndexList : IReadOnlyList<ushort>
{
private readonly ArraySlice<GlyphInfo> _glyphInfos;
public GlyphIndexList(ArraySlice<GlyphInfo> glyphInfos)
{
_glyphInfos = glyphInfos;
}
public ushort this[int index] => _glyphInfos[index].GlyphIndex;
public int Count => _glyphInfos.Length;
public IEnumerator<ushort> GetEnumerator() => new ImmutableReadOnlyListStructEnumerator<ushort>(this);
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
private readonly struct GlyphClusterList : IReadOnlyList<int>
{
private readonly ArraySlice<GlyphInfo> _glyphInfos;
public GlyphClusterList(ArraySlice<GlyphInfo> glyphInfos)
{
_glyphInfos = glyphInfos;
}
public int this[int index] => _glyphInfos[index].GlyphCluster;
public int Count => _glyphInfos.Length;
public IEnumerator<int> GetEnumerator() => new ImmutableReadOnlyListStructEnumerator<int>(this);
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
private readonly struct GlyphOffsetList : IReadOnlyList<Vector>
{
private readonly ArraySlice<GlyphInfo> _glyphInfos;
public GlyphOffsetList(ArraySlice<GlyphInfo> glyphInfos)
{
_glyphInfos = glyphInfos;
}
public Vector this[int index] => _glyphInfos[index].GlyphOffset;
public int Count => _glyphInfos.Length;
public IEnumerator<Vector> GetEnumerator() => new ImmutableReadOnlyListStructEnumerator<Vector>(this);
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
}
public readonly struct GlyphInfo
{
public GlyphInfo(ushort glyphIndex, int glyphCluster, double glyphAdvance = 0, Vector glyphOffset = default)
{
GlyphIndex = glyphIndex;
GlyphAdvance = glyphAdvance;
GlyphCluster = glyphCluster;
GlyphOffset = glyphOffset;
}
/// <summary>
/// Get the glyph index.
/// </summary>
public ushort GlyphIndex { get; }
/// <summary>
/// Get the glyph cluster.
/// </summary>
public int GlyphCluster { get; }
/// <summary>
/// Get the glyph advance.
/// </summary>
public double GlyphAdvance { get; }
/// <summary>
/// Get the glyph offset.
/// </summary>
public Vector GlyphOffset { get; }
}
}

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

@ -1,4 +1,6 @@
using Avalonia.Utilities;
using System;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
@ -7,15 +9,23 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
public sealed class ShapedTextCharacters : DrawableTextRun
{
public ShapedTextCharacters(GlyphRun glyphRun, TextRunProperties properties)
private GlyphRun? _glyphRun;
public ShapedTextCharacters(ShapedBuffer shapedBuffer, TextRunProperties properties)
{
Text = glyphRun.Characters;
ShapedBuffer = shapedBuffer;
Text = shapedBuffer.Text;
Properties = properties;
TextSourceLength = Text.Length;
FontMetrics = new FontMetrics(Properties.Typeface, Properties.FontRenderingEmSize);
GlyphRun = glyphRun;
FontMetrics = new FontMetrics(properties.Typeface, properties.FontRenderingEmSize);
}
public bool IsReversed { get; private set; }
public sbyte BidiLevel => ShapedBuffer.BidiLevel;
public ShapedBuffer ShapedBuffer { get; }
/// <inheritdoc/>
public override ReadOnlySlice<char> Text { get; }
@ -25,31 +35,29 @@ namespace Avalonia.Media.TextFormatting
/// <inheritdoc/>
public override int TextSourceLength { get; }
/// <inheritdoc/>
public FontMetrics FontMetrics { get; }
public override Size Size => GlyphRun.Size;
/// <summary>
/// Gets the font metrics.
/// </summary>
/// <value>
/// The font metrics.
/// </value>
public FontMetrics FontMetrics { get; }
public GlyphRun GlyphRun
{
get
{
if(_glyphRun is null)
{
_glyphRun = CreateGlyphRun();
}
/// <summary>
/// Gets the glyph run.
/// </summary>
/// <value>
/// The glyphs.
/// </value>
public GlyphRun GlyphRun { get; }
return _glyphRun;
}
}
/// <inheritdoc/>
public override void Draw(DrawingContext drawingContext, Point origin)
{
using (drawingContext.PushPreTransform(Matrix.CreateTranslation(origin)))
{
if (GlyphRun.GlyphIndices.Length == 0)
if (GlyphRun.GlyphIndices.Count == 0)
{
return;
}
@ -78,116 +86,88 @@ namespace Avalonia.Media.TextFormatting
foreach (var textDecoration in Properties.TextDecorations)
{
textDecoration.Draw(drawingContext, this);
textDecoration.Draw(drawingContext, GlyphRun, FontMetrics, Properties.ForegroundBrush);
}
}
}
internal void Reverse()
{
_glyphRun = null;
ShapedBuffer.GlyphInfos.Span.Reverse();
IsReversed = !IsReversed;
}
/// <summary>
/// Splits the <see cref="TextRun"/> at specified length.
/// Measures the number of characters that fit into available width.
/// </summary>
/// <param name="length">The length.</param>
/// <returns>The split result.</returns>
public SplitTextCharactersResult Split(int length)
/// <param name="availableWidth">The available width.</param>
/// <param name="length">The count of fitting characters.</param>
/// <returns>
/// <c>true</c> if characters fit into the available width; otherwise, <c>false</c>.
/// </returns>
internal bool TryMeasureCharacters(double availableWidth, out int length)
{
var glyphCount = GlyphRun.IsLeftToRight ?
GlyphRun.FindGlyphIndex(GlyphRun.Characters.Start + length) :
GlyphRun.FindGlyphIndex(GlyphRun.Characters.End - length);
length = 0;
var currentWidth = 0.0;
if (GlyphRun.Characters.Length == length)
for (var i = 0; i < ShapedBuffer.Length; i++)
{
return new SplitTextCharactersResult(this, null);
var advance = ShapedBuffer.GlyphAdvances[i];
if (currentWidth + advance > availableWidth)
{
break;
}
Codepoint.ReadAt(GlyphRun.Characters, length, out var count);
length += count;
currentWidth += advance;
}
if (GlyphRun.GlyphIndices.Length == glyphCount)
return length > 0;
}
internal SplitResult<ShapedTextCharacters> Split(int length)
{
if (IsReversed)
{
return new SplitTextCharactersResult(this, null);
Reverse();
}
if (GlyphRun.IsLeftToRight)
if(length == 0)
{
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),
GlyphRun.BiDiLevel);
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),
GlyphRun.BiDiLevel);
var secondTextRun = new ShapedTextCharacters(secondGlyphRun, Properties);
return new SplitTextCharactersResult(firstTextRun, secondTextRun);
throw new ArgumentOutOfRangeException(nameof(length), "length must be greater than zero.");
}
else
if(length == ShapedBuffer.Length)
{
var take = GlyphRun.GlyphIndices.Length - glyphCount;
var firstGlyphRun = new GlyphRun(
Properties.Typeface.GlyphTypeface,
Properties.FontRenderingEmSize,
GlyphRun.GlyphIndices.Take(take),
GlyphRun.GlyphAdvances.Take(take),
GlyphRun.GlyphOffsets.Take(take),
GlyphRun.Characters.Skip(length),
GlyphRun.GlyphClusters.Take(take),
GlyphRun.BiDiLevel);
var firstTextRun = new ShapedTextCharacters(firstGlyphRun, Properties);
var secondGlyphRun = new GlyphRun(
Properties.Typeface.GlyphTypeface,
Properties.FontRenderingEmSize,
GlyphRun.GlyphIndices.Skip(take),
GlyphRun.GlyphAdvances.Skip(take),
GlyphRun.GlyphOffsets.Skip(take),
GlyphRun.Characters.Take(length),
GlyphRun.GlyphClusters.Skip(take),
GlyphRun.BiDiLevel);
var secondTextRun = new ShapedTextCharacters(secondGlyphRun, Properties);
return new SplitTextCharactersResult(secondTextRun,firstTextRun);
return new SplitResult<ShapedTextCharacters>(this, null);
}
}
public readonly struct SplitTextCharactersResult
{
public SplitTextCharactersResult(ShapedTextCharacters first, ShapedTextCharacters? second)
{
First = first;
var splitBuffer = ShapedBuffer.Split(length);
Second = second;
}
var first = new ShapedTextCharacters(splitBuffer.First, Properties);
var second = new ShapedTextCharacters(splitBuffer.Second!, Properties);
/// <summary>
/// Gets the first text run.
/// </summary>
/// <value>
/// The first text run.
/// </value>
public ShapedTextCharacters First { get; }
/// <summary>
/// Gets the second text run.
/// </summary>
/// <value>
/// The second text run.
/// </value>
public ShapedTextCharacters? Second { get; }
return new SplitResult<ShapedTextCharacters>(first, second);
}
internal GlyphRun CreateGlyphRun()
{
return new GlyphRun(
ShapedBuffer.GlyphTypeface,
ShapedBuffer.FontRenderingEmSize,
Text,
ShapedBuffer.GlyphIndices,
ShapedBuffer.GlyphAdvances,
ShapedBuffer.GlyphOffsets,
ShapedBuffer.GlyphClusters,
BidiLevel);
}
}
}

28
src/Avalonia.Visuals/Media/TextFormatting/SplitResult.cs

@ -0,0 +1,28 @@
namespace Avalonia.Media.TextFormatting
{
internal readonly struct SplitResult<T>
{
public SplitResult(T first, T? second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first part.
/// </summary>
/// <value>
/// The first part.
/// </value>
public T First { get; }
/// <summary>
/// Gets the second part.
/// </summary>
/// <value>
/// The second part.
/// </value>
public T? Second { get; }
}
}

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

@ -37,15 +37,13 @@ namespace Avalonia.Media.TextFormatting
/// Gets a list of <see cref="ShapeableTextCharacters"/>.
/// </summary>
/// <returns>The shapeable text characters.</returns>
internal IList<ShapeableTextCharacters> GetShapeableCharacters()
internal IList<ShapeableTextCharacters> GetShapeableCharacters(ReadOnlySlice<char> runText, sbyte biDiLevel)
{
var shapeableCharacters = new List<ShapeableTextCharacters>(2);
var runText = Text;
while (!runText.IsEmpty)
{
var shapeableRun = CreateShapeableRun(runText, Properties);
var shapeableRun = CreateShapeableRun(runText, Properties, biDiLevel);
shapeableCharacters.Add(shapeableRun);
@ -60,34 +58,48 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
/// <param name="text">The text to create text runs from.</param>
/// <param name="defaultProperties">The default text run properties.</param>
/// <param name="biDiLevel">The bidi level of the run.</param>
/// <returns>A list of shapeable text runs.</returns>
private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice<char> text, TextRunProperties defaultProperties)
private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice<char> text, TextRunProperties defaultProperties, sbyte biDiLevel)
{
var defaultTypeface = defaultProperties.Typeface;
var currentTypeface = defaultTypeface;
if (TryGetRunProperties(text, currentTypeface, defaultTypeface, out var count))
if (TryGetShapeableLength(text, currentTypeface, defaultTypeface, out var count))
{
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel);
}
var codepoint = Codepoint.ReadAt(text, count, out _);
var codepoint = Codepoint.ReplacementCodepoint;
var codepointEnumerator = new CodepointEnumerator(text.Skip(count));
while (codepointEnumerator.MoveNext())
{
if (codepointEnumerator.Current.IsWhiteSpace)
{
continue;
}
codepoint = codepointEnumerator.Current;
break;
}
//ToDo: Fix FontFamily fallback
var matchFound =
FontManager.Current.TryMatchCharacter(codepoint, defaultTypeface.Style, defaultTypeface.Weight,
defaultTypeface.FontFamily, defaultProperties.CultureInfo, out currentTypeface);
if (matchFound && TryGetRunProperties(text, currentTypeface, defaultTypeface, out count))
if (matchFound && TextCharacters.TryGetShapeableLength(text, currentTypeface, defaultTypeface, out count))
{
//Fallback found
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel);
}
// no fallback found
@ -111,7 +123,7 @@ namespace Avalonia.Media.TextFormatting
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel);
}
/// <summary>
@ -120,22 +132,21 @@ namespace Avalonia.Media.TextFormatting
/// <param name="defaultTypeface"></param>
/// <param name="text"></param>
/// <param name="typeface">The typeface that is used to find matching characters.</param>
/// <param name="count"></param>
/// <param name="length"></param>
/// <returns></returns>
protected bool TryGetRunProperties(ReadOnlySlice<char> text, Typeface typeface, Typeface defaultTypeface,
out int count)
protected static bool TryGetShapeableLength(ReadOnlySlice<char> text, Typeface typeface, Typeface defaultTypeface,
out int length)
{
if (text.Length == 0)
{
count = 0;
length = 0;
return false;
}
var isFallback = typeface != defaultTypeface;
count = 0;
length = 0;
var script = Script.Unknown;
var direction = BiDiClass.LeftToRight;
var font = typeface.GlyphTypeface;
var defaultFont = defaultTypeface.GlyphTypeface;
@ -148,20 +159,9 @@ namespace Avalonia.Media.TextFormatting
var currentScript = currentGrapheme.FirstCodepoint.Script;
var currentDirection = currentGrapheme.FirstCodepoint.BiDiClass;
//// ToDo: Implement BiDi algorithm
//if (currentScript.HorizontalDirection != direction)
//{
// if (!UnicodeUtility.IsWhiteSpace(grapheme.FirstCodepoint))
// {
// break;
// }
//}
if (currentScript != script)
{
if (script is Script.Unknown)
if (script is Script.Unknown || currentScript != Script.Common && (script is Script.Common || script is Script.Inherited))
{
script = currentScript;
}
@ -175,7 +175,7 @@ namespace Avalonia.Media.TextFormatting
}
//Only handle non whitespace here
if (!currentGrapheme.FirstCodepoint.IsWhiteSpace)
if(!currentGrapheme.FirstCodepoint.IsWhiteSpace)
{
//Stop at the first glyph that is present in the default typeface.
if (isFallback && defaultFont.TryGetGlyph(currentGrapheme.FirstCodepoint, out _))
@ -195,16 +195,10 @@ namespace Avalonia.Media.TextFormatting
break;
}
if (direction == BiDiClass.RightToLeft && currentDirection == BiDiClass.CommonSeparator)
{
break;
}
count += currentGrapheme.Text.Length;
direction = currentDirection;
length += currentGrapheme.Text.Length;
}
return count > 0;
return length > 0;
}
}
}

9
src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs

@ -5,5 +5,14 @@
/// </summary>
public class TextEndOfParagraph : TextEndOfLine
{
public TextEndOfParagraph()
{
}
public TextEndOfParagraph(int textSourceLength) : base(textSourceLength)
{
}
}
}

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

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
@ -11,172 +13,56 @@ namespace Avalonia.Media.TextFormatting
TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null)
{
var textWrapping = paragraphProperties.TextWrapping;
FlowDirection flowDirection;
TextLineBreak? nextLineBreak = null;
List<ShapedTextCharacters> shapedRuns;
var textRuns = FetchTextRuns(textSource, firstTextSourceIndex, previousLineBreak,
out var nextLineBreak);
var textRuns = FetchTextRuns(textSource, firstTextSourceIndex,
out var textEndOfLine, out var textRange);
var textRange = GetTextRange(textRuns);
TextLine textLine;
switch (textWrapping)
{
case TextWrapping.NoWrap:
{
textLine = new TextLineImpl(textRuns, textRange, paragraphWidth, paragraphProperties,
nextLineBreak);
break;
}
case TextWrapping.WrapWithOverflow:
case TextWrapping.Wrap:
{
textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties,
nextLineBreak);
break;
}
default:
throw new ArgumentOutOfRangeException();
}
return textLine;
}
/// <summary>
/// Measures the number of characters that fit into available width.
/// </summary>
/// <param name="textCharacters">The text run.</param>
/// <param name="availableWidth">The available width.</param>
/// <param name="count">The count of fitting characters.</param>
/// <returns>
/// <c>true</c> if characters fit into the available width; otherwise, <c>false</c>.
/// </returns>
internal static bool TryMeasureCharacters(ShapedTextCharacters textCharacters, double availableWidth,
out int count)
{
var glyphRun = textCharacters.GlyphRun;
if (glyphRun.Size.Width < availableWidth)
if (previousLineBreak?.RemainingCharacters != null)
{
count = glyphRun.Characters.Length;
return true;
flowDirection = previousLineBreak.FlowDirection;
shapedRuns = previousLineBreak.RemainingCharacters.ToList();
nextLineBreak = previousLineBreak;
}
var glyphCount = 0;
var currentWidth = 0.0;
if (glyphRun.GlyphAdvances.IsEmpty)
else
{
var glyphTypeface = glyphRun.GlyphTypeface;
shapedRuns = ShapeTextRuns(textRuns, paragraphProperties.FlowDirection,out flowDirection);
if (glyphRun.IsLeftToRight)
if(nextLineBreak == null && textEndOfLine != null)
{
foreach (var glyph in glyphRun.GlyphIndices)
{
var advance = glyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
if (currentWidth + advance > availableWidth)
{
break;
}
currentWidth += advance;
glyphCount++;
}
nextLineBreak = new TextLineBreak(textEndOfLine, flowDirection);
}
else
{
for (var index = glyphRun.GlyphClusters.Length - 1; index > 0; index--)
{
var glyph = glyphRun.GlyphIndices[index];
var advance = glyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
if (currentWidth + advance > availableWidth)
{
break;
}
}
currentWidth += advance;
TextLineImpl textLine;
glyphCount++;
}
}
}
else
switch (textWrapping)
{
if (glyphRun.IsLeftToRight)
{
for (var index = 0; index < glyphRun.GlyphAdvances.Length; index++)
case TextWrapping.NoWrap:
{
var advance = glyphRun.GlyphAdvances[index];
if (currentWidth + advance > availableWidth)
{
break;
}
TextLineImpl.SortRuns(shapedRuns);
currentWidth += advance;
textLine = new TextLineImpl(shapedRuns, textRange, paragraphWidth, paragraphProperties,
flowDirection, nextLineBreak);
glyphCount++;
textLine.FinalizeLine();
break;
}
}
else
{
for (var index = glyphRun.GlyphAdvances.Length - 1; index > 0; index--)
case TextWrapping.WrapWithOverflow:
case TextWrapping.Wrap:
{
var advance = glyphRun.GlyphAdvances[index];
if (currentWidth + advance > availableWidth)
{
break;
}
currentWidth += advance;
glyphCount++;
textLine = PerformTextWrapping(shapedRuns, textRange, paragraphWidth, paragraphProperties,
flowDirection, nextLineBreak);
break;
}
}
}
if (glyphCount == 0)
{
count = 0;
return false;
}
if (glyphCount == glyphRun.GlyphIndices.Length)
{
count = glyphRun.Characters.Length;
return true;
}
if (glyphRun.GlyphClusters.IsEmpty)
{
count = glyphCount;
return true;
}
var firstCluster = glyphRun.GlyphClusters[0];
var lastCluster = glyphRun.GlyphClusters[glyphCount];
if (glyphRun.IsLeftToRight)
{
count = lastCluster - firstCluster;
}
else
{
count = firstCluster - lastCluster;
default:
throw new ArgumentOutOfRangeException(nameof(textWrapping));
}
return count > 0;
return textLine;
}
/// <summary>
@ -185,7 +71,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textRuns">The text run's.</param>
/// <param name="length">The length to split at.</param>
/// <returns>The split text runs.</returns>
internal static SplitTextRunsResult SplitTextRuns(List<ShapedTextCharacters> textRuns, int length)
internal static SplitResult<List<ShapedTextCharacters>> SplitShapedRuns(List<ShapedTextCharacters> textRuns, int length)
{
var currentLength = 0;
@ -193,13 +79,13 @@ namespace Avalonia.Media.TextFormatting
{
var currentRun = textRuns[i];
if (currentLength + currentRun.GlyphRun.Characters.Length <= length)
if (currentLength + currentRun.Text.Length < length)
{
currentLength += currentRun.GlyphRun.Characters.Length;
currentLength += currentRun.Text.Length;
continue;
}
var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i;
var firstCount = currentRun.Text.Length >= 1 ? i + 1 : i;
var first = new List<ShapedTextCharacters>(firstCount);
@ -213,14 +99,14 @@ namespace Avalonia.Media.TextFormatting
var secondCount = textRuns.Count - firstCount;
if (currentLength + currentRun.GlyphRun.Characters.Length == length)
if (currentLength + currentRun.Text.Length == length)
{
var second = new List<ShapedTextCharacters>(secondCount);
var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0;
var second = secondCount > 0 ? new List<ShapedTextCharacters>(secondCount) : null;
if (secondCount > 0)
if (second != null)
{
var offset = currentRun.Text.Length >= 1 ? 1 : 0;
for (var j = 0; j < secondCount; j++)
{
second.Add(textRuns[i + j + offset]);
@ -229,7 +115,7 @@ namespace Avalonia.Media.TextFormatting
first.Add(currentRun);
return new SplitTextRunsResult(first, second);
return new SplitResult<List<ShapedTextCharacters>>(first, second);
}
else
{
@ -243,120 +129,202 @@ namespace Avalonia.Media.TextFormatting
second.Add(split.Second!);
if (secondCount > 0)
for (var j = 1; j < secondCount; j++)
{
for (var j = 1; j < secondCount; j++)
{
second.Add(textRuns[i + j]);
}
second.Add(textRuns[i + j]);
}
return new SplitTextRunsResult(first, second);
return new SplitResult<List<ShapedTextCharacters>>(first, second);
}
}
return new SplitTextRunsResult(textRuns, null);
return new SplitResult<List<ShapedTextCharacters>>(textRuns, null);
}
/// <summary>
/// Fetches text runs.
/// Shape specified text runs with specified paragraph embedding.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="firstTextSourceIndex">The first text source index.</param>
/// <param name="previousLineBreak">Previous line break. Can be null.</param>
/// <param name="nextLineBreak">Next line break. Can be null.</param>
/// <param name="textRuns">The text runs to shape.</param>
/// <param name="flowDirection">The paragraph embedding level.</param>
/// <param name="resolvedFlowDirection">The resolved flow direction.</param>
/// <returns>
/// The formatted text runs.
/// A list of shaped text characters.
/// </returns>
private static List<ShapedTextCharacters> FetchTextRuns(ITextSource textSource,
int firstTextSourceIndex, TextLineBreak? previousLineBreak, out TextLineBreak? nextLineBreak)
private static List<ShapedTextCharacters> ShapeTextRuns(List<TextCharacters> textRuns,
FlowDirection flowDirection, out FlowDirection resolvedFlowDirection)
{
nextLineBreak = default;
var shapedTextCharacters = new List<ShapedTextCharacters>();
var currentLength = 0;
var biDiData = new BidiData((sbyte)flowDirection);
var textRuns = new List<ShapedTextCharacters>();
foreach (var textRun in textRuns)
{
biDiData.Append(textRun.Text);
}
if (previousLineBreak?.RemainingCharacters != null)
var biDi = BidiAlgorithm.Instance.Value!;
biDi.Process(biDiData);
var resolvedEmbeddingLevel = biDi.ResolveEmbeddingLevel(biDiData.Classes);
resolvedFlowDirection =
(resolvedEmbeddingLevel & 1) == 0 ? FlowDirection.LeftToRight : FlowDirection.RightToLeft;
foreach (var shapeableRuns in CoalesceLevels(textRuns, biDi.ResolvedLevels))
{
for (var index = 0; index < previousLineBreak.RemainingCharacters.Count; index++)
for (var index = 0; index < shapeableRuns.Count; index++)
{
var shapedCharacters = previousLineBreak.RemainingCharacters[index];
var currentRun = shapeableRuns[index];
var shapedBuffer = TextShaper.Current.ShapeText(currentRun.Text, currentRun.Properties.Typeface.GlyphTypeface,
currentRun.Properties.FontRenderingEmSize, currentRun.Properties.CultureInfo, currentRun.BidiLevel);
var shapedCharacters = new ShapedTextCharacters(shapedBuffer, currentRun.Properties);
shapedTextCharacters.Add(shapedCharacters);
}
}
return shapedTextCharacters;
}
/// <summary>
/// Coalesces ranges of the same bidi level to form <see cref="ShapeableTextCharacters"/>
/// </summary>
/// <param name="textCharacters">The text characters to form <see cref="ShapeableTextCharacters"/> from.</param>
/// <param name="levels">The bidi levels.</param>
/// <returns></returns>
private static IEnumerable<IList<ShapeableTextCharacters>> CoalesceLevels(
IReadOnlyList<TextCharacters> textCharacters,
ReadOnlySlice<sbyte> levels)
{
if (levels.Length == 0)
{
yield break;
}
var levelIndex = 0;
var runLevel = levels[0];
textRuns.Add(shapedCharacters);
TextCharacters? currentRun = null;
var runText = ReadOnlySlice<char>.Empty;
if (TryGetLineBreak(shapedCharacters, out var runLineBreak))
for (var i = 0; i < textCharacters.Count; i++)
{
var j = 0;
currentRun = textCharacters[i];
runText = currentRun.Text;
for (; j < runText.Length;)
{
Codepoint.ReadAt(runText, j, out var count);
if (levelIndex + 1 == levels.Length)
{
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap);
break;
}
if (splitResult.Second == null)
{
return splitResult.First;
}
levelIndex++;
j += count;
if (++index < previousLineBreak.RemainingCharacters.Count)
{
for (; index < previousLineBreak.RemainingCharacters.Count; index++)
{
splitResult.Second.Add(previousLineBreak.RemainingCharacters[index]);
}
}
if (j == runText.Length)
{
yield return currentRun.GetShapeableCharacters(runText.Take(j), runLevel);
nextLineBreak = new TextLineBreak(splitResult.Second);
runLevel = levels[levelIndex];
return splitResult.First;
continue;
}
if (levels[levelIndex] == runLevel)
{
continue;
}
currentLength += shapedCharacters.Text.Length;
// End of this run
yield return currentRun.GetShapeableCharacters(runText.Take(j), runLevel);
runText = runText.Skip(j);
j = 0;
// Move to next run
runLevel = levels[levelIndex];
}
}
firstTextSourceIndex += currentLength;
if (currentRun is null || runText.IsEmpty)
{
yield break;
}
yield return currentRun.GetShapeableCharacters(runText, runLevel);
}
/// <summary>
/// Fetches text runs.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="firstTextSourceIndex">The first text source index.</param>
/// <param name="endOfLine"></param>
/// <param name="textRange"></param>
/// <returns>
/// The formatted text runs.
/// </returns>
private static List<TextCharacters> FetchTextRuns(ITextSource textSource, int firstTextSourceIndex,
out TextEndOfLine? endOfLine, out TextRange textRange)
{
var length = 0;
endOfLine = null;
var textRuns = new List<TextCharacters>();
var textRunEnumerator = new TextRunEnumerator(textSource, firstTextSourceIndex);
while (textRunEnumerator.MoveNext())
{
var textRun = textRunEnumerator.Current!;
var textRun = textRunEnumerator.Current;
if(textRun == null)
{
break;
}
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);
if (TryGetLineBreak(textCharacters, out var runLineBreak))
{
var splitResult = new TextCharacters(textCharacters.Text.Take(runLineBreak.PositionWrap),
textCharacters.Properties);
var shapedCharacters = new ShapedTextCharacters(glyphRun, run.Properties);
textRuns.Add(splitResult);
textRuns.Add(shapedCharacters);
}
length += runLineBreak.PositionWrap;
break;
}
case TextEndOfLine textEndOfLine:
nextLineBreak = new TextLineBreak(textEndOfLine);
break;
}
textRange = new TextRange(firstTextSourceIndex, length);
if (TryGetLineBreak(textRun, out var runLineBreak))
{
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap);
return textRuns;
}
if (splitResult.Second != null)
{
nextLineBreak = new TextLineBreak(splitResult.Second);
}
textRuns.Add(textCharacters);
return splitResult.First;
break;
}
case TextEndOfLine textEndOfLine:
endOfLine = textEndOfLine;
break;
}
currentLength += textRun.Text.Length;
length += textRun.Text.Length;
}
textRange = new TextRange(firstTextSourceIndex, length);
return textRuns;
}
@ -380,49 +348,52 @@ namespace Avalonia.Media.TextFormatting
lineBreak = lineBreakEnumerator.Current;
if (lineBreak.PositionWrap >= textRun.Text.Length)
{
return true;
}
return true;
return lineBreak.PositionWrap >= textRun.Text.Length || true;
}
return false;
}
/// <summary>
/// Performs text wrapping returns a list of text lines.
/// </summary>
/// <param name="textRuns">The text run's.</param>
/// <param name="textRange">The text range that is covered by the text runs.</param>
/// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="paragraphProperties">The text paragraph properties.</param>
/// <param name="currentLineBreak">The current line break if the line was explicitly broken.</param>
/// <returns>The wrapped text line.</returns>
private static TextLine PerformTextWrapping(List<ShapedTextCharacters> textRuns, TextRange textRange,
double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak? currentLineBreak)
private static int MeasureLength(IReadOnlyList<ShapedTextCharacters> textRuns, TextRange textRange,
double paragraphWidth)
{
var availableWidth = paragraphWidth;
var currentWidth = 0.0;
var measuredLength = 0;
var lastCluster = textRange.Start;
foreach (var currentRun in textRuns)
{
if (currentWidth + currentRun.Size.Width > availableWidth)
for (var i = 0; i < currentRun.ShapedBuffer.Length; i++)
{
if (TryMeasureCharacters(currentRun, paragraphWidth - currentWidth, out var count))
var glyphInfo = currentRun.ShapedBuffer[i];
if (currentWidth + glyphInfo.GlyphAdvance > paragraphWidth)
{
measuredLength += count;
return lastCluster - textRange.Start;
}
break;
lastCluster = glyphInfo.GlyphCluster;
currentWidth += glyphInfo.GlyphAdvance;
}
}
currentWidth += currentRun.Size.Width;
return textRange.Length;
}
measuredLength += currentRun.Text.Length;
}
/// <summary>
/// Performs text wrapping returns a list of text lines.
/// </summary>
/// <param name="textRuns"></param>
/// <param name="textRange">The text range that is covered by the text runs.</param>
/// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="paragraphProperties">The text paragraph properties.</param>
/// <param name="flowDirection"></param>
/// <param name="currentLineBreak">The current line break if the line was explicitly broken.</param>
/// <returns>The wrapped text line.</returns>
private static TextLineImpl PerformTextWrapping(List<ShapedTextCharacters> textRuns, TextRange textRange,
double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection flowDirection,
TextLineBreak? currentLineBreak)
{
var measuredLength = MeasureLength(textRuns, textRange, paragraphWidth);
var currentLength = 0;
@ -430,154 +401,136 @@ namespace Avalonia.Media.TextFormatting
var currentPosition = 0;
if (measuredLength == 0 && paragraphProperties.TextWrapping != TextWrapping.WrapWithOverflow)
for (var index = 0; index < textRuns.Count; index++)
{
measuredLength = 1;
}
else
{
for (var index = 0; index < textRuns.Count; index++)
{
var currentRun = textRuns[index];
var currentRun = textRuns[index];
var lineBreaker = new LineBreakEnumerator(currentRun.Text);
var lineBreaker = new LineBreakEnumerator(currentRun.Text);
var breakFound = false;
var breakFound = false;
while (lineBreaker.MoveNext())
while (lineBreaker.MoveNext())
{
if (lineBreaker.Current.Required &&
currentLength + lineBreaker.Current.PositionMeasure <= measuredLength)
{
if (lineBreaker.Current.Required &&
currentLength + lineBreaker.Current.PositionMeasure <= measuredLength)
{
breakFound = true;
//Explicit break found
breakFound = true;
currentPosition = currentLength + lineBreaker.Current.PositionWrap;
currentPosition = currentLength + lineBreaker.Current.PositionWrap;
break;
}
break;
}
if ((paragraphProperties.TextWrapping != TextWrapping.WrapWithOverflow || lastWrapPosition != 0) &&
currentLength + lineBreaker.Current.PositionMeasure > measuredLength)
if (currentLength + lineBreaker.Current.PositionMeasure > measuredLength)
{
if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow)
{
if (lastWrapPosition > 0)
{
currentPosition = lastWrapPosition;
}
else
{
currentPosition = currentLength + measuredLength;
}
breakFound = true;
breakFound = true;
break;
}
if (currentLength + lineBreaker.Current.PositionWrap >= measuredLength)
{
currentPosition = currentLength + lineBreaker.Current.PositionWrap;
break;
}
if (index < textRuns.Count - 1 &&
lineBreaker.Current.PositionWrap == currentRun.Text.Length)
//Find next possible wrap position (overflow)
if (index < textRuns.Count - 1)
{
var nextRun = textRuns[index + 1];
if (lineBreaker.Current.PositionWrap != currentRun.Text.Length)
{
//We already found the next possible wrap position.
breakFound = true;
lineBreaker = new LineBreakEnumerator(nextRun.Text);
currentPosition = currentLength + lineBreaker.Current.PositionWrap;
break;
}
if (lineBreaker.MoveNext() &&
lineBreaker.Current.PositionMeasure == 0)
while (lineBreaker.MoveNext() && index < textRuns.Count)
{
currentPosition += lineBreaker.Current.PositionWrap;
if (lineBreaker.Current.PositionWrap != currentRun.Text.Length)
{
break;
}
index++;
if (index >= textRuns.Count)
{
break;
}
currentRun = textRuns[index];
lineBreaker = new LineBreakEnumerator(currentRun.Text);
}
}
else
{
currentPosition = currentLength + lineBreaker.Current.PositionWrap;
}
breakFound = true;
break;
}
lastWrapPosition = currentLength + lineBreaker.Current.PositionWrap;
}
//We overflowed so we use the last available wrap position.
currentPosition = lastWrapPosition == 0 ? measuredLength : lastWrapPosition;
if (!breakFound)
{
currentLength += currentRun.Text.Length;
breakFound = true;
continue;
break;
}
measuredLength = currentPosition;
break;
if (lineBreaker.Current.PositionMeasure != lineBreaker.Current.PositionWrap)
{
lastWrapPosition = currentLength + lineBreaker.Current.PositionWrap;
}
}
}
var splitResult = SplitTextRuns(textRuns, measuredLength);
textRange = new TextRange(textRange.Start, measuredLength);
if (!breakFound)
{
currentLength += currentRun.Text.Length;
var remainingCharacters = splitResult.Second;
continue;
}
var lineBreak = remainingCharacters?.Count > 0 ? new TextLineBreak(remainingCharacters) : null;
measuredLength = currentPosition;
if (lineBreak is null && currentLineBreak?.TextEndOfLine != null)
{
lineBreak = new TextLineBreak(currentLineBreak.TextEndOfLine);
break;
}
return new TextLineImpl(splitResult.First, textRange, paragraphWidth, paragraphProperties, lineBreak);
}
/// <summary>
/// Gets the text range that is covered by the text runs.
/// </summary>
/// <param name="textRuns">The text runs.</param>
/// <returns>The text range that is covered by the text runs.</returns>
private static TextRange GetTextRange(IReadOnlyList<TextRun> textRuns)
{
if (textRuns.Count == 0)
if (measuredLength == 0)
{
return new TextRange();
measuredLength = 1;
}
var firstTextRun = textRuns[0];
var splitResult = SplitShapedRuns(textRuns, measuredLength);
if (textRuns.Count == 1)
{
return new TextRange(firstTextRun.Text.Start, firstTextRun.Text.Length);
}
textRange = new TextRange(textRange.Start, measuredLength);
var start = firstTextRun.Text.Start;
var remainingCharacters = splitResult.Second;
var end = textRuns[textRuns.Count - 1].Text.End + 1;
var lineBreak = remainingCharacters?.Count > 0 ?
new TextLineBreak(currentLineBreak?.TextEndOfLine, flowDirection, remainingCharacters) :
null;
return new TextRange(start, end - start);
}
internal readonly struct SplitTextRunsResult
{
public SplitTextRunsResult(List<ShapedTextCharacters> first, List<ShapedTextCharacters>? second)
if (lineBreak is null && currentLineBreak?.TextEndOfLine != null)
{
First = first;
Second = second;
lineBreak = new TextLineBreak(currentLineBreak.TextEndOfLine, flowDirection);
}
/// <summary>
/// Gets the first text runs.
/// </summary>
/// <value>
/// The first text runs.
/// </value>
public List<ShapedTextCharacters> First { get; }
/// <summary>
/// Gets the second text runs.
/// </summary>
/// <value>
/// The second text runs.
/// </value>
public List<ShapedTextCharacters>? Second { get; }
TextLineImpl.SortRuns(splitResult.First);
var textLine = new TextLineImpl(splitResult.First, textRange, paragraphWidth, paragraphProperties, flowDirection,
lineBreak);
return textLine.FinalizeLine();
}
private struct TextRunEnumerator
@ -614,5 +567,28 @@ namespace Avalonia.Media.TextFormatting
return true;
}
}
/// <summary>
/// Creates a shaped symbol.
/// </summary>
/// <param name="textRun">The symbol run to shape.</param>
/// <param name="flowDirection">The flow direction.</param>
/// <returns>
/// The shaped symbol.
/// </returns>
internal static ShapedTextCharacters CreateSymbol(TextRun textRun, FlowDirection flowDirection)
{
var textShaper = TextShaper.Current;
var glyphTypeface = textRun.Properties!.Typeface.GlyphTypeface;
var fontRenderingEmSize = textRun.Properties.FontRenderingEmSize;
var cultureInfo = textRun.Properties.CultureInfo;
var shapedBuffer = textShaper.ShapeText(textRun.Text, glyphTypeface, fontRenderingEmSize, cultureInfo, (sbyte)flowDirection);
return new ShapedTextCharacters(shapedBuffer, textRun.Properties);
}
}
}

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

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Utilities;
@ -29,6 +28,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textWrapping">The text wrapping.</param>
/// <param name="textTrimming">The text trimming.</param>
/// <param name="textDecorations">The text decorations.</param>
/// <param name="flowDirection">The text flow direction.</param>
/// <param name="maxWidth">The maximum width.</param>
/// <param name="maxHeight">The maximum height.</param>
/// <param name="lineHeight">The height of each line of text.</param>
@ -43,6 +43,7 @@ namespace Avalonia.Media.TextFormatting
TextWrapping textWrapping = TextWrapping.NoWrap,
TextTrimming textTrimming = TextTrimming.None,
TextDecorationCollection? textDecorations = null,
FlowDirection flowDirection = FlowDirection.LeftToRight,
double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity,
double lineHeight = double.NaN,
@ -55,7 +56,7 @@ namespace Avalonia.Media.TextFormatting
_paragraphProperties =
CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping,
textDecorations, lineHeight);
textDecorations, flowDirection, lineHeight);
_textTrimming = textTrimming;
@ -69,7 +70,7 @@ namespace Avalonia.Media.TextFormatting
MaxLines = maxLines;
UpdateLayout();
TextLines = CreateTextLines();
}
/// <summary>
@ -190,29 +191,149 @@ namespace Avalonia.Media.TextFormatting
}
var result = new List<Rect>(TextLines.Count);
var currentY = 0d;
var currentPosition = 0;
foreach (var textLine in TextLines)
{
var currentX = textLine.Start;
if (textLine.TextRange.End < start)
//Current line isn't covered.
if (currentPosition + textLine.TextRange.Length <= start)
{
currentY += textLine.Height;
currentPosition += textLine.TextRange.Length;
continue;
}
if (start > textLine.TextRange.Start)
//The whole line is covered.
if (currentPosition >= start && start + length > currentPosition + textLine.TextRange.Length)
{
currentX += textLine.GetDistanceFromCharacterHit(new CharacterHit(start));
result.Add(new Rect(textLine.Start, currentY, textLine.WidthIncludingTrailingWhitespace, textLine.Height));
currentY += textLine.Height;
currentPosition += textLine.TextRange.Length;
continue;
}
var startX = textLine.Start;
//A portion of the line is covered.
for (var index = 0; index < textLine.TextRuns.Count; index++)
{
var currentRun = (ShapedTextCharacters)textLine.TextRuns[index];
if (index + 1 < textLine.TextRuns.Count)
{
if (currentRun.ShapedBuffer.IsLeftToRight)
{
if (currentRun.Text.End < start)
{
startX += currentRun.Size.Width;
currentPosition = currentRun.Text.End;
continue;
}
}
else
{
if (currentRun.Text.Start < start || currentRun.Text.End >= start + length)
{
startX += currentRun.Size.Width;
currentPosition = currentRun.Text.Start;
continue;
}
}
}
var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(
currentRun.ShapedBuffer.IsLeftToRight ?
new CharacterHit(start + length) :
new CharacterHit(start));
var endX = startX + endOffset;
var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(
currentRun.ShapedBuffer.IsLeftToRight ?
new CharacterHit(start) :
new CharacterHit(start + length));
startX += startOffset;
var characterHit = currentRun.GlyphRun.IsLeftToRight ?
currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _) :
currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
currentPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if(index + 1 < textLine.TextRuns.Count)
{
var nextRun = (ShapedTextCharacters)textLine.TextRuns[index + 1];
if (currentRun.ShapedBuffer.IsLeftToRight == nextRun.ShapedBuffer.IsLeftToRight)
{
endOffset = nextRun.GlyphRun.GetDistanceFromCharacterHit(
nextRun.ShapedBuffer.IsLeftToRight ?
new CharacterHit(start + length) :
new CharacterHit(start));
index++;
endX += endOffset;
currentRun = nextRun;
if (currentRun.ShapedBuffer.IsLeftToRight)
{
characterHit = nextRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var endX = textLine.GetDistanceFromCharacterHit(new CharacterHit(start + length));
currentPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
}
}
}
result.Add(new Rect(currentX, currentY, endX - currentX, textLine.Height));
if (endX < startX)
{
(endX, startX) = (startX, endX);
}
var width = endX - startX;
result.Add(new Rect(startX, currentY, width, textLine.Height));
if (currentRun.ShapedBuffer.IsLeftToRight)
{
if (currentPosition >= start + length)
{
break;
}
}
else
{
if (currentPosition <= start)
{
break;
}
}
if (!currentRun.ShapedBuffer.IsLeftToRight && currentPosition != currentRun.Text.Start)
{
endX += currentRun.GlyphRun.Size.Width - endOffset;
}
startX = endX;
}
if (currentPosition == start || currentPosition == start + length)
{
break;
}
if (textLine.TextRange.Start + textLine.TextRange.Length >= start + length)
{
break;
@ -256,6 +377,37 @@ namespace Avalonia.Media.TextFormatting
return GetHitTestResult(currentLine, characterHit, point);
}
public int GetLineIndexFromCharacterIndex(int charIndex)
{
if (charIndex < 0)
{
return -1;
}
if (charIndex > _text.Length - 1)
{
return TextLines.Count - 1;
}
for (var index = 0; index < TextLines.Count; index++)
{
var textLine = TextLines[index];
if (textLine.TextRange.End < charIndex)
{
continue;
}
if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.End)
{
return index;
}
}
return TextLines.Count - 1;
}
private TextHitTestResult GetHitTestResult(TextLine textLine, CharacterHit characterHit, Point point)
{
var (x, y) = point;
@ -274,7 +426,12 @@ namespace Avalonia.Media.TextFormatting
var isTrailing = lastTrailingIndex == textPosition && characterHit.TrailingLength > 0 ||
y > Size.Height;
return new TextHitTestResult { IsInside = isInside, IsTrailing = isTrailing, TextPosition = textPosition };
if (textPosition == textLine.TextRange.Start + textLine.TextRange.Length)
{
textPosition -= textLine.NewLineLength;
}
return new TextHitTestResult(characterHit, textPosition, isInside, isTrailing);
}
/// <summary>
@ -286,15 +443,16 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textAlignment">The text alignment.</param>
/// <param name="textWrapping">The text wrapping.</param>
/// <param name="textDecorations">The text decorations.</param>
/// <param name="flowDirection">The text flow direction.</param>
/// <param name="lineHeight">The height of each line of text.</param>
/// <returns></returns>
private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize,
IBrush foreground, TextAlignment textAlignment, TextWrapping textWrapping,
TextDecorationCollection? textDecorations, double lineHeight)
TextDecorationCollection? textDecorations, FlowDirection flowDirection, double lineHeight)
{
var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground);
return new GenericTextParagraphProperties(FlowDirection.LeftToRight, textAlignment, true, false,
return new GenericTextParagraphProperties(flowDirection, textAlignment, true, false,
textRunStyle, textWrapping, lineHeight, 0);
}
@ -306,8 +464,8 @@ namespace Avalonia.Media.TextFormatting
/// <param name="height">The current height.</param>
private static void UpdateBounds(TextLine textLine, ref double width, ref double height)
{
var lineWidth = textLine.Width + textLine.Start * 2;
var lineWidth = textLine.WidthIncludingTrailingWhitespace + textLine.Start * 2;
if (width < lineWidth)
{
width = lineWidth;
@ -322,97 +480,97 @@ namespace Avalonia.Media.TextFormatting
/// <returns>The empty text line.</returns>
private TextLine CreateEmptyTextLine(int startingIndex)
{
var flowDirection = _paragraphProperties.FlowDirection;
var properties = _paragraphProperties.DefaultTextRunProperties;
var glyphTypeface = properties.Typeface.GlyphTypeface;
var text = new ReadOnlySlice<char>(s_empty, startingIndex, 1);
var glyph = glyphTypeface.GetGlyph(s_empty[0]);
var glyphInfos = new[] { new GlyphInfo(glyph, startingIndex) };
var glyphRun = TextShaper.Current.ShapeText(new ReadOnlySlice<char>(s_empty, startingIndex, 1),
properties.Typeface, properties.FontRenderingEmSize, properties.CultureInfo);
var shapedBuffer = new ShapedBuffer(text, glyphInfos, glyphTypeface, properties.FontRenderingEmSize,
(sbyte)flowDirection);
var textRuns = new List<ShapedTextCharacters>
{
new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties)
};
var textRuns = new List<ShapedTextCharacters> { new ShapedTextCharacters(shapedBuffer, properties) };
var textRange = new TextRange(startingIndex, 1);
return new TextLineImpl(textRuns, textRange, MaxWidth, _paragraphProperties);
return new TextLineImpl(textRuns, textRange, MaxWidth, _paragraphProperties, flowDirection).FinalizeLine();
}
/// <summary>
/// Updates the layout and applies specified text style overrides.
/// </summary>
[MemberNotNull(nameof(TextLines))]
private void UpdateLayout()
private IReadOnlyList<TextLine> CreateTextLines()
{
if (_text.IsEmpty || MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight))
{
var textLine = CreateEmptyTextLine(0);
TextLines = new List<TextLine> { textLine };
Size = new Size(0, textLine.Height);
return new List<TextLine> { textLine };
}
else
{
var textLines = new List<TextLine>();
double width = 0.0, height = 0.0;
var textLines = new List<TextLine>();
var currentPosition = 0;
double width = 0.0, height = 0.0;
var textSource = new FormattedTextSource(_text,
_paragraphProperties.DefaultTextRunProperties, _textStyleOverrides);
var currentPosition = 0;
TextLine? previousLine = null;
var textSource = new FormattedTextSource(_text,
_paragraphProperties.DefaultTextRunProperties, _textStyleOverrides);
while (currentPosition < _text.Length)
{
var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth,
_paragraphProperties, previousLine?.TextLineBreak);
TextLine? previousLine = null;
currentPosition += textLine.TextRange.Length;
while (currentPosition < _text.Length)
{
var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth,
_paragraphProperties, previousLine?.TextLineBreak);
currentPosition += textLine.TextRange.Length;
if (textLines.Count > 0)
if (textLines.Count > 0)
{
if (textLines.Count == MaxLines || !double.IsPositiveInfinity(MaxHeight) &&
height + textLine.Height > MaxHeight)
{
if (textLines.Count == MaxLines || !double.IsPositiveInfinity(MaxHeight) &&
height + textLine.Height > MaxHeight)
if (previousLine?.TextLineBreak != null && _textTrimming != TextTrimming.None)
{
if (previousLine?.TextLineBreak != null && _textTrimming != TextTrimming.None)
{
var collapsedLine =
previousLine.Collapse(GetCollapsingProperties(MaxWidth));
var collapsedLine =
previousLine.Collapse(GetCollapsingProperties(MaxWidth));
textLines[textLines.Count - 1] = collapsedLine;
}
break;
textLines[textLines.Count - 1] = collapsedLine;
}
}
var hasOverflowed = textLine.HasOverflowed;
if (hasOverflowed && _textTrimming != TextTrimming.None)
{
textLine = textLine.Collapse(GetCollapsingProperties(MaxWidth));
break;
}
}
var hasOverflowed = textLine.HasOverflowed;
textLines.Add(textLine);
if (hasOverflowed && _textTrimming != TextTrimming.None)
{
textLine = textLine.Collapse(GetCollapsingProperties(MaxWidth));
}
UpdateBounds(textLine, ref width, ref height);
textLines.Add(textLine);
previousLine = textLine;
UpdateBounds(textLine, ref width, ref height);
if (currentPosition == _text.Length && textLine.NewLineLength > 0)
{
var emptyTextLine = CreateEmptyTextLine(currentPosition);
previousLine = textLine;
textLines.Add(emptyTextLine);
}
if (currentPosition != _text.Length || textLine.NewLineLength <= 0)
{
continue;
}
Size = new Size(width, height);
var emptyTextLine = CreateEmptyTextLine(currentPosition);
textLines.Add(emptyTextLine);
TextLines = textLines;
UpdateBounds(emptyTextLine, ref width, ref height);
}
Size = new Size(width, height);
return textLines;
}
/// <summary>
@ -431,241 +589,5 @@ namespace Avalonia.Media.TextFormatting
_ => throw new ArgumentOutOfRangeException(),
};
}
public int GetLineIndexFromCharacterIndex(int charIndex)
{
if (TextLines is null)
{
return -1;
}
if (charIndex < 0)
{
return -1;
}
if (charIndex > _text.Length - 1)
{
return TextLines.Count - 1;
}
for (var index = 0; index < TextLines.Count; index++)
{
var textLine = TextLines[index];
if (textLine.TextRange.End < charIndex)
{
continue;
}
if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.End)
{
return index;
}
}
return TextLines.Count - 1;
}
public int GetCharacterIndexFromPoint(Point point, bool snapToText)
{
if (TextLines is null)
{
return -1;
}
var (x, y) = point;
if (!snapToText && y > Size.Height)
{
return -1;
}
var currentY = 0d;
foreach (var textLine in TextLines)
{
if (currentY + textLine.Height <= y)
{
currentY += textLine.Height;
continue;
}
if (x > textLine.WidthIncludingTrailingWhitespace)
{
if (snapToText)
{
return textLine.TextRange.End;
}
return -1;
}
var characterHit = textLine.GetCharacterHitFromDistance(x);
return characterHit.FirstCharacterIndex + characterHit.TrailingLength;
}
return _text.Length;
}
public Rect GetRectFromCharacterIndex(int characterIndex, bool trailingEdge)
{
if (TextLines is null)
{
return Rect.Empty;
}
var distanceY = 0d;
var currentIndex = 0;
foreach (var textLine in TextLines)
{
if (currentIndex + textLine.TextRange.Length < characterIndex)
{
distanceY += textLine.Height;
currentIndex += textLine.TextRange.Length;
continue;
}
var characterHit = new CharacterHit(characterIndex);
while (characterHit.FirstCharacterIndex < characterIndex)
{
characterHit = textLine.GetNextCaretCharacterHit(characterHit);
}
var distanceX = textLine.GetDistanceFromCharacterHit(trailingEdge ?
characterHit :
new CharacterHit(characterHit.FirstCharacterIndex));
if (characterHit.TrailingLength > 0)
{
distanceX += 1;
}
return new Rect(distanceX, distanceY, 0, textLine.Height);
}
return Rect.Empty;
}
private readonly struct FormattedTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextRunProperties _defaultProperties;
private readonly IReadOnlyList<ValueSpan<TextRunProperties>>? _textModifier;
public FormattedTextSource(ReadOnlySlice<char> text, TextRunProperties defaultProperties,
IReadOnlyList<ValueSpan<TextRunProperties>>? textModifier)
{
_text = text;
_defaultProperties = defaultProperties;
_textModifier = textModifier;
}
public TextRun? GetTextRun(int textSourceIndex)
{
if (textSourceIndex > _text.Length)
{
return null;
}
var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty)
{
return new TextEndOfParagraph();
}
var textStyleRun = CreateTextStyleRun(runText, _defaultProperties, _textModifier);
return new TextCharacters(runText.Take(textStyleRun.Length), textStyleRun.Value);
}
/// <summary>
/// Creates a span of text run properties that has modifier applied.
/// </summary>
/// <param name="text">The text to create the properties for.</param>
/// <param name="defaultProperties">The default text properties.</param>
/// <param name="textModifier">The text properties modifier.</param>
/// <returns>
/// The created text style run.
/// </returns>
private static ValueSpan<TextRunProperties> CreateTextStyleRun(ReadOnlySlice<char> text,
TextRunProperties defaultProperties, IReadOnlyList<ValueSpan<TextRunProperties>>? textModifier)
{
if (textModifier == null || textModifier.Count == 0)
{
return new ValueSpan<TextRunProperties>(text.Start, text.Length, defaultProperties);
}
var currentProperties = defaultProperties;
var hasOverride = false;
var i = 0;
var length = 0;
for (; i < textModifier.Count; i++)
{
var propertiesOverride = textModifier[i];
var textRange = new TextRange(propertiesOverride.Start, propertiesOverride.Length);
if (textRange.End < text.Start)
{
continue;
}
if (textRange.Start > text.End)
{
length = text.Length;
break;
}
if (textRange.Start > text.Start)
{
if (propertiesOverride.Value != currentProperties)
{
length = Math.Min(Math.Abs(textRange.Start - text.Start), text.Length);
break;
}
}
length += Math.Min(text.Length - length, textRange.Length);
if (hasOverride)
{
continue;
}
hasOverride = true;
currentProperties = propertiesOverride.Value;
}
if (length < text.Length && i == textModifier.Count)
{
if (currentProperties == defaultProperties)
{
length = text.Length;
}
}
if (length != text.Length)
{
text = text.Take(length);
}
return new ValueSpan<TextRunProperties>(text.Start, length, currentProperties);
}
}
}
}

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

@ -191,27 +191,45 @@ namespace Avalonia.Media.TextFormatting
/// <summary>
/// Gets the text line offset x.
/// </summary>
/// <param name="lineWidth">The line width.</param>
/// <param name="width">The line width.</param>
/// <param name="widthIncludingTrailingWhitespace">The paragraph width including whitespace.</param>
/// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="textAlignment">The text alignment.</param>
/// <param name="flowDirection">The flow direction of the line.</param>
/// <returns>The paragraph offset.</returns>
internal static double GetParagraphOffsetX(double lineWidth, double paragraphWidth, TextAlignment textAlignment)
internal static double GetParagraphOffsetX(double width, double widthIncludingTrailingWhitespace,
double paragraphWidth, TextAlignment textAlignment, FlowDirection flowDirection)
{
if (double.IsPositiveInfinity(paragraphWidth))
{
return 0;
}
if (flowDirection == FlowDirection.LeftToRight)
{
switch (textAlignment)
{
case TextAlignment.Center:
return (paragraphWidth - width) / 2;
case TextAlignment.Right:
return paragraphWidth - widthIncludingTrailingWhitespace;
default:
return 0;
}
}
switch (textAlignment)
{
case TextAlignment.Center:
return (paragraphWidth - lineWidth) / 2;
return (paragraphWidth - width) / 2;
case TextAlignment.Right:
return paragraphWidth - lineWidth;
return 0;
default:
return 0.0f;
return paragraphWidth - widthIncludingTrailingWhitespace;
}
}
}

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

@ -4,21 +4,24 @@ namespace Avalonia.Media.TextFormatting
{
public class TextLineBreak
{
public TextLineBreak(TextEndOfLine textEndOfLine)
public TextLineBreak(TextEndOfLine? textEndOfLine = null, FlowDirection flowDirection = FlowDirection.LeftToRight,
IReadOnlyList<ShapedTextCharacters>? remainingCharacters = null)
{
TextEndOfLine = textEndOfLine;
}
public TextLineBreak(IReadOnlyList<ShapedTextCharacters> remainingCharacters)
{
FlowDirection = flowDirection;
RemainingCharacters = remainingCharacters;
}
/// <summary>
/// Get the
/// Get the end of line run.
/// </summary>
public TextEndOfLine? TextEndOfLine { get; }
/// <summary>
/// Get the flow direction for remaining characters.
/// </summary>
public FlowDirection FlowDirection { get; }
/// <summary>
/// Get the remaining shaped characters that were split up by the <see cref="TextFormatter"/> during the formatting process.
/// </summary>

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

@ -1,34 +1,40 @@
using System;
using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
internal class TextLineImpl : TextLine
{
private readonly List<ShapedTextCharacters> _shapedTextRuns;
private static readonly Comparer<int> s_compareStart = Comparer<int>.Default;
private static readonly Comparison<ShapedTextCharacters> s_compareLogicalOrder =
(a, b) => s_compareStart.Compare(a.Text.Start, b.Text.Start);
private readonly List<ShapedTextCharacters> _textRuns;
private readonly double _paragraphWidth;
private readonly TextParagraphProperties _paragraphProperties;
private readonly TextLineMetrics _textLineMetrics;
private TextLineMetrics _textLineMetrics;
private readonly FlowDirection _flowDirection;
public TextLineImpl(List<ShapedTextCharacters> textRuns, TextRange textRange, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak? lineBreak = null, bool hasCollapsed = false)
TextParagraphProperties paragraphProperties, FlowDirection flowDirection = FlowDirection.LeftToRight,
TextLineBreak? lineBreak = null, bool hasCollapsed = false)
{
TextRange = textRange;
TextLineBreak = lineBreak;
HasCollapsed = hasCollapsed;
_shapedTextRuns = textRuns;
_textRuns = textRuns;
_paragraphWidth = paragraphWidth;
_paragraphProperties = paragraphProperties;
_textLineMetrics = CreateLineMetrics();
_flowDirection = flowDirection;
}
/// <inheritdoc/>
public override IReadOnlyList<TextRun> TextRuns => _shapedTextRuns;
public override IReadOnlyList<TextRun> TextRuns => _textRuns;
/// <inheritdoc/>
public override TextRange TextRange { get; }
@ -80,12 +86,12 @@ namespace Avalonia.Media.TextFormatting
{
var (currentX, currentY) = lineOrigin;
foreach (var textRun in _shapedTextRuns)
foreach (var textRun in _textRuns)
{
var offsetY = Baseline - textRun.GlyphRun.BaselineOrigin.Y;
textRun.Draw(drawingContext, new Point(currentX, currentY + offsetY));
currentX += textRun.Size.Width;
}
}
@ -93,7 +99,7 @@ namespace Avalonia.Media.TextFormatting
/// <inheritdoc/>
public override TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList)
{
if (collapsingPropertiesList == null || collapsingPropertiesList.Length == 0)
if (collapsingPropertiesList.Length == 0)
{
return this;
}
@ -105,21 +111,22 @@ namespace Avalonia.Media.TextFormatting
var textRange = TextRange;
var collapsedLength = 0;
var shapedSymbol = CreateShapedSymbol(collapsingProperties.Symbol);
var shapedSymbol = TextFormatterImpl.CreateSymbol(collapsingProperties.Symbol, _paragraphProperties.FlowDirection);
var availableWidth = collapsingProperties.Width - shapedSymbol.Size.Width;
var availableWidth = collapsingProperties.Width - shapedSymbol.GlyphRun.Size.Width;
while (runIndex < _shapedTextRuns.Count)
while (runIndex < _textRuns.Count)
{
var currentRun = _shapedTextRuns[runIndex];
var currentRun = _textRuns[runIndex];
currentWidth += currentRun.Size.Width;
if (currentWidth > availableWidth)
{
if (TextFormatterImpl.TryMeasureCharacters(currentRun, availableWidth, out var measuredLength))
if (currentRun.TryMeasureCharacters(availableWidth, out var measuredLength))
{
if (collapsingProperties.Style == TextCollapsingStyle.TrailingWord && measuredLength < textRange.End)
if (collapsingProperties.Style == TextCollapsingStyle.TrailingWord &&
measuredLength < textRange.End)
{
var currentBreakPosition = 0;
@ -148,18 +155,22 @@ namespace Avalonia.Media.TextFormatting
collapsedLength += measuredLength;
var splitResult = TextFormatterImpl.SplitTextRuns(_shapedTextRuns, collapsedLength);
var splitResult = TextFormatterImpl.SplitShapedRuns(_textRuns, collapsedLength);
var shapedTextCharacters = new List<ShapedTextCharacters>(splitResult.First.Count + 1);
shapedTextCharacters.AddRange(splitResult.First);
SortRuns(shapedTextCharacters);
shapedTextCharacters.Add(shapedSymbol);
textRange = new TextRange(textRange.Start, collapsedLength);
return new TextLineImpl(shapedTextCharacters, textRange, _paragraphWidth, _paragraphProperties,
TextLineBreak, true);
var textLine = new TextLineImpl(shapedTextCharacters, textRange, _paragraphWidth, _paragraphProperties,
_flowDirection, TextLineBreak, true);
return textLine.FinalizeLine();
}
availableWidth -= currentRun.Size.Width;
@ -172,78 +183,23 @@ namespace Avalonia.Media.TextFormatting
return this;
}
private TextLineMetrics CreateLineMetrics()
{
var width = 0d;
var widthIncludingWhitespace = 0d;
var trailingWhitespaceLength = 0;
var newLineLength = 0;
var ascent = 0d;
var descent = 0d;
var lineGap = 0d;
for (var index = 0; index < _shapedTextRuns.Count; index++)
{
var textRun = _shapedTextRuns[index];
var fontMetrics =
new FontMetrics(textRun.Properties.Typeface, textRun.Properties.FontRenderingEmSize);
if (ascent > fontMetrics.Ascent)
{
ascent = fontMetrics.Ascent;
}
if (descent < fontMetrics.Descent)
{
descent = fontMetrics.Descent;
}
if (lineGap < fontMetrics.LineGap)
{
lineGap = fontMetrics.LineGap;
}
if (index == _shapedTextRuns.Count - 1)
{
width = widthIncludingWhitespace + textRun.GlyphRun.Metrics.Width;
widthIncludingWhitespace += textRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace;
trailingWhitespaceLength = textRun.GlyphRun.Metrics.TrailingWhitespaceLength;
newLineLength = textRun.GlyphRun.Metrics.NewlineLength;
}
else
{
widthIncludingWhitespace += textRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace;
}
}
var start = GetParagraphOffsetX(width, _paragraphWidth, _paragraphProperties.TextAlignment);
var lineHeight = _paragraphProperties.LineHeight;
var height = double.IsNaN(lineHeight) || MathUtilities.IsZero(lineHeight) ?
descent - ascent + lineGap :
lineHeight;
return new TextLineMetrics(widthIncludingWhitespace > _paragraphWidth, height, newLineLength, start,
-ascent, trailingWhitespaceLength, width, widthIncludingWhitespace);
}
/// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
distance -= Start;
if (distance < 0)
if (distance <= 0)
{
// hit happens before the line, return the first position
return new CharacterHit(TextRange.Start);
var firstRun = _textRuns[0];
return firstRun.GlyphRun.GetCharacterHitFromDistance(distance, out _);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
foreach (var run in _shapedTextRuns)
foreach (var run in _textRuns)
{
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _);
@ -263,27 +219,90 @@ namespace Avalonia.Media.TextFormatting
{
var characterIndex = characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0);
if (characterIndex > TextRange.End)
var currentDistance = Start;
GlyphRun? lastRun = null;
for (var index = 0; index < _textRuns.Count; index++)
{
if (NewLineLength > 0)
var textRun = _textRuns[index];
var currentRun = textRun.GlyphRun;
if (lastRun != null)
{
return Start + Width;
if (!lastRun.IsLeftToRight && currentRun.IsLeftToRight &&
currentRun.Characters.Start == characterHit.FirstCharacterIndex &&
characterHit.TrailingLength == 0)
{
return currentDistance;
}
}
return Start + WidthIncludingTrailingWhitespace;
}
var currentDistance = Start;
//Look for a hit in within the current run
if (characterIndex >= textRun.Text.Start && characterIndex <= textRun.Text.End)
{
var distance = currentRun.GetDistanceFromCharacterHit(characterHit);
foreach (var textRun in _shapedTextRuns)
{
if (characterIndex > textRun.Text.End)
return currentDistance + distance;
}
//Look at the left and right edge of the current run
if (currentRun.IsLeftToRight)
{
currentDistance += textRun.Size.Width;
if (lastRun == null || lastRun.IsLeftToRight)
{
if (characterIndex <= textRun.Text.Start)
{
return currentDistance;
}
}
else
{
if (characterIndex == textRun.Text.Start)
{
return currentDistance;
}
}
continue;
if (characterIndex == textRun.Text.Start + textRun.Text.Length && characterHit.TrailingLength > 0)
{
return currentDistance + currentRun.Size.Width;
}
}
else
{
if (characterIndex == textRun.Text.Start)
{
return currentDistance + currentRun.Size.Width;
}
var nextRun = index + 1 < _textRuns.Count ? _textRuns[index + 1] : null;
if (nextRun != null)
{
if (characterHit.FirstCharacterIndex == textRun.Text.End && nextRun.ShapedBuffer.IsLeftToRight)
{
return currentDistance;
}
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(characterIndex));
if (characterIndex > textRun.Text.End && nextRun.Text.End < textRun.Text.End)
{
return currentDistance;
}
}
else
{
if (characterIndex > textRun.Text.End)
{
return currentDistance;
}
}
}
//No hit hit found so we add the full width
currentDistance += currentRun.Size.Width;
lastRun = currentRun;
}
return currentDistance;
@ -297,18 +316,14 @@ namespace Avalonia.Media.TextFormatting
return nextCharacterHit;
}
if (characterHit.FirstCharacterIndex + characterHit.TrailingLength <= TextRange.Start + TextRange.Length)
{
return characterHit; // Can't move, we're after the last character
}
// Can't move, we're after the last character
var runIndex = GetRunIndexAtCharacterIndex(TextRange.End, LogicalDirection.Forward);
var runIndex = GetRunIndexAtCodepointIndex(TextRange.End);
var textRun = _shapedTextRuns[runIndex];
var textRun = _textRuns[runIndex];
characterHit = textRun.GlyphRun.GetNextCaretCharacterHit(characterHit);
return characterHit; // Can't move, we're after the last character
return characterHit;
}
/// <inheritdoc/>
@ -319,7 +334,7 @@ namespace Avalonia.Media.TextFormatting
return previousCharacterHit;
}
if (characterHit.FirstCharacterIndex < TextRange.Start)
if (characterHit.FirstCharacterIndex <= TextRange.Start)
{
characterHit = new CharacterHit(TextRange.Start);
}
@ -334,6 +349,170 @@ namespace Avalonia.Media.TextFormatting
return GetPreviousCaretCharacterHit(characterHit);
}
public static void SortRuns(List<ShapedTextCharacters> textRuns)
{
textRuns.Sort(s_compareLogicalOrder);
}
public TextLineImpl FinalizeLine()
{
BidiReorder();
_textLineMetrics = CreateLineMetrics();
return this;
}
private void BidiReorder()
{
// Build up the collection of ordered runs.
var run = _textRuns[0];
OrderedBidiRun orderedRun = new(run);
var current = orderedRun;
for (var i = 1; i < _textRuns.Count; i++)
{
run = _textRuns[i];
current.Next = new OrderedBidiRun(run);
current = current.Next;
}
// Reorder them into visual order.
orderedRun = LinearReOrder(orderedRun);
// Now perform a recursive reversal of each run.
// From the highest level found in the text to the lowest odd level on each line, including intermediate levels
// not actually present in the text, reverse any contiguous sequence of characters that are at that level or higher.
// https://unicode.org/reports/tr9/#L2
sbyte max = 0;
var min = sbyte.MaxValue;
for (var i = 0; i < _textRuns.Count; i++)
{
var level = _textRuns[i].BidiLevel;
if (level > max)
{
max = level;
}
if ((level & 1) != 0 && level < min)
{
min = level;
}
}
if (min > max)
{
min = max;
}
if (max == 0 || (min == max && (max & 1) == 0))
{
// Nothing to reverse.
return;
}
// Now apply the reversal and replace the original contents.
var minLevelToReverse = max;
while (minLevelToReverse >= min)
{
current = orderedRun;
while (current != null)
{
if (current.Level >= minLevelToReverse && current.Level % 2 != 0)
{
if (!current.Run.IsReversed)
{
current.Run.Reverse();
}
}
current = current.Next;
}
minLevelToReverse--;
}
_textRuns.Clear();
current = orderedRun;
while (current != null)
{
_textRuns.Add(current.Run);
current = current.Next;
}
}
/// <summary>
/// Reorders a series of runs from logical to visual order, returning the left most run.
/// <see href="https://github.com/fribidi/linear-reorder/blob/f2f872257d4d8b8e137fcf831f254d6d4db79d3c/linear-reorder.c"/>
/// </summary>
/// <param name="run">The ordered bidi run.</param>
/// <returns>The <see cref="OrderedBidiRun"/>.</returns>
private static OrderedBidiRun LinearReOrder(OrderedBidiRun? run)
{
BidiRange? range = null;
while (run != null)
{
var next = run.Next;
while (range != null && range.Level > run.Level
&& range.Previous != null && range.Previous.Level >= run.Level)
{
range = BidiRange.MergeWithPrevious(range);
}
if (range != null && range.Level >= run.Level)
{
// Attach run to the range.
if ((run.Level & 1) != 0)
{
// Odd, range goes to the right of run.
run.Next = range.Left;
range.Left = run;
}
else
{
// Even, range goes to the left of run.
range.Right!.Next = run;
range.Right = run;
}
range.Level = run.Level;
}
else
{
var r = new BidiRange();
r.Left = r.Right = run;
r.Level = run.Level;
r.Previous = range;
range = r;
}
run = next;
}
while (range?.Previous != null)
{
range = BidiRange.MergeWithPrevious(range);
}
// Terminate.
range!.Right!.Next = null;
return range.Left!;
}
/// <summary>
/// Tries to find the next character hit.
/// </summary>
@ -346,7 +525,7 @@ namespace Avalonia.Media.TextFormatting
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (codepointIndex > TextRange.End)
if (codepointIndex >= TextRange.End)
{
return false; // Cannot go forward anymore
}
@ -356,18 +535,26 @@ namespace Avalonia.Media.TextFormatting
codepointIndex = TextRange.Start;
}
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex);
var runIndex = GetRunIndexAtCharacterIndex(codepointIndex, LogicalDirection.Forward);
while (runIndex < _shapedTextRuns.Count)
while (runIndex < _textRuns.Count)
{
var run = _shapedTextRuns[runIndex];
var run = _textRuns[runIndex];
var foundCharacterHit =
run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _);
run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength,
out _);
var isAtEnd = foundCharacterHit.FirstCharacterIndex + foundCharacterHit.TrailingLength ==
TextRange.Length;
if (isAtEnd && !run.GlyphRun.IsLeftToRight)
{
nextCharacterHit = foundCharacterHit;
return true;
}
var characterIndex = codepointIndex - run.Text.Start;
if (characterIndex < 0 && characterHit.TrailingLength == 0)
@ -398,7 +585,9 @@ namespace Avalonia.Media.TextFormatting
/// <returns></returns>
private bool TryFindPreviousCharacterHit(CharacterHit characterHit, out CharacterHit previousCharacterHit)
{
if (characterHit.FirstCharacterIndex == TextRange.Start)
var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (characterIndex == TextRange.Start)
{
previousCharacterHit = new CharacterHit(TextRange.Start);
@ -407,26 +596,32 @@ namespace Avalonia.Media.TextFormatting
previousCharacterHit = characterHit;
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (codepointIndex < TextRange.Start)
if (characterIndex < TextRange.Start)
{
return false; // Cannot go backward anymore.
}
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex);
var runIndex = GetRunIndexAtCharacterIndex(characterIndex, LogicalDirection.Backward);
while (runIndex >= 0)
{
var run = _shapedTextRuns[runIndex];
var run = _textRuns[runIndex];
var foundCharacterHit = run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _);
var foundCharacterHit =
run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _);
if (foundCharacterHit.FirstCharacterIndex + foundCharacterHit.TrailingLength < characterIndex)
{
previousCharacterHit = foundCharacterHit;
return true;
}
previousCharacterHit = characterHit.TrailingLength != 0 ?
foundCharacterHit :
new CharacterHit(foundCharacterHit.FirstCharacterIndex);
if (previousCharacterHit.FirstCharacterIndex < characterHit.FirstCharacterIndex)
if (previousCharacterHit != characterHit)
{
return true;
}
@ -441,52 +636,203 @@ namespace Avalonia.Media.TextFormatting
/// Gets the run index of the specified codepoint index.
/// </summary>
/// <param name="codepointIndex">The codepoint index.</param>
/// <param name="direction">The logical direction.</param>
/// <returns>The text run index.</returns>
private int GetRunIndexAtCodepointIndex(int codepointIndex)
private int GetRunIndexAtCharacterIndex(int codepointIndex, LogicalDirection direction)
{
if (codepointIndex > TextRange.End)
{
return _shapedTextRuns.Count - 1;
}
var runIndex = 0;
ShapedTextCharacters? previousRun = null;
if (codepointIndex <= 0)
while (runIndex < _textRuns.Count)
{
return 0;
var currentRun = _textRuns[runIndex];
if (previousRun != null && !previousRun.ShapedBuffer.IsLeftToRight)
{
if (currentRun.ShapedBuffer.IsLeftToRight)
{
if (currentRun.Text.Start >= codepointIndex)
{
return --runIndex;
}
}
else
{
if (codepointIndex > currentRun.Text.Start + currentRun.Text.Length)
{
return --runIndex;
}
}
}
if (direction == LogicalDirection.Forward)
{
if (codepointIndex >= currentRun.Text.Start && codepointIndex <= currentRun.Text.End)
{
return runIndex;
}
}
else
{
if (codepointIndex > currentRun.Text.Start &&
codepointIndex <= currentRun.Text.Start + currentRun.Text.Length)
{
return runIndex;
}
}
if (runIndex + 1 < _textRuns.Count)
{
runIndex++;
previousRun = currentRun;
}
else
{
break;
}
}
var runIndex = 0;
return runIndex;
}
while (runIndex < _shapedTextRuns.Count)
private TextLineMetrics CreateLineMetrics()
{
var width = 0d;
var widthIncludingWhitespace = 0d;
var trailingWhitespaceLength = 0;
var newLineLength = 0;
var ascent = 0d;
var descent = 0d;
var lineGap = 0d;
var fontRenderingEmSize = 0d;
for (var index = 0; index < _textRuns.Count; index++)
{
var run = _shapedTextRuns[runIndex];
var textRun = _textRuns[index];
if (run.Text.End >= codepointIndex)
var fontMetrics =
new FontMetrics(textRun.Properties.Typeface, textRun.Properties.FontRenderingEmSize);
if (fontRenderingEmSize < textRun.Properties.FontRenderingEmSize)
{
return runIndex;
fontRenderingEmSize = textRun.Properties.FontRenderingEmSize;
if (ascent > fontMetrics.Ascent)
{
ascent = fontMetrics.Ascent;
}
if (descent < fontMetrics.Descent)
{
descent = fontMetrics.Descent;
}
if (lineGap < fontMetrics.LineGap)
{
lineGap = fontMetrics.LineGap;
}
}
runIndex++;
switch (_paragraphProperties.FlowDirection)
{
case FlowDirection.LeftToRight:
{
if (index == _textRuns.Count - 1)
{
width = widthIncludingWhitespace + textRun.GlyphRun.Metrics.Width;
trailingWhitespaceLength = textRun.GlyphRun.Metrics.TrailingWhitespaceLength;
newLineLength = textRun.GlyphRun.Metrics.NewlineLength;
}
break;
}
case FlowDirection.RightToLeft:
{
if (index == _textRuns.Count - 1)
{
var firstRun = _textRuns[0];
var offset = firstRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace -
firstRun.GlyphRun.Metrics.Width;
width = widthIncludingWhitespace +
textRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace - offset;
trailingWhitespaceLength = firstRun.GlyphRun.Metrics.TrailingWhitespaceLength;
newLineLength = firstRun.GlyphRun.Metrics.NewlineLength;
}
break;
}
}
widthIncludingWhitespace += textRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace;
}
return runIndex;
var start = GetParagraphOffsetX(width, widthIncludingWhitespace, _paragraphWidth,
_paragraphProperties.TextAlignment, _paragraphProperties.FlowDirection);
var lineHeight = _paragraphProperties.LineHeight;
var height = double.IsNaN(lineHeight) || MathUtilities.IsZero(lineHeight) ?
descent - ascent + lineGap :
lineHeight;
return new TextLineMetrics(widthIncludingWhitespace > _paragraphWidth, height, newLineLength, start,
-ascent, trailingWhitespaceLength, width, widthIncludingWhitespace);
}
/// <summary>
/// Creates a shaped symbol.
/// </summary>
/// <param name="textRun">The symbol run to shape.</param>
/// <returns>
/// The shaped symbol.
/// </returns>
internal static ShapedTextCharacters CreateShapedSymbol(TextRun textRun)
private sealed class OrderedBidiRun
{
var properties = textRun.Properties;
public OrderedBidiRun(ShapedTextCharacters run) => Run = run;
public sbyte Level => Run.BidiLevel;
_ = properties ?? throw new InvalidOperationException($"{nameof(TextRun.Properties)} should not be null.");
public ShapedTextCharacters Run { get; }
var glyphRun = TextShaper.Current.ShapeText(textRun.Text, properties.Typeface, properties.FontRenderingEmSize, properties.CultureInfo);
public OrderedBidiRun? Next { get; set; }
return new ShapedTextCharacters(glyphRun, properties);
public void Reverse() => Run.ShapedBuffer.GlyphInfos.Span.Reverse();
}
private sealed class BidiRange
{
public int Level { get; set; }
public OrderedBidiRun? Left { get; set; }
public OrderedBidiRun? Right { get; set; }
public BidiRange? Previous { get; set; }
public static BidiRange MergeWithPrevious(BidiRange range)
{
var previous = range.Previous;
BidiRange left;
BidiRange right;
if ((previous!.Level & 1) != 0)
{
// Odd, previous goes to the right of range.
left = range;
right = previous;
}
else
{
// Even, previous goes to the left of range.
left = previous;
right = range;
}
// Stitch them
left.Right!.Next = right.Left;
previous.Left = left.Left;
previous.Right = right.Right;
return previous;
}
}
}
}

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

@ -7,7 +7,7 @@
{
/// <summary>
/// This property specifies whether the primary text advance
/// direction shall be left-to-right, right-to-left, or top-to-bottom.
/// direction shall be left-to-right, right-to-left.
/// </summary>
public abstract FlowDirection FlowDirection { get; }

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

@ -41,7 +41,7 @@ namespace Avalonia.Media.TextFormatting
{
unsafe
{
fixed (char* charsPtr = _textRun.Text.Buffer.Span)
fixed (char* charsPtr = _textRun.Text.Span)
{
return new string(charsPtr, 0, _textRun.Text.Length);
}

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

@ -45,10 +45,10 @@ namespace Avalonia.Media.TextFormatting
}
/// <inheritdoc cref="ITextShaperImpl.ShapeText"/>
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize,
CultureInfo? culture)
public ShapedBuffer ShapeText(ReadOnlySlice<char> text, GlyphTypeface typeface, double fontRenderingEmSize,
CultureInfo? culture, sbyte bidiLevel)
{
return _platformImpl.ShapeText(text, typeface, fontRenderingEmSize, culture);
return _platformImpl.ShapeText(text, typeface, fontRenderingEmSize, culture, bidiLevel);
}
}
}

1717
src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiAlgorithm.cs

File diff suppressed because it is too large

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

@ -1,6 +1,6 @@
namespace Avalonia.Media.TextFormatting.Unicode
{
public enum BiDiClass
public enum BidiClass
{
LeftToRight, //L
ArabicLetter, //AL

182
src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiData.cs

@ -0,0 +1,182 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.
// Ported from: https://github.com/SixLabors/Fonts/
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode
{
/// <summary>
/// Represents a unicode string and all associated attributes
/// for each character required for the bidirectional Unicode algorithm
/// </summary>
internal class BidiData
{
private ArrayBuilder<BidiClass> _classes;
private ArrayBuilder<BidiPairedBracketType> _pairedBracketTypes;
private ArrayBuilder<int> _pairedBracketValues;
private ArrayBuilder<BidiClass> _savedClasses;
private ArrayBuilder<BidiPairedBracketType> _savedPairedBracketTypes;
private ArrayBuilder<sbyte> _tempLevelBuffer;
public BidiData(sbyte paragraphEmbeddingLevel = 0)
{
ParagraphEmbeddingLevel = paragraphEmbeddingLevel;
}
public BidiData(ReadOnlySlice<char> text, sbyte paragraphEmbeddingLevel = 0) : this(paragraphEmbeddingLevel)
{
Append(text);
}
public sbyte ParagraphEmbeddingLevel { get; private set; }
public bool HasBrackets { get; private set; }
public bool HasEmbeddings { get; private set; }
public bool HasIsolates { get; private set; }
/// <summary>
/// Gets the length of the data held by the BidiData
/// </summary>
public int Length{get; private set; }
/// <summary>
/// Gets the bidi character type of each code point
/// </summary>
public ArraySlice<BidiClass> Classes { get; private set; }
/// <summary>
/// Gets the paired bracket type for each code point
/// </summary>
public ArraySlice<BidiPairedBracketType> PairedBracketTypes { get; private set; }
/// <summary>
/// Gets the paired bracket value for code point
/// </summary>
/// <remarks>
/// The paired bracket values are the code points
/// of each character where the opening code point
/// is replaced with the closing code point for easier
/// matching. Also, bracket code points are mapped
/// to their canonical equivalents
/// </remarks>
public ArraySlice<int> PairedBracketValues { get; private set; }
public void Append(ReadOnlySlice<char> text)
{
_classes.Add(text.Length);
_pairedBracketTypes.Add(text.Length);
_pairedBracketValues.Add(text.Length);
var i = Length;
var codePointEnumerator = new CodepointEnumerator(text);
while (codePointEnumerator.MoveNext())
{
var codepoint = codePointEnumerator.Current;
// Look up BiDiClass
var dir = codepoint.BiDiClass;
_classes[i] = dir;
switch (dir)
{
case BidiClass.LeftToRightEmbedding:
case BidiClass.LeftToRightOverride:
case BidiClass.RightToLeftEmbedding:
case BidiClass.RightToLeftOverride:
case BidiClass.PopDirectionalFormat:
{
HasEmbeddings = true;
break;
}
case BidiClass.LeftToRightIsolate:
case BidiClass.RightToLeftIsolate:
case BidiClass.FirstStrongIsolate:
case BidiClass.PopDirectionalIsolate:
{
HasIsolates = true;
break;
}
}
// Lookup paired bracket types
var pbt = codepoint.PairedBracketType;
_pairedBracketTypes[i] = pbt;
if (pbt == BidiPairedBracketType.Open)
{
// Opening bracket types can never have a null pairing.
codepoint.TryGetPairedBracket(out var paired);
_pairedBracketValues[i] = Codepoint.GetCanonicalType(paired).Value;
HasBrackets = true;
}
else if (pbt == BidiPairedBracketType.Close)
{
_pairedBracketValues[i] = Codepoint.GetCanonicalType(codepoint).Value;
HasBrackets = true;
}
i++;
}
Length = i;
Classes = _classes.AsSlice(0, Length);
PairedBracketTypes = _pairedBracketTypes.AsSlice(0, Length);
PairedBracketValues = _pairedBracketValues.AsSlice(0, Length);
}
/// <summary>
/// Save the Types and PairedBracketTypes of this BiDiData
/// </summary>
/// <remarks>
/// This is used when processing embedded style runs with
/// BiDiClass overrides. Text layout process saves the data,
/// overrides the style runs to neutral, processes the bidi
/// data for the entire paragraph and then restores this data
/// before processing the embedded runs.
/// </remarks>
public void SaveTypes()
{
// Capture the types data
_savedClasses.Clear();
_savedClasses.Add(_classes.AsSlice());
_savedPairedBracketTypes.Clear();
_savedPairedBracketTypes.Add(_pairedBracketTypes.AsSlice());
}
/// <summary>
/// Restore the data saved by SaveTypes
/// </summary>
public void RestoreTypes()
{
_classes.Clear();
_classes.Add(_savedClasses.AsSlice());
_pairedBracketTypes.Clear();
_pairedBracketTypes.Add(_savedPairedBracketTypes.AsSlice());
}
/// <summary>
/// Gets a temporary level buffer. Used by the text layout process when
/// resolving style runs with different BiDiClass.
/// </summary>
/// <param name="length">Length of the required ExpandableBuffer</param>
/// <returns>An uninitialized level ExpandableBuffer</returns>
public ArraySlice<sbyte> GetTempLevelBuffer(int length)
{
_tempLevelBuffer.Clear();
return _tempLevelBuffer.Add(length, false);
}
}
}

9
src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiPairedBracketType.cs

@ -0,0 +1,9 @@
namespace Avalonia.Media.TextFormatting.Unicode
{
public enum BidiPairedBracketType
{
None, //n
Close, //c
Open, //o
}
}

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

@ -1,4 +1,5 @@
using Avalonia.Utilities;
using System.Runtime.CompilerServices;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode
{
@ -30,10 +31,15 @@ namespace Avalonia.Media.TextFormatting.Unicode
public Script Script => UnicodeData.GetScript(Value);
/// <summary>
/// Gets the <see cref="Unicode.BiDiClass"/>.
/// Gets the <see cref="Unicode.BidiClass"/>.
/// </summary>
public BiDiClass BiDiClass => UnicodeData.GetBiDiClass(Value);
public BidiClass BiDiClass => UnicodeData.GetBiDiClass(Value);
/// <summary>
/// Gets the <see cref="Unicode.BidiPairedBracketType"/>.
/// </summary>
public BidiPairedBracketType PairedBracketType => UnicodeData.GetBiDiPairedBracketType(Value);
/// <summary>
/// Gets the <see cref="Unicode.LineBreakClass"/>.
/// </summary>
@ -93,6 +99,52 @@ namespace Avalonia.Media.TextFormatting.Unicode
return false;
}
}
/// <summary>
/// Gets the canonical representation of a given codepoint.
/// <see href="http://www.unicode.org/L2/L2013/13123-norm-and-bpa.pdf"/>
/// </summary>
/// <param name="codePoint">The code point to be mapped.</param>
/// <returns>The mapped canonical code point, or the passed <paramref name="codePoint"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static Codepoint GetCanonicalType(Codepoint codePoint)
{
if (codePoint.Value == 0x3008)
{
return new Codepoint(0x2329);
}
if (codePoint.Value == 0x3009)
{
return new Codepoint(0x232A);
}
return codePoint;
}
/// <summary>
/// Gets the codepoint representing the bracket pairing for this instance.
/// </summary>
/// <param name="codepoint">
/// When this method returns, contains the codepoint representing the bracket pairing for this instance;
/// otherwise, the default value for the type of the <paramref name="codepoint"/> parameter.
/// This parameter is passed uninitialized.
/// .</param>
/// <returns><see langword="true"/> if this instance has a bracket pairing; otherwise, <see langword="false"/></returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetPairedBracket(out Codepoint codepoint)
{
if (PairedBracketType == BidiPairedBracketType.None)
{
codepoint = default;
return false;
}
codepoint = UnicodeData.GetBiDiPairedBracket(Value);
return true;
}
public static implicit operator int(Codepoint codepoint)
{

331
src/Avalonia.Visuals/Media/TextFormatting/Unicode/PropertyValueAliasHelper.cs

@ -174,5 +174,336 @@ namespace Avalonia.Media.TextFormatting.Unicode
}
return s_scriptToTag[script];
}
private static readonly Dictionary<string, Script> s_tagToScript =
new Dictionary<string,Script>{
{ "Zzzz", Script.Unknown},
{ "Zyyy", Script.Common},
{ "Zinh", Script.Inherited},
{ "Adlm", Script.Adlam},
{ "Aghb", Script.CaucasianAlbanian},
{ "Ahom", Script.Ahom},
{ "Arab", Script.Arabic},
{ "Armi", Script.ImperialAramaic},
{ "Armn", Script.Armenian},
{ "Avst", Script.Avestan},
{ "Bali", Script.Balinese},
{ "Bamu", Script.Bamum},
{ "Bass", Script.BassaVah},
{ "Batk", Script.Batak},
{ "Beng", Script.Bengali},
{ "Bhks", Script.Bhaiksuki},
{ "Bopo", Script.Bopomofo},
{ "Brah", Script.Brahmi},
{ "Brai", Script.Braille},
{ "Bugi", Script.Buginese},
{ "Buhd", Script.Buhid},
{ "Cakm", Script.Chakma},
{ "Cans", Script.CanadianAboriginal},
{ "Cari", Script.Carian},
{ "Cham", Script.Cham},
{ "Cher", Script.Cherokee},
{ "Chrs", Script.Chorasmian},
{ "Copt", Script.Coptic},
{ "Cprt", Script.Cypriot},
{ "Cyrl", Script.Cyrillic},
{ "Deva", Script.Devanagari},
{ "Diak", Script.DivesAkuru},
{ "Dogr", Script.Dogra},
{ "Dsrt", Script.Deseret},
{ "Dupl", Script.Duployan},
{ "Egyp", Script.EgyptianHieroglyphs},
{ "Elba", Script.Elbasan},
{ "Elym", Script.Elymaic},
{ "Ethi", Script.Ethiopic},
{ "Geor", Script.Georgian},
{ "Glag", Script.Glagolitic},
{ "Gong", Script.GunjalaGondi},
{ "Gonm", Script.MasaramGondi},
{ "Goth", Script.Gothic},
{ "Gran", Script.Grantha},
{ "Grek", Script.Greek},
{ "Gujr", Script.Gujarati},
{ "Guru", Script.Gurmukhi},
{ "Hang", Script.Hangul},
{ "Hani", Script.Han},
{ "Hano", Script.Hanunoo},
{ "Hatr", Script.Hatran},
{ "Hebr", Script.Hebrew},
{ "Hira", Script.Hiragana},
{ "Hluw", Script.AnatolianHieroglyphs},
{ "Hmng", Script.PahawhHmong},
{ "Hmnp", Script.NyiakengPuachueHmong},
{ "Hrkt", Script.KatakanaOrHiragana},
{ "Hung", Script.OldHungarian},
{ "Ital", Script.OldItalic},
{ "Java", Script.Javanese},
{ "Kali", Script.KayahLi},
{ "Kana", Script.Katakana},
{ "Khar", Script.Kharoshthi},
{ "Khmr", Script.Khmer},
{ "Khoj", Script.Khojki},
{ "Kits", Script.KhitanSmallScript},
{ "Knda", Script.Kannada},
{ "Kthi", Script.Kaithi},
{ "Lana", Script.TaiTham},
{ "Laoo", Script.Lao},
{ "Latn", Script.Latin},
{ "Lepc", Script.Lepcha},
{ "Limb", Script.Limbu},
{ "Lina", Script.LinearA},
{ "Linb", Script.LinearB},
{ "Lisu", Script.Lisu},
{ "Lyci", Script.Lycian},
{ "Lydi", Script.Lydian},
{ "Mahj", Script.Mahajani},
{ "Maka", Script.Makasar},
{ "Mand", Script.Mandaic},
{ "Mani", Script.Manichaean},
{ "Marc", Script.Marchen},
{ "Medf", Script.Medefaidrin},
{ "Mend", Script.MendeKikakui},
{ "Merc", Script.MeroiticCursive},
{ "Mero", Script.MeroiticHieroglyphs},
{ "Mlym", Script.Malayalam},
{ "Modi", Script.Modi},
{ "Mong", Script.Mongolian},
{ "Mroo", Script.Mro},
{ "Mtei", Script.MeeteiMayek},
{ "Mult", Script.Multani},
{ "Mymr", Script.Myanmar},
{ "Nand", Script.Nandinagari},
{ "Narb", Script.OldNorthArabian},
{ "Nbat", Script.Nabataean},
{ "Newa", Script.Newa},
{ "Nkoo", Script.Nko},
{ "Nshu", Script.Nushu},
{ "Ogam", Script.Ogham},
{ "Olck", Script.OlChiki},
{ "Orkh", Script.OldTurkic},
{ "Orya", Script.Oriya},
{ "Osge", Script.Osage},
{ "Osma", Script.Osmanya},
{ "Palm", Script.Palmyrene},
{ "Pauc", Script.PauCinHau},
{ "Perm", Script.OldPermic},
{ "Phag", Script.PhagsPa},
{ "Phli", Script.InscriptionalPahlavi},
{ "Phlp", Script.PsalterPahlavi},
{ "Phnx", Script.Phoenician},
{ "Plrd", Script.Miao},
{ "Prti", Script.InscriptionalParthian},
{ "Rjng", Script.Rejang},
{ "Rohg", Script.HanifiRohingya},
{ "Runr", Script.Runic},
{ "Samr", Script.Samaritan},
{ "Sarb", Script.OldSouthArabian},
{ "Saur", Script.Saurashtra},
{ "Sgnw", Script.SignWriting},
{ "Shaw", Script.Shavian},
{ "Shrd", Script.Sharada},
{ "Sidd", Script.Siddham},
{ "Sind", Script.Khudawadi},
{ "Sinh", Script.Sinhala},
{ "Sogd", Script.Sogdian},
{ "Sogo", Script.OldSogdian},
{ "Sora", Script.SoraSompeng},
{ "Soyo", Script.Soyombo},
{ "Sund", Script.Sundanese},
{ "Sylo", Script.SylotiNagri},
{ "Syrc", Script.Syriac},
{ "Tagb", Script.Tagbanwa},
{ "Takr", Script.Takri},
{ "Tale", Script.TaiLe},
{ "Talu", Script.NewTaiLue},
{ "Taml", Script.Tamil},
{ "Tang", Script.Tangut},
{ "Tavt", Script.TaiViet},
{ "Telu", Script.Telugu},
{ "Tfng", Script.Tifinagh},
{ "Tglg", Script.Tagalog},
{ "Thaa", Script.Thaana},
{ "Thai", Script.Thai},
{ "Tibt", Script.Tibetan},
{ "Tirh", Script.Tirhuta},
{ "Ugar", Script.Ugaritic},
{ "Vaii", Script.Vai},
{ "Wara", Script.WarangCiti},
{ "Wcho", Script.Wancho},
{ "Xpeo", Script.OldPersian},
{ "Xsux", Script.Cuneiform},
{ "Yezi", Script.Yezidi},
{ "Yiii", Script.Yi},
{ "Zanb", Script.ZanabazarSquare},
};
public static Script GetScript(string tag)
{
if(!s_tagToScript.ContainsKey(tag))
{
return Script.Unknown;
}
return s_tagToScript[tag];
}
private static readonly Dictionary<string, GeneralCategory> s_tagToGeneralCategory =
new Dictionary<string,GeneralCategory>{
{ "C", GeneralCategory.Other},
{ "Cc", GeneralCategory.Control},
{ "Cf", GeneralCategory.Format},
{ "Cn", GeneralCategory.Unassigned},
{ "Co", GeneralCategory.PrivateUse},
{ "Cs", GeneralCategory.Surrogate},
{ "L", GeneralCategory.Letter},
{ "LC", GeneralCategory.CasedLetter},
{ "Ll", GeneralCategory.LowercaseLetter},
{ "Lm", GeneralCategory.ModifierLetter},
{ "Lo", GeneralCategory.OtherLetter},
{ "Lt", GeneralCategory.TitlecaseLetter},
{ "Lu", GeneralCategory.UppercaseLetter},
{ "M", GeneralCategory.Mark},
{ "Mc", GeneralCategory.SpacingMark},
{ "Me", GeneralCategory.EnclosingMark},
{ "Mn", GeneralCategory.NonspacingMark},
{ "N", GeneralCategory.Number},
{ "Nd", GeneralCategory.DecimalNumber},
{ "Nl", GeneralCategory.LetterNumber},
{ "No", GeneralCategory.OtherNumber},
{ "P", GeneralCategory.Punctuation},
{ "Pc", GeneralCategory.ConnectorPunctuation},
{ "Pd", GeneralCategory.DashPunctuation},
{ "Pe", GeneralCategory.ClosePunctuation},
{ "Pf", GeneralCategory.FinalPunctuation},
{ "Pi", GeneralCategory.InitialPunctuation},
{ "Po", GeneralCategory.OtherPunctuation},
{ "Ps", GeneralCategory.OpenPunctuation},
{ "S", GeneralCategory.Symbol},
{ "Sc", GeneralCategory.CurrencySymbol},
{ "Sk", GeneralCategory.ModifierSymbol},
{ "Sm", GeneralCategory.MathSymbol},
{ "So", GeneralCategory.OtherSymbol},
{ "Z", GeneralCategory.Separator},
{ "Zl", GeneralCategory.LineSeparator},
{ "Zp", GeneralCategory.ParagraphSeparator},
{ "Zs", GeneralCategory.SpaceSeparator},
};
public static GeneralCategory GetGeneralCategory(string tag)
{
if(!s_tagToGeneralCategory.ContainsKey(tag))
{
return GeneralCategory.Other;
}
return s_tagToGeneralCategory[tag];
}
private static readonly Dictionary<string, LineBreakClass> s_tagToLineBreakClass =
new Dictionary<string,LineBreakClass>{
{ "OP", LineBreakClass.OpenPunctuation},
{ "CL", LineBreakClass.ClosePunctuation},
{ "CP", LineBreakClass.CloseParenthesis},
{ "QU", LineBreakClass.Quotation},
{ "GL", LineBreakClass.Glue},
{ "NS", LineBreakClass.Nonstarter},
{ "EX", LineBreakClass.Exclamation},
{ "SY", LineBreakClass.BreakSymbols},
{ "IS", LineBreakClass.InfixNumeric},
{ "PR", LineBreakClass.PrefixNumeric},
{ "PO", LineBreakClass.PostfixNumeric},
{ "NU", LineBreakClass.Numeric},
{ "AL", LineBreakClass.Alphabetic},
{ "HL", LineBreakClass.HebrewLetter},
{ "ID", LineBreakClass.Ideographic},
{ "IN", LineBreakClass.Inseparable},
{ "HY", LineBreakClass.Hyphen},
{ "BA", LineBreakClass.BreakAfter},
{ "BB", LineBreakClass.BreakBefore},
{ "B2", LineBreakClass.BreakBoth},
{ "ZW", LineBreakClass.ZWSpace},
{ "CM", LineBreakClass.CombiningMark},
{ "WJ", LineBreakClass.WordJoiner},
{ "H2", LineBreakClass.H2},
{ "H3", LineBreakClass.H3},
{ "JL", LineBreakClass.JL},
{ "JV", LineBreakClass.JV},
{ "JT", LineBreakClass.JT},
{ "RI", LineBreakClass.RegionalIndicator},
{ "EB", LineBreakClass.EBase},
{ "EM", LineBreakClass.EModifier},
{ "ZWJ", LineBreakClass.ZWJ},
{ "CB", LineBreakClass.ContingentBreak},
{ "XX", LineBreakClass.Unknown},
{ "AI", LineBreakClass.Ambiguous},
{ "BK", LineBreakClass.MandatoryBreak},
{ "CJ", LineBreakClass.ConditionalJapaneseStarter},
{ "CR", LineBreakClass.CarriageReturn},
{ "LF", LineBreakClass.LineFeed},
{ "NL", LineBreakClass.NextLine},
{ "SA", LineBreakClass.ComplexContext},
{ "SG", LineBreakClass.Surrogate},
{ "SP", LineBreakClass.Space},
};
public static LineBreakClass GetLineBreakClass(string tag)
{
if(!s_tagToLineBreakClass.ContainsKey(tag))
{
return LineBreakClass.Unknown;
}
return s_tagToLineBreakClass[tag];
}
private static readonly Dictionary<string, BidiPairedBracketType> s_tagToBiDiPairedBracketType =
new Dictionary<string,BidiPairedBracketType>{
{ "n", BidiPairedBracketType.None},
{ "c", BidiPairedBracketType.Close},
{ "o", BidiPairedBracketType.Open},
};
public static BidiPairedBracketType GetBiDiPairedBracketType(string tag)
{
if(!s_tagToBiDiPairedBracketType.ContainsKey(tag))
{
return BidiPairedBracketType.None;
}
return s_tagToBiDiPairedBracketType[tag];
}
private static readonly Dictionary<string, BidiClass> s_tagToBiDiClass =
new Dictionary<string,BidiClass>{
{ "L", BidiClass.LeftToRight},
{ "AL", BidiClass.ArabicLetter},
{ "AN", BidiClass.ArabicNumber},
{ "B", BidiClass.ParagraphSeparator},
{ "BN", BidiClass.BoundaryNeutral},
{ "CS", BidiClass.CommonSeparator},
{ "EN", BidiClass.EuropeanNumber},
{ "ES", BidiClass.EuropeanSeparator},
{ "ET", BidiClass.EuropeanTerminator},
{ "FSI", BidiClass.FirstStrongIsolate},
{ "LRE", BidiClass.LeftToRightEmbedding},
{ "LRI", BidiClass.LeftToRightIsolate},
{ "LRO", BidiClass.LeftToRightOverride},
{ "NSM", BidiClass.NonspacingMark},
{ "ON", BidiClass.OtherNeutral},
{ "PDF", BidiClass.PopDirectionalFormat},
{ "PDI", BidiClass.PopDirectionalIsolate},
{ "R", BidiClass.RightToLeft},
{ "RLE", BidiClass.RightToLeftEmbedding},
{ "RLI", BidiClass.RightToLeftIsolate},
{ "RLO", BidiClass.RightToLeftOverride},
{ "S", BidiClass.SegmentSeparator},
{ "WS", BidiClass.WhiteSpace},
};
public static BidiClass GetBiDiClass(string tag)
{
if(!s_tagToBiDiClass.ContainsKey(tag))
{
return BidiClass.LeftToRight;
}
return s_tagToBiDiClass[tag];
}
}
}

71
src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs

@ -1,4 +1,6 @@
namespace Avalonia.Media.TextFormatting.Unicode
using System.Runtime.CompilerServices;
namespace Avalonia.Media.TextFormatting.Unicode
{
/// <summary>
/// Helper for looking up unicode character class information
@ -7,25 +9,35 @@
{
internal const int CATEGORY_BITS = 6;
internal const int SCRIPT_BITS = 8;
internal const int BIDI_BITS = 5;
internal const int LINEBREAK_BITS = 6;
internal const int SCRIPT_SHIFT = CATEGORY_BITS;
internal const int BIDI_SHIFT = CATEGORY_BITS + SCRIPT_BITS;
internal const int LINEBREAK_SHIFT = CATEGORY_BITS + SCRIPT_BITS + BIDI_BITS;
internal const int BIDIPAIREDBRACKED_BITS = 16;
internal const int BIDIPAIREDBRACKEDTYPE_BITS = 2;
internal const int BIDICLASS_BITS = 5;
internal const int SCRIPT_SHIFT = CATEGORY_BITS;
internal const int LINEBREAK_SHIFT = CATEGORY_BITS + SCRIPT_BITS;
internal const int BIDIPAIREDBRACKEDTYPE_SHIFT = BIDIPAIREDBRACKED_BITS;
internal const int BIDICLASS_SHIFT = BIDIPAIREDBRACKED_BITS + BIDIPAIREDBRACKEDTYPE_BITS;
internal const int CATEGORY_MASK = (1 << CATEGORY_BITS) - 1;
internal const int SCRIPT_MASK = (1 << SCRIPT_BITS) - 1;
internal const int BIDI_MASK = (1 << BIDI_BITS) - 1;
internal const int LINEBREAK_MASK = (1 << LINEBREAK_BITS) - 1;
internal const int BIDIPAIREDBRACKED_MASK = (1 << BIDIPAIREDBRACKED_BITS) - 1;
internal const int BIDIPAIREDBRACKEDTYPE_MASK = (1 << BIDIPAIREDBRACKEDTYPE_BITS) - 1;
internal const int BIDICLASS_MASK = (1 << BIDICLASS_BITS) - 1;
private static readonly UnicodeTrie s_unicodeDataTrie;
private static readonly UnicodeTrie s_graphemeBreakTrie;
private static readonly UnicodeTrie s_biDiTrie;
static UnicodeData()
{
s_unicodeDataTrie = new UnicodeTrie(typeof(UnicodeData).Assembly.GetManifestResourceStream("Avalonia.Assets.UnicodeData.trie")!);
s_graphemeBreakTrie = new UnicodeTrie(typeof(UnicodeData).Assembly.GetManifestResourceStream("Avalonia.Assets.GraphemeBreak.trie")!);
s_biDiTrie = new UnicodeTrie(typeof(UnicodeData).Assembly.GetManifestResourceStream("Avalonia.Assets.BiDi.trie")!);
}
/// <summary>
@ -33,11 +45,10 @@
/// </summary>
/// <param name="codepoint">The codepoint in question.</param>
/// <returns>The code point's general category.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static GeneralCategory GetGeneralCategory(int codepoint)
{
var value = s_unicodeDataTrie.Get(codepoint);
return (GeneralCategory)(value & CATEGORY_MASK);
return (GeneralCategory)(s_unicodeDataTrie.Get(codepoint) & CATEGORY_MASK);
}
/// <summary>
@ -45,23 +56,43 @@
/// </summary>
/// <param name="codepoint">The codepoint in question.</param>
/// <returns>The code point's script.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Script GetScript(int codepoint)
{
var value = s_unicodeDataTrie.Get(codepoint);
return (Script)((value >> SCRIPT_SHIFT) & SCRIPT_MASK);
return (Script)((s_unicodeDataTrie.Get(codepoint) >> SCRIPT_SHIFT) & SCRIPT_MASK);
}
/// <summary>
/// Gets the <see cref="BiDiClass"/> for a Unicode codepoint.
/// Gets the <see cref="BidiClass"/> for a Unicode codepoint.
/// </summary>
/// <param name="codepoint">The codepoint in question.</param>
/// <returns>The code point's biDi class.</returns>
public static BiDiClass GetBiDiClass(int codepoint)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static BidiClass GetBiDiClass(int codepoint)
{
var value = s_unicodeDataTrie.Get(codepoint);
return (BiDiClass)((value >> BIDI_SHIFT) & BIDI_MASK);
return (BidiClass)((s_biDiTrie.Get(codepoint) >> BIDICLASS_SHIFT) & BIDICLASS_MASK);
}
/// <summary>
/// Gets the <see cref="BidiPairedBracketType"/> for a Unicode codepoint.
/// </summary>
/// <param name="codepoint">The codepoint in question.</param>
/// <returns>The code point's paired bracket type.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static BidiPairedBracketType GetBiDiPairedBracketType(int codepoint)
{
return (BidiPairedBracketType)((s_biDiTrie.Get(codepoint) >> BIDIPAIREDBRACKEDTYPE_SHIFT) & BIDIPAIREDBRACKEDTYPE_MASK);
}
/// <summary>
/// Gets the paired bracket for a Unicode codepoint.
/// </summary>
/// <param name="codepoint">The codepoint in question.</param>
/// <returns>The code point's paired bracket.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Codepoint GetBiDiPairedBracket(int codepoint)
{
return new Codepoint((int)(s_biDiTrie.Get(codepoint) & BIDIPAIREDBRACKED_MASK));
}
/// <summary>
@ -69,11 +100,10 @@
/// </summary>
/// <param name="codepoint">The codepoint in question.</param>
/// <returns>The code point's line break class.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static LineBreakClass GetLineBreakClass(int codepoint)
{
var value = s_unicodeDataTrie.Get(codepoint);
return (LineBreakClass)((value >> LINEBREAK_SHIFT) & LINEBREAK_MASK);
return (LineBreakClass)((s_unicodeDataTrie.Get(codepoint) >> LINEBREAK_SHIFT) & LINEBREAK_MASK);
}
/// <summary>
@ -81,6 +111,7 @@
/// </summary>
/// <param name="codepoint">The codepoint in question.</param>
/// <returns>The code point's grapheme break type.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static GraphemeBreakClass GetGraphemeClusterBreak(int codepoint)
{
return (GraphemeBreakClass)s_graphemeBreakTrie.Get(codepoint);

27
src/Avalonia.Visuals/Media/TextHitTestResult.cs

@ -1,23 +1,38 @@
using Avalonia.Media.TextFormatting;
namespace Avalonia.Media
{
/// <summary>
/// Holds a hit test result from a <see cref="FormattedText"/>.
/// Holds a hit test result from a <see cref="TextLayout"/>.
/// </summary>
public class TextHitTestResult
public readonly struct TextHitTestResult
{
public TextHitTestResult(CharacterHit characterHit, int textPosition, bool isInside, bool isTrailing)
{
CharacterHit = characterHit;
TextPosition = textPosition;
IsInside = isInside;
IsTrailing = isTrailing;
}
/// <summary>
/// Gets the character hit of the hit test result.
/// </summary>
public CharacterHit CharacterHit { get; }
/// <summary>
/// Gets or sets a value indicating whether the point is inside the bounds of the text.
/// Gets a value indicating whether the point is inside the bounds of the text.
/// </summary>
public bool IsInside { get; set; }
public bool IsInside { get; }
/// <summary>
/// Gets the index of the hit character in the text.
/// </summary>
public int TextPosition { get; set; }
public int TextPosition { get; }
/// <summary>
/// Gets a value indicating whether the hit is on the trailing edge of the character.
/// </summary>
public bool IsTrailing { get; set; }
public bool IsTrailing { get; }
}
}

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

@ -48,7 +48,7 @@ namespace Avalonia.Media
/// <summary>
/// Gets the font family.
/// </summary>
public FontFamily? FontFamily { get; }
public FontFamily FontFamily { get; }
/// <summary>
/// Gets the font style.

7
src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs

@ -83,13 +83,6 @@ namespace Avalonia.Platform
/// </remarks>
void DrawEllipse(IBrush? brush, IPen? pen, Rect rect);
/// <summary>
/// Draws text.
/// </summary>
/// <param name="foreground">The foreground brush.</param>
/// <param name="origin">The upper-left corner of the text.</param>
/// <param name="text">The text.</param>
void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text);
/// <summary>
/// Draws a glyph run.

59
src/Avalonia.Visuals/Platform/IFormattedTextImpl.cs

@ -1,59 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia.Media;
namespace Avalonia.Platform
{
/// <summary>
/// Defines the platform-specific interface for <see cref="FormattedText"/>.
/// </summary>
public interface IFormattedTextImpl
{
/// <summary>
/// Gets the constraint of the text.
/// </summary>
Size Constraint { get; }
/// <summary>
/// The measured bounds of the text.
/// </summary>
Rect Bounds{ get; }
/// <summary>
/// Gets the text.
/// </summary>
string Text { get; }
/// <summary>
/// Gets the lines in the text.
/// </summary>
/// <returns>
/// A collection of <see cref="FormattedTextLine"/> objects.
/// </returns>
IEnumerable<FormattedTextLine> GetLines();
/// <summary>
/// Hit tests a point in the text.
/// </summary>
/// <param name="point">The point.</param>
/// <returns>
/// A <see cref="TextHitTestResult"/> describing the result of the hit test.
/// </returns>
TextHitTestResult HitTestPoint(Point point);
/// <summary>
/// Gets the bounds rectangle that the specified character occupies.
/// </summary>
/// <param name="index">The index of the character.</param>
/// <returns>The character bounds.</returns>
Rect HitTestTextPosition(int index);
/// <summary>
/// Gets the bounds rectangles that the specified text range occupies.
/// </summary>
/// <param name="index">The index of the first character.</param>
/// <param name="length">The number of characters in the text range.</param>
/// <returns>The character bounds.</returns>
IEnumerable<Rect> HitTestTextRange(int index, int length);
}
}

21
src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs

@ -11,27 +11,6 @@ namespace Avalonia.Platform
/// </summary>
public interface IPlatformRenderInterface
{
/// <summary>
/// Creates a formatted text implementation.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="typeface">The base typeface.</param>
/// <param name="fontSize">The font size.</param>
/// <param name="textAlignment">The text alignment.</param>
/// <param name="wrapping">The text wrapping mode.</param>
/// <param name="constraint">The text layout constraints.</param>
/// <param name="spans">The style spans.</param>
/// <returns>An <see cref="IFormattedTextImpl"/>.</returns>
IFormattedTextImpl CreateFormattedText(
string text,
Typeface typeface,
double fontSize,
TextAlignment textAlignment,
TextWrapping wrapping,
Size constraint,
IReadOnlyList<FormattedTextStyleSpan>? spans);
/// <summary>
/// Creates an ellipse geometry implementation.
/// </summary>
/// <param name="rect">The bounds of the ellipse.</param>

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

@ -1,5 +1,6 @@
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Platform
@ -10,13 +11,14 @@ namespace Avalonia.Platform
public interface ITextShaperImpl
{
/// <summary>
/// Shapes the specified region within the text and returns a resulting glyph run.
/// Shapes the specified region within the text and returns a shaped buffer.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="typeface">The typeface.</param>
/// <param name="fontRenderingEmSize">The font rendering em size.</param>
/// <param name="culture">The culture.</param>
/// <param name="bidiLevel">The bidi level.</param>
/// <returns>A shaped glyph run.</returns>
GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo? culture);
ShapedBuffer ShapeText(ReadOnlySlice<char> text, GlyphTypeface typeface, double fontRenderingEmSize, CultureInfo? culture, sbyte bidiLevel);
}
}

5
src/Avalonia.Visuals/Rendering/DeferredRenderer.cs

@ -588,7 +588,10 @@ namespace Avalonia.Rendering
if (DrawFps)
{
RenderFps(context, clientRect, scene.Layers.Count);
using (var c = new DrawingContext(context, false))
{
RenderFps(c, clientRect, scene.Layers.Count);
}
}
}

2
src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs

@ -78,7 +78,7 @@ namespace Avalonia.Rendering
if (DrawFps)
{
RenderFps(context.PlatformImpl, _root.Bounds, null);
RenderFps(context, _root.Bounds, null);
}
}
}

30
src/Avalonia.Visuals/Rendering/RendererBase.cs

@ -1,7 +1,7 @@
using System;
using System.Diagnostics;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Platform;
namespace Avalonia.Rendering
{
@ -12,22 +12,16 @@ namespace Avalonia.Rendering
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
private int _framesThisSecond;
private int _fps;
private FormattedText _fpsText;
private TimeSpan _lastFpsUpdate;
public RendererBase(bool useManualFpsCounting = false)
{
_useManualFpsCounting = useManualFpsCounting;
_fpsText = new FormattedText
{
Typeface = new Typeface(FontFamily.Default),
FontSize = s_fontSize
};
}
protected void FpsTick() => _framesThisSecond++;
protected void RenderFps(IDrawingContextImpl context, Rect clientRect, int? layerCount)
protected void RenderFps(DrawingContext context, Rect clientRect, int? layerCount)
{
var now = _stopwatch.Elapsed;
var elapsed = now - _lastFpsUpdate;
@ -42,21 +36,15 @@ namespace Avalonia.Rendering
_lastFpsUpdate = now;
}
if (layerCount.HasValue)
{
_fpsText.Text = string.Format("Layers: {0} FPS: {1:000}", layerCount, _fps);
}
else
{
_fpsText.Text = string.Format("FPS: {0:000}", _fps);
}
var text = layerCount.HasValue ? $"Layers: {layerCount} FPS: {_fps:000}" : $"FPS: {_fps:000}";
var formattedText = new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, s_fontSize, Brushes.White);
var rect = new Rect(clientRect.Right - formattedText.Width, 0, formattedText.Width, formattedText.Height);
var size = _fpsText.Bounds.Size;
var rect = new Rect(clientRect.Right - size.Width, 0, size.Width, size.Height);
context.DrawRectangle(Brushes.Black, null, rect);
context.Transform = Matrix.Identity;
context.DrawRectangle(Brushes.Black,null, rect);
context.DrawText(Brushes.White, rect.TopLeft, _fpsText.PlatformImpl);
context.DrawText(formattedText, rect.TopLeft);
}
}
}

15
src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs

@ -202,21 +202,6 @@ namespace Avalonia.Rendering.SceneGraph
++_drawOperationindex;
}
/// <inheritdoc/>
public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text)
{
var next = NextDrawAs<TextNode>();
if (next == null || !next.Item.Equals(Transform, foreground, origin, text))
{
Add(new TextNode(Transform, foreground, origin, text, CreateChildScene(foreground)));
}
else
{
++_drawOperationindex;
}
}
/// <inheritdoc/>
public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun)
{

89
src/Avalonia.Visuals/Rendering/SceneGraph/TextNode.cs

@ -1,89 +0,0 @@
using System.Collections.Generic;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.VisualTree;
namespace Avalonia.Rendering.SceneGraph
{
/// <summary>
/// A node in the scene graph which represents a text draw.
/// </summary>
internal class TextNode : BrushDrawOperation
{
/// <summary>
/// Initializes a new instance of the <see cref="TextNode"/> class.
/// </summary>
/// <param name="transform">The transform.</param>
/// <param name="foreground">The foreground brush.</param>
/// <param name="origin">The draw origin.</param>
/// <param name="text">The text to draw.</param>
/// <param name="childScenes">Child scenes for drawing visual brushes.</param>
public TextNode(
Matrix transform,
IBrush foreground,
Point origin,
IFormattedTextImpl text,
IDictionary<IVisual, Scene>? childScenes = null)
: base(text.Bounds.Translate(origin), transform)
{
Transform = transform;
Foreground = foreground.ToImmutable();
Origin = origin;
Text = text;
ChildScenes = childScenes;
}
/// <summary>
/// Gets the transform with which the node will be drawn.
/// </summary>
public Matrix Transform { get; }
/// <summary>
/// Gets the foreground brush.
/// </summary>
public IBrush Foreground { get; }
/// <summary>
/// Gets the draw origin.
/// </summary>
public Point Origin { get; }
/// <summary>
/// Gets the text to draw.
/// </summary>
public IFormattedTextImpl Text { get; }
/// <inheritdoc/>
public override IDictionary<IVisual, Scene>? ChildScenes { get; }
/// <inheritdoc/>
public override void Render(IDrawingContextImpl context)
{
context.Transform = Transform;
context.DrawText(Foreground, Origin, Text);
}
/// <summary>
/// Determines if this draw operation equals another.
/// </summary>
/// <param name="transform">The transform of the other draw operation.</param>
/// <param name="foreground">The foreground of the other draw operation.</param>
/// <param name="origin">The draw origin of the other draw operation.</param>
/// <param name="text">The text of the other draw operation.</param>
/// <returns>True if the draw operations are the same, otherwise false.</returns>
/// <remarks>
/// The properties of the other draw operation are passed in as arguments to prevent
/// allocation of a not-yet-constructed draw operation object.
/// </remarks>
internal bool Equals(Matrix transform, IBrush foreground, Point origin, IFormattedTextImpl text)
{
return transform == Transform &&
Equals(foreground, Foreground) &&
origin == Origin &&
Equals(text, Text);
}
/// <inheritdoc/>
public override bool HitTest(Point p) => Bounds.Contains(p);
}
}

184
src/Avalonia.Visuals/Utilities/ArrayBuilder.cs

@ -0,0 +1,184 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.
// Ported from: https://github.com/SixLabors/Fonts/
using System;
using System.Runtime.CompilerServices;
namespace Avalonia.Utilities
{
/// <summary>
/// A helper type for avoiding allocations while building arrays.
/// </summary>
/// <typeparam name="T">The type of item contained in the array.</typeparam>
internal struct ArrayBuilder<T>
where T : struct
{
private const int DefaultCapacity = 4;
private const int MaxCoreClrArrayLength = 0x7FeFFFFF;
// Starts out null, initialized on first Add.
private T[] _data;
private int _size;
/// <summary>
/// Gets or sets the number of items in the array.
/// </summary>
public int Length
{
get => _size;
set
{
if (value == _size)
{
return;
}
if (value > 0)
{
EnsureCapacity(value);
_size = value;
}
else
{
_size = 0;
}
}
}
/// <summary>
/// Returns a reference to specified element of the array.
/// </summary>
/// <param name="index">The index of the element to return.</param>
/// <returns>The <typeparamref name="T"/>.</returns>
/// <exception cref="IndexOutOfRangeException">
/// Thrown when index less than 0 or index greater than or equal to <see cref="Length"/>.
/// </exception>
public ref T this[int index]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
#if DEBUG
if (index.CompareTo(0) < 0 || index.CompareTo(_size) > 0)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
#endif
return ref _data![index];
}
}
/// <summary>
/// Appends a given number of empty items to the array returning
/// the items as a slice.
/// </summary>
/// <param name="length">The number of items in the slice.</param>
/// <param name="clear">Whether to clear the new slice, Defaults to <see langword="true"/>.</param>
/// <returns>The <see cref="ArraySlice{T}"/>.</returns>
public ArraySlice<T> Add(int length, bool clear = true)
{
var position = _size;
// Expand the array.
Length += length;
var slice = AsSlice(position, Length - position);
if (clear)
{
slice.Span.Clear();
}
return slice;
}
/// <summary>
/// Appends the slice to the array copying the data across.
/// </summary>
/// <param name="value">The array slice.</param>
/// <returns>The <see cref="ArraySlice{T}"/>.</returns>
public ArraySlice<T> Add(in ReadOnlySlice<T> value)
{
var position = _size;
// Expand the array.
Length += value.Length;
var slice = AsSlice(position, Length - position);
value.Span.CopyTo(slice.Span);
return slice;
}
/// <summary>
/// Clears the array.
/// Allocated memory is left intact for future usage.
/// </summary>
public void Clear()
{
// No need to actually clear since we're not allowing reference types.
_size = 0;
}
private void EnsureCapacity(int min)
{
var length = _data?.Length ?? 0;
if (length >= min)
{
return;
}
// Same expansion algorithm as List<T>.
var newCapacity = length == 0 ? DefaultCapacity : (uint)length * 2u;
if (newCapacity > MaxCoreClrArrayLength)
{
newCapacity = MaxCoreClrArrayLength;
}
if (newCapacity < min)
{
newCapacity = (uint)min;
}
var array = new T[newCapacity];
if (_size > 0)
{
Array.Copy(_data!, array, _size);
}
_data = array;
}
/// <summary>
/// Returns the current state of the array as a slice.
/// </summary>
/// <returns>The <see cref="ArraySlice{T}"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ArraySlice<T> AsSlice() => AsSlice(Length);
/// <summary>
/// Returns the current state of the array as a slice.
/// </summary>
/// <param name="length">The number of items in the slice.</param>
/// <returns>The <see cref="ArraySlice{T}"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ArraySlice<T> AsSlice(int length) => new ArraySlice<T>(_data!, 0, length);
/// <summary>
/// Returns the current state of the array as a slice.
/// </summary>
/// <param name="start">The index at which to begin the slice.</param>
/// <param name="length">The number of items in the slice.</param>
/// <returns>The <see cref="ArraySlice{T}"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ArraySlice<T> AsSlice(int start, int length) => new ArraySlice<T>(_data!, start, length);
}
}

197
src/Avalonia.Visuals/Utilities/ArraySlice.cs

@ -0,0 +1,197 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.
// Ported from: https://github.com/SixLabors/Fonts/
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace Avalonia.Utilities
{
/// <summary>
/// ArraySlice represents a contiguous region of arbitrary memory similar
/// to <see cref="Memory{T}"/> and <see cref="Span{T}"/> though constrained
/// to arrays.
/// Unlike <see cref="Span{T}"/>, it is not a byref-like type.
/// </summary>
/// <typeparam name="T">The type of item contained in the slice.</typeparam>
internal readonly struct ArraySlice<T> : IReadOnlyList<T>
where T : struct
{
/// <summary>
/// Gets an empty <see cref="ArraySlice{T}"/>
/// </summary>
public static ArraySlice<T> Empty => new ArraySlice<T>(Array.Empty<T>());
private readonly T[] _data;
/// <summary>
/// Initializes a new instance of the <see cref="ArraySlice{T}"/> struct.
/// </summary>
/// <param name="data">The underlying data buffer.</param>
public ArraySlice(T[] data)
: this(data, 0, data.Length)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ArraySlice{T}"/> struct.
/// </summary>
/// <param name="data">The underlying data buffer.</param>
/// <param name="start">The offset position in the underlying buffer this slice was created from.</param>
/// <param name="length">The number of items in the slice.</param>
public ArraySlice(T[] data, int start, int length)
{
#if DEBUG
if (start.CompareTo(0) < 0)
{
throw new ArgumentOutOfRangeException(nameof(start));
}
if (length.CompareTo(data.Length) > 0)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
if ((start + length).CompareTo(data.Length) > 0)
{
throw new ArgumentOutOfRangeException(nameof(data));
}
#endif
_data = data;
Start = start;
Length = length;
}
/// <summary>
/// Gets a value that indicates whether this instance of <see cref="ArraySlice{T}"/> is Empty.
/// </summary>
public bool IsEmpty => Length == 0;
/// <summary>
/// Gets the offset position in the underlying buffer this slice was created from.
/// </summary>
public int Start { get; }
/// <summary>
/// Gets the number of items in the slice.
/// </summary>
public int Length { get; }
/// <summary>
/// Gets a <see cref="Span{T}"/> representing this slice.
/// </summary>
public Span<T> Span => new Span<T>(_data, Start, Length);
/// <summary>
/// Returns a reference to specified element of the slice.
/// </summary>
/// <param name="index">The index of the element to return.</param>
/// <returns>The <typeparamref name="T"/>.</returns>
/// <exception cref="IndexOutOfRangeException">
/// Thrown when index less than 0 or index greater than or equal to <see cref="Length"/>.
/// </exception>
public ref T this[int index]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
#if DEBUG
if (index.CompareTo(0) < 0 || index.CompareTo(Length) > 0)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
#endif
var i = index + Start;
return ref _data[i];
}
}
/// <summary>
/// Defines an implicit conversion of a <see cref="ArraySlice{T}"/> to a <see cref="ReadOnlySlice{T}"/>
/// </summary>
public static implicit operator ReadOnlySlice<T>(ArraySlice<T> slice)
{
return new ReadOnlySlice<T>(slice._data).AsSlice(slice.Start, slice.Length);
}
/// <summary>
/// Defines an implicit conversion of an array to a <see cref="ArraySlice{T}"/>
/// </summary>
public static implicit operator ArraySlice<T>(T[] array) => new ArraySlice<T>(array, 0, array.Length);
/// <summary>
/// Fills the contents of this slice with the given value.
/// </summary>
public void Fill(T value) => Span.Fill(value);
/// <summary>
/// Forms a slice out of the given slice, beginning at 'start', of given length
/// </summary>
/// <param name="start">The index at which to begin this slice.</param>
/// <param name="length">The desired length for the slice (exclusive).</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when the specified <paramref name="start"/> or end index is not in range (&lt;0 or &gt;Length).
/// </exception>
public ArraySlice<T> Slice(int start, int length) => new ArraySlice<T>(_data, start, length);
/// <summary>
/// Returns a specified number of contiguous elements from the start of the slice.
/// </summary>
/// <param name="length">The number of elements to return.</param>
/// <returns>A <see cref="ArraySlice{T}"/> that contains the specified number of elements from the start of this slice.</returns>
public ArraySlice<T> Take(int length)
{
if (IsEmpty)
{
return this;
}
if (length > Length)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
return new ArraySlice<T>(_data, Start, length);
}
/// <summary>
/// Bypasses a specified number of elements in the slice and then returns the remaining elements.
/// </summary>
/// <param name="length">The number of elements to skip before returning the remaining elements.</param>
/// <returns>A <see cref="ArraySlice{T}"/> that contains the elements that occur after the specified index in this slice.</returns>
public ArraySlice<T> Skip(int length)
{
if (IsEmpty)
{
return this;
}
if (length > Length)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
return new ArraySlice<T>(_data, Start + length, Length - length);
}
public ImmutableReadOnlyListStructEnumerator<T> GetEnumerator() =>
new ImmutableReadOnlyListStructEnumerator<T>(this);
/// <inheritdoc/>
IEnumerator<T> IEnumerable<T>.GetEnumerator() => GetEnumerator();
/// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
/// <inheritdoc/>
T IReadOnlyList<T>.this[int index] => this[index];
/// <inheritdoc/>
int IReadOnlyCollection<T>.Count => Length;
}
}

93
src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs

@ -0,0 +1,93 @@
// RichTextKit
// Copyright © 2019-2020 Topten Software. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this product except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// Copied from: https://github.com/toptensoftware/RichTextKit
using System;
using System.Collections.Generic;
namespace Avalonia.Utilities
{
/// <summary>
/// Extension methods for binary searching an IReadOnlyList collection
/// </summary>
public static class BinarySearchExtension
{
private static int GetMedian(int low, int hi)
{
System.Diagnostics.Debug.Assert(low <= hi);
System.Diagnostics.Debug.Assert(hi - low >= 0, "Length overflow!");
return low + (hi - low >> 1);
}
/// <summary>
/// Performs a binary search on the entire contents of an IReadOnlyList
/// </summary>
/// <typeparam name="T">The list element type</typeparam>
/// <param name="list">The list to be searched</param>
/// <param name="value">The value to search for</param>
/// <returns>The index of the found item; otherwise the bitwise complement of the index of the next larger item</returns>
public static int BinarySearch<T>(this IReadOnlyList<T> list, T value) where T : IComparable
{
return list.BinarySearch(value, Comparer<T>.Default);
}
/// <summary>
/// Performs a binary search on the entire contents of an IReadOnlyList
/// </summary>
/// <typeparam name="T">The list element type</typeparam>
/// <param name="list">The list to be searched</param>
/// <param name="value">The value to search for</param>
/// <param name="comparer">The comparer</param>
/// <returns>The index of the found item; otherwise the bitwise complement of the index of the next larger item</returns>
public static int BinarySearch<T>(this IReadOnlyList<T> list, T value, IComparer<T> comparer) where T : IComparable
{
return list.BinarySearch(0, list.Count, value, comparer);
}
/// <summary>
/// Performs a binary search on a a subset of an IReadOnlyList
/// </summary>
/// <typeparam name="T">The list element type</typeparam>
/// <typeparam name="U">The value type being searched for</typeparam>
/// <param name="list">The list to be searched</param>
/// <param name="index">The start of the range to be searched</param>
/// <param name="length">The length of the range to be searched</param>
/// <param name="value">The value to search for</param>
/// <param name="comparer">A comparer</param>
/// <returns>The index of the found item; otherwise the bitwise complement of the index of the next larger item</returns>
public static int BinarySearch<T>(this IReadOnlyList<T> list, int index, int length, T value, IComparer<T> comparer)
{
// Based on this: https://referencesource.microsoft.com/#mscorlib/system/array.cs,957
var lo = index;
var hi = index + length - 1;
while (lo <= hi)
{
var i = GetMedian(lo, hi);
var c = comparer.Compare(list[i], value);
if (c == 0)
return i;
if (c < 0)
{
lo = i + 1;
}
else
{
hi = i - 1;
}
}
return ~lo;
}
}
}

2360
src/Avalonia.Visuals/Utilities/FrugalList.cs

File diff suppressed because it is too large

58
src/Avalonia.Visuals/Utilities/MappedArraySlice.cs

@ -0,0 +1,58 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.
// Ported from: https://github.com/SixLabors/Fonts/
using System;
using System.Runtime.CompilerServices;
namespace Avalonia.Utilities
{
/// <summary>
/// Provides a mapped view of an underlying slice, selecting arbitrary indices
/// from the source array.
/// </summary>
/// <typeparam name="T">The type of item contained in the underlying array.</typeparam>
internal readonly struct MappedArraySlice<T>
where T : struct
{
private readonly ArraySlice<T> _data;
private readonly ArraySlice<int> _map;
/// <summary>
/// Initializes a new instance of the <see cref="MappedArraySlice{T}"/> struct.
/// </summary>
/// <param name="data">The data slice.</param>
/// <param name="map">The map slice.</param>
public MappedArraySlice(in ArraySlice<T> data, in ArraySlice<int> map)
{
#if DEBUG
if (map.Length.CompareTo(data.Length) > 0)
{
throw new ArgumentOutOfRangeException(nameof(map));
}
#endif
_data = data;
_map = map;
}
/// <summary>
/// Gets the number of items in the map.
/// </summary>
public int Length => _map.Length;
/// <summary>
/// Returns a reference to specified element of the slice.
/// </summary>
/// <param name="index">The index of the element to return.</param>
/// <returns>The <typeparamref name="T"/>.</returns>
/// <exception cref="IndexOutOfRangeException">
/// Thrown when index less than 0 or index greater than or equal to <see cref="Length"/>.
/// </exception>
public ref T this[int index]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => ref _data[_map[index]];
}
}
}

80
src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs

@ -2,6 +2,7 @@
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace Avalonia.Utilities
{
@ -10,15 +11,37 @@ namespace Avalonia.Utilities
/// </summary>
/// <typeparam name="T">The type of elements in the slice.</typeparam>
[DebuggerTypeProxy(typeof(ReadOnlySlice<>.ReadOnlySliceDebugView))]
public readonly struct ReadOnlySlice<T> : IReadOnlyList<T>
public readonly struct ReadOnlySlice<T> : IReadOnlyList<T> where T : struct
{
private readonly int _offset;
/// <summary>
/// Gets an empty <see cref="ReadOnlySlice{T}"/>
/// </summary>
public static ReadOnlySlice<T> Empty => new ReadOnlySlice<T>(Array.Empty<T>());
private readonly ReadOnlyMemory<T> _buffer;
public ReadOnlySlice(ReadOnlyMemory<T> buffer) : this(buffer, 0, buffer.Length) { }
public ReadOnlySlice(ReadOnlyMemory<T> buffer, int start, int length)
public ReadOnlySlice(ReadOnlyMemory<T> buffer, int start, int length, int offset = 0)
{
Buffer = buffer;
#if DEBUG
if (start.CompareTo(0) < 0)
{
throw new ArgumentOutOfRangeException(nameof (start));
}
if (length.CompareTo(buffer.Length) > 0)
{
throw new ArgumentOutOfRangeException(nameof (length));
}
#endif
_buffer = buffer;
Start = start;
Length = length;
_offset = offset;
}
/// <summary>
@ -51,12 +74,38 @@ namespace Avalonia.Utilities
public bool IsEmpty => Length == 0;
/// <summary>
/// The buffer.
/// The underlying span.
/// </summary>
public ReadOnlyMemory<T> Buffer { get; }
public ReadOnlySpan<T> Span => _buffer.Span.Slice(_offset, Length);
public T this[int index] => Buffer.Span[index];
/// <summary>
/// The underlying buffer.
/// </summary>
public ReadOnlyMemory<T> Buffer => _buffer;
/// <summary>
/// Returns a value to specified element of the slice.
/// </summary>
/// <param name="index">The index of the element to return.</param>
/// <returns>The <typeparamref name="T"/>.</returns>
/// <exception cref="IndexOutOfRangeException">
/// Thrown when index less than 0 or index greater than or equal to <see cref="Length"/>.
/// </exception>
public T this[int index]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
#if DEBUG
if (index.CompareTo(0) < 0 || index.CompareTo(Length) > 0)
{
throw new ArgumentOutOfRangeException(nameof (index));
}
#endif
return Span[index];
}
}
/// <summary>
/// Returns a sub slice of elements that start at the specified index and has the specified number of elements.
/// </summary>
@ -70,19 +119,22 @@ namespace Avalonia.Utilities
return this;
}
if (start < Start || start > End)
if (length == 0)
{
return Empty;
}
if (start < 0 || _offset + start > _buffer.Length - 1)
{
throw new ArgumentOutOfRangeException(nameof(start));
}
if (start + length > Start + Length)
if (_offset + start + length > _buffer.Length)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
var bufferOffset = start - Start;
return new ReadOnlySlice<T>(Buffer.Slice(bufferOffset), start, length);
return new ReadOnlySlice<T>(_buffer, start, length, _offset);
}
/// <summary>
@ -102,7 +154,7 @@ namespace Avalonia.Utilities
throw new ArgumentOutOfRangeException(nameof(length));
}
return new ReadOnlySlice<T>(Buffer.Slice(0, length), Start, length);
return new ReadOnlySlice<T>(_buffer, Start, length, _offset);
}
/// <summary>
@ -122,7 +174,7 @@ namespace Avalonia.Utilities
throw new ArgumentOutOfRangeException(nameof(length));
}
return new ReadOnlySlice<T>(Buffer.Slice(length), Start + length, Length - length);
return new ReadOnlySlice<T>(_buffer, Start + length, Length - length, _offset + length);
}
/// <summary>
@ -174,7 +226,7 @@ namespace Avalonia.Utilities
public bool IsEmpty => _readOnlySlice.IsEmpty;
public ReadOnlyMemory<T> Items => _readOnlySlice.Buffer;
public ReadOnlySpan<T> Items => _readOnlySlice.Span;
}
}
}

596
src/Avalonia.Visuals/Utilities/Span.cs

@ -0,0 +1,596 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
//+-----------------------------------------------------------------------
//
//
//
// Contents: Generic span types
//
// [As of this creation, C# has no real generic type system]
//
using System;
using System.Collections;
using System.Diagnostics;
namespace Avalonia.Utilities
{
internal class Span
{
public readonly object? element;
public int length;
public Span(object? element, int length)
{
this.element = element;
this.length = length;
}
}
/// <summary>
/// VECTOR: A series of spans
/// </summary>
internal class SpanVector : IEnumerable
{
private static readonly Equals s_referenceEquals = object.ReferenceEquals;
private static readonly Equals s_equals = object.Equals;
private FrugalStructList<Span> _spans;
internal SpanVector(
object? defaultObject,
FrugalStructList<Span> spans = new FrugalStructList<Span>())
{
Default = defaultObject;
_spans = spans;
}
/// <summary>
/// Get enumerator to vector
/// </summary>
public IEnumerator GetEnumerator()
{
return new SpanEnumerator(this);
}
/// <summary>
/// Add a new span to vector
/// </summary>
private void Add(Span span)
{
_spans.Add(span);
}
/// <summary>
/// Delete n elements of vector
/// </summary>
internal virtual void Delete(int index, int count, ref SpanPosition latestPosition)
{
DeleteInternal(index, count);
if (index <= latestPosition.Index)
latestPosition = new SpanPosition();
}
private void DeleteInternal(int index, int count)
{
// Do removes highest index to lowest to minimize the number
// of array entries copied.
for (var i = index + count - 1; i >= index; --i)
{
_spans.RemoveAt(i);
}
}
/// <summary>
/// Insert n elements to vector
/// </summary>
private void Insert(int index, int count)
{
for (var c = 0; c < count; c++)
_spans.Insert(index, new Span(null, 0));
}
/// <summary>
/// Finds the span that contains the specified character position.
/// </summary>
/// <param name="cp">position to find</param>
/// <param name="latestPosition">Position of the most recently accessed span (e.g., the current span
/// of a SpanRider) for performance; FindSpan runs in O(1) time if the specified cp is in the same span
/// or an adjacent span.</param>
/// <param name="spanPosition">receives the index and first cp of the span that contains the specified
/// position or, if the position is past the end of the vector, the index and cp just past the end of
/// the last span.</param>
/// <returns>Returns true if cp is in range or false if not.</returns>
internal bool FindSpan(int cp, SpanPosition latestPosition, out SpanPosition spanPosition)
{
Debug.Assert(cp >= 0);
var spanCount = _spans.Count;
int spanIndex, spanCP;
if (cp == 0)
{
// CP zero always corresponds to span index zero
spanIndex = 0;
spanCP = 0;
}
else if (cp >= latestPosition.Offset || cp * 2 < latestPosition.Offset)
{
// One of the following is true:
// 1. cp is after the latest position (the most recently accessed span)
// 2. cp is closer to zero than to the latest position
if (cp >= latestPosition.Offset)
{
// case 1: scan forward from the latest position
spanIndex = latestPosition.Index;
spanCP = latestPosition.Offset;
}
else
{
// case 2: scan forward from the start of the span vector
spanIndex = 0;
spanCP = 0;
}
// Scan forward until we find the Span that contains the specified CP or
// reach the end of the SpanVector
for (; spanIndex < spanCount; ++spanIndex)
{
var spanLength = _spans[spanIndex].length;
if (cp < spanCP + spanLength)
{
break;
}
spanCP += spanLength;
}
}
else
{
// The specified CP is before the latest position but closer to it than to zero;
// therefore scan backwards from the latest position
spanIndex = latestPosition.Index;
spanCP = latestPosition.Offset;
while (spanCP > cp)
{
Debug.Assert(spanIndex > 0);
spanCP -= _spans[--spanIndex].length;
}
}
// Return index and cp of span in out param.
spanPosition = new SpanPosition(spanIndex, spanCP);
// Return true if the span is in range.
return spanIndex != spanCount;
}
/// <summary>
/// Set an element as a value to a character range
/// </summary>
/// <remarks>
/// Implementation of span element object must implement Object.Equals to
/// avoid runtime reflection cost on equality check of nested-type object.
/// </remarks>
public void SetValue(int first, int length, object element)
{
Set(first, length, element, SpanVector.s_equals, new SpanPosition());
}
/// <summary>
/// Set an element as a value to a character range; takes a SpanPosition of a recently accessed
/// span for performance and returns a known valid SpanPosition
/// </summary>
public SpanPosition SetValue(int first, int length, object element, SpanPosition spanPosition)
{
return Set(first, length, element, SpanVector.s_equals, spanPosition);
}
/// <summary>
/// Set an element as a reference to a character range
/// </summary>
public void SetReference(int first, int length, object element)
{
Set(first, length, element, SpanVector.s_referenceEquals, new SpanPosition());
}
/// <summary>
/// Set an element as a reference to a character range; takes a SpanPosition of a recently accessed
/// span for performance and returns a known valid SpanPosition
/// </summary>
public SpanPosition SetReference(int first, int length, object element, SpanPosition spanPosition)
{
return Set(first, length, element, SpanVector.s_referenceEquals, spanPosition);
}
private SpanPosition Set(int first, int length, object? element, Equals equals, SpanPosition spanPosition)
{
var inRange = FindSpan(first, spanPosition, out spanPosition);
// fs = index of first span partly or completely updated
// fc = character index at start of fs
var fs = spanPosition.Index;
var fc = spanPosition.Offset;
// Find the span that contains the first affected cp
if (!inRange)
{
// The first cp is past the end of the last span
if (fc < first)
{
// Create default run up to first
Add(new Span(Default, first - fc));
}
if (Count > 0
&& equals(_spans[Count - 1].element, element))
{
// New Element matches end Element, just extend end Element
_spans[Count - 1].length += length;
// Make sure fs and fc still agree
if (fs == Count)
{
fc += length;
}
}
else
{
Add(new Span(element, length));
}
}
else
{
// Now find the last span affected by the update
var ls = fs;
var lc = fc;
while (ls < Count
&& lc + _spans[ls].length <= first + length)
{
lc += _spans[ls].length;
ls++;
}
// ls = first span following update to remain unchanged in part or in whole
// lc = character index at start of ls
// expand update region backwards to include existing Spans of identical
// Element type
if (first == fc)
{
// Item at [fs] is completely replaced. Check prior item
if (fs > 0
&& equals(_spans[fs - 1].element, element))
{
// Expand update area over previous run of equal classification
fs--;
fc -= _spans[fs].length;
first = fc;
length += _spans[fs].length;
}
}
else
{
// Item at [fs] is partially replaced. Check if it is same as update
if (equals(_spans[fs].element, element))
{
// Expand update area back to start of first affected equal valued run
length = first + length - fc;
first = fc;
}
}
// Expand update region forwards to include existing Spans of identical
// Element type
if (ls < Count
&& equals(_spans[ls].element, element))
{
// Extend update region to end of existing split run
length = lc + _spans[ls].length - first;
lc += _spans[ls].length;
ls++;
}
// If no old Spans remain beyond area affected by update, handle easily:
if (ls >= Count)
{
// None of the old span list extended beyond the update region
if (fc < first)
{
// Updated region leaves some of [fs]
if (Count != fs + 2)
{
if (!Resize(fs + 2))
throw new OutOfMemoryException();
}
_spans[fs].length = first - fc;
_spans[fs + 1] = new Span(element, length);
}
else
{
// Updated item replaces [fs]
if (Count != fs + 1)
{
if (!Resize(fs + 1))
throw new OutOfMemoryException();
}
_spans[fs] = new Span(element, length);
}
}
else
{
// Record partial element type at end, if any
object? trailingElement = null;
var trailingLength = 0;
if (first + length > lc)
{
trailingElement = _spans[ls].element;
trailingLength = lc + _spans[ls].length - (first + length);
}
// Calculate change in number of Spans
var spanDelta = 1 // The new span
+ (first > fc ? 1 : 0) // part span at start
- (ls - fs); // existing affected span count
// Note part span at end doesn't affect the calculation - the run may need
// updating, but it doesn't need creating.
if (spanDelta < 0)
{
DeleteInternal(fs + 1, -spanDelta);
}
else if (spanDelta > 0)
{
Insert(fs + 1, spanDelta);
// Initialize inserted Spans
for (var i = 0; i < spanDelta; i++)
{
_spans[fs + 1 + i] = new Span(null, 0);
}
}
// Assign Element values
// Correct Length of split span before updated range
if (fc < first)
{
_spans[fs].length = first - fc;
fs++;
fc = first;
}
// Record Element type for updated range
_spans[fs] = new Span(element, length);
fs++;
fc += length;
// Correct Length of split span following updated range
if (lc < first + length)
{
_spans[fs] = new Span(trailingElement, trailingLength);
}
}
}
// Return a known valid span position.
return new SpanPosition(fs, fc);
}
/// <summary>
/// Number of spans in vector
/// </summary>
public int Count
{
get { return _spans.Count; }
}
/// <summary>
/// The default element of vector
/// </summary>
public object? Default { get; }
/// <summary>
/// Span accessor at nth element
/// </summary>
public Span this[int index]
{
get { return _spans[index]; }
}
private bool Resize(int targetCount)
{
if (targetCount > Count)
{
for (var c = 0; c < targetCount - Count; c++)
{
_spans.Add(new Span(null, 0));
}
}
else if (targetCount < Count)
{
DeleteInternal(targetCount, Count - targetCount);
}
return true;
}
}
/// <summary>
/// Equality check method
/// </summary>
internal delegate bool Equals(object? first, object? second);
/// <summary>
/// ENUMERATOR: To navigate a vector through its element
/// </summary>
internal sealed class SpanEnumerator : IEnumerator
{
private readonly SpanVector _spans;
private int _current; // current span
internal SpanEnumerator(SpanVector spans)
{
_spans = spans;
_current = -1;
}
/// <summary>
/// The current span
/// </summary>
public object Current
{
get { return _spans[_current]; }
}
/// <summary>
/// Move to the next span
/// </summary>
public bool MoveNext()
{
_current++;
return _current < _spans.Count;
}
/// <summary>
/// Reset the enumerator
/// </summary>
public void Reset()
{
_current = -1;
}
}
/// <summary>
/// Represents a Span's position as a pair of related values: its index in the
/// SpanVector its CP offset from the start of the SpanVector.
/// </summary>
internal readonly struct SpanPosition
{
internal SpanPosition(int spanIndex, int spanOffset)
{
Index = spanIndex;
Offset = spanOffset;
}
internal int Index { get; }
internal int Offset { get; }
}
/// <summary>
/// RIDER: To navigate a vector through character index
/// </summary>
internal struct SpanRider
{
private readonly SpanVector _spans; // vector of spans
private SpanPosition _spanPosition; // index and cp of current span
public SpanRider(SpanVector spans, SpanPosition latestPosition) : this(spans, latestPosition, latestPosition.Offset)
{
}
public SpanRider(SpanVector spans, SpanPosition latestPosition = new SpanPosition(), int cp = 0)
{
_spans = spans;
_spanPosition = new SpanPosition();
CurrentPosition = 0;
Length = 0;
At(latestPosition, cp);
}
/// <summary>
/// Move rider to a given cp
/// </summary>
public bool At(int cp)
{
return At(_spanPosition, cp);
}
public bool At(SpanPosition latestPosition, int cp)
{
var inRange = _spans.FindSpan(cp, latestPosition, out _spanPosition);
if (inRange)
{
// cp is in range:
// - Length is the distance to the end of the span
// - CurrentPosition is cp
Length = _spans[_spanPosition.Index].length - (cp - _spanPosition.Offset);
CurrentPosition = cp;
}
else
{
// cp is out of range:
// - Length is the default span length
// - CurrentPosition is the end of the last span
Length = int.MaxValue;
CurrentPosition = _spanPosition.Offset;
}
return inRange;
}
/// <summary>
/// The first cp of the current span
/// </summary>
public int CurrentSpanStart
{
get { return _spanPosition.Offset; }
}
/// <summary>
/// The length of current span start from the current cp
/// </summary>
public int Length { get; private set; }
/// <summary>
/// The current position
/// </summary>
public int CurrentPosition { get; private set; }
/// <summary>
/// The element of the current span
/// </summary>
public object? CurrentElement
{
get { return _spanPosition.Index >= _spans.Count ? _spans.Default : _spans[_spanPosition.Index].element; }
}
/// <summary>
/// Index of the span at the current position.
/// </summary>
public int CurrentSpanIndex
{
get { return _spanPosition.Index; }
}
/// <summary>
/// Index and first cp of the current span.
/// </summary>
public SpanPosition SpanPosition
{
get { return _spanPosition; }
}
}
}

12
src/Skia/Avalonia.Skia/DrawingContextImpl.cs

@ -441,17 +441,7 @@ namespace Avalonia.Skia
}
}
}
/// <inheritdoc />
public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text)
{
using (var paint = CreatePaint(_fillPaint, foreground, text.Bounds.Size))
{
var textImpl = (FormattedTextImpl) text;
textImpl.Draw(this, Canvas, origin.ToSKPoint(), paint, _canTextUseLcdRendering);
}
}
/// <inheritdoc />
public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun)
{

838
src/Skia/Avalonia.Skia/FormattedTextImpl.cs

@ -1,838 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Utilities;
using SkiaSharp;
namespace Avalonia.Skia
{
/// <summary>
/// Skia formatted text implementation.
/// </summary>
internal class FormattedTextImpl : IFormattedTextImpl
{
private static readonly ThreadLocal<SKTextBlobBuilder> t_builder = new ThreadLocal<SKTextBlobBuilder>(() => new SKTextBlobBuilder());
private const float MAX_LINE_WIDTH = 10000;
private readonly List<KeyValuePair<FBrushRange, IBrush>> _foregroundBrushes =
new List<KeyValuePair<FBrushRange, IBrush>>();
private readonly List<FormattedTextLine> _lines = new List<FormattedTextLine>();
private readonly SKPaint _paint;
private readonly List<Rect> _rects = new List<Rect>();
public string Text { get; }
private readonly TextWrapping _wrapping;
private Size _constraint = new Size(double.PositiveInfinity, double.PositiveInfinity);
private float _lineHeight = 0;
private float _lineOffset = 0;
private Rect _bounds;
private List<AvaloniaFormattedTextLine> _skiaLines;
private ReadOnlySlice<ushort> _glyphs;
private ReadOnlySlice<float> _advances;
public FormattedTextImpl(
string text,
Typeface typeface,
double fontSize,
TextAlignment textAlignment,
TextWrapping wrapping,
Size constraint,
IReadOnlyList<FormattedTextStyleSpan> spans)
{
Text = text ?? string.Empty;
UpdateGlyphInfo(Text, typeface.GlyphTypeface, (float)fontSize);
_paint = new SKPaint
{
TextEncoding = SKTextEncoding.Utf16,
IsStroke = false,
IsAntialias = true,
LcdRenderText = true,
SubpixelText = true,
IsLinearText = true,
Typeface = ((GlyphTypefaceImpl)typeface.GlyphTypeface.PlatformImpl).Typeface,
TextSize = (float)fontSize,
TextAlign = textAlignment.ToSKTextAlign()
};
//currently Skia does not measure properly with Utf8 !!!
//Paint.TextEncoding = SKTextEncoding.Utf8;
_wrapping = wrapping;
_constraint = constraint;
if (spans != null)
{
foreach (var span in spans)
{
if (span.ForegroundBrush != null)
{
SetForegroundBrush(span.ForegroundBrush, span.StartIndex, span.Length);
}
}
}
Rebuild();
}
public Size Constraint => _constraint;
public Rect Bounds => _bounds;
public IEnumerable<FormattedTextLine> GetLines()
{
return _lines;
}
public TextHitTestResult HitTestPoint(Point point)
{
float y = (float)point.Y;
AvaloniaFormattedTextLine line = default;
float nextTop = 0;
foreach(var currentLine in _skiaLines)
{
if(currentLine.Top <= y)
{
line = currentLine;
nextTop = currentLine.Top + currentLine.Height;
}
else
{
nextTop = currentLine.Top;
break;
}
}
if (!line.Equals(default(AvaloniaFormattedTextLine)))
{
var rects = GetRects();
for (int c = line.Start; c < line.Start + line.TextLength; c++)
{
var rc = rects[c];
if (rc.Contains(point))
{
return new TextHitTestResult
{
IsInside = !(line.TextLength > line.Length),
TextPosition = c,
IsTrailing = (point.X - rc.X) > rc.Width / 2
};
}
}
int offset = 0;
if (point.X >= (rects[line.Start].X + line.Width) && line.Length > 0)
{
offset = line.TextLength > line.Length ?
line.Length : (line.Length - 1);
}
if (y < nextTop)
{
return new TextHitTestResult
{
IsInside = false,
TextPosition = line.Start + offset,
IsTrailing = Text.Length == (line.Start + offset + 1)
};
}
}
bool end = point.X > _bounds.Width || point.Y > _lines.Sum(l => l.Height);
return new TextHitTestResult()
{
IsInside = false,
IsTrailing = end,
TextPosition = end ? Text.Length - 1 : 0
};
}
public Rect HitTestTextPosition(int index)
{
if (string.IsNullOrEmpty(Text))
{
var alignmentOffset = TransformX(0, 0, _paint.TextAlign);
return new Rect(alignmentOffset, 0, 0, _lineHeight);
}
var rects = GetRects();
if (index >= Text.Length || index < 0)
{
var r = rects.LastOrDefault();
var c = Text[Text.Length - 1];
switch (c)
{
case '\n':
case '\r':
return new Rect(r.X, r.Y, 0, _lineHeight);
default:
return new Rect(r.X + r.Width, r.Y, 0, _lineHeight);
}
}
return rects[index];
}
public IEnumerable<Rect> HitTestTextRange(int index, int length)
{
List<Rect> result = new List<Rect>();
var rects = GetRects();
int lastIndex = index + length - 1;
foreach (var line in _skiaLines.Where(l =>
(l.Start + l.Length) > index &&
lastIndex >= l.Start &&
!l.IsEmptyTrailingLine))
{
int lineEndIndex = line.Start + (line.Length > 0 ? line.Length - 1 : 0);
double left = rects[line.Start > index ? line.Start : index].X;
double right = rects[lineEndIndex > lastIndex ? lastIndex : lineEndIndex].Right;
result.Add(new Rect(left, line.Top, right - left, line.Height));
}
return result;
}
public override string ToString()
{
return Text;
}
private void DrawTextBlob(int start, int length, float x, float y, SKCanvas canvas, SKPaint paint)
{
if(length == 0)
{
return;
}
var glyphs = _glyphs.Buffer.Span.Slice(start, length);
var advances = _advances.Buffer.Span.Slice(start, length);
var builder = t_builder.Value;
var buffer = builder.AllocateHorizontalRun(_paint.ToFont(), length, 0);
buffer.SetGlyphs(glyphs);
var positions = buffer.GetPositionSpan();
var pos = 0f;
for (int i = 0; i < advances.Length; i++)
{
positions[i] = pos;
pos += advances[i];
}
var blob = builder.Build();
if(blob != null)
{
canvas.DrawText(blob, x, y, paint);
}
}
internal void Draw(DrawingContextImpl context,
SKCanvas canvas,
SKPoint origin,
DrawingContextImpl.PaintWrapper foreground,
bool canUseLcdRendering)
{
/* TODO: This originated from Native code, it might be useful for debugging character positions as
* we improve the FormattedText support. Will need to port this to C# obviously. Rmove when
* not needed anymore.
SkPaint dpaint;
ctx->Canvas->save();
ctx->Canvas->translate(origin.fX, origin.fY);
for (int c = 0; c < Lines.size(); c++)
{
dpaint.setARGB(255, 0, 0, 0);
SkRect rc;
rc.fLeft = 0;
rc.fTop = Lines[c].Top;
rc.fRight = Lines[c].Width;
rc.fBottom = rc.fTop + LineOffset;
ctx->Canvas->drawRect(rc, dpaint);
}
for (int c = 0; c < Length; c++)
{
dpaint.setARGB(255, c % 10 * 125 / 10 + 125, (c * 7) % 10 * 250 / 10, (c * 13) % 10 * 250 / 10);
dpaint.setStyle(SkPaint::kFill_Style);
ctx->Canvas->drawRect(Rects[c], dpaint);
}
ctx->Canvas->restore();
*/
using (var paint = _paint.Clone())
{
IDisposable currd = null;
var currentWrapper = foreground;
SKPaint currentPaint = null;
try
{
ApplyWrapperTo(ref currentPaint, foreground, ref currd, paint, canUseLcdRendering);
bool hasCusomFGBrushes = _foregroundBrushes.Any();
for (int c = 0; c < _skiaLines.Count; c++)
{
AvaloniaFormattedTextLine line = _skiaLines[c];
float x = TransformX(origin.X, line.Width, paint.TextAlign);
if (!hasCusomFGBrushes)
{
DrawTextBlob(line.Start, line.Length, x, origin.Y + line.Top + _lineOffset, canvas, paint);
}
else
{
float currX = x;
float measure;
int len;
float factor;
switch (paint.TextAlign)
{
case SKTextAlign.Left:
factor = 0;
break;
case SKTextAlign.Center:
factor = 0.5f;
break;
case SKTextAlign.Right:
factor = 1;
break;
default:
throw new ArgumentOutOfRangeException();
}
currX -= line.Length == 0 ? 0 : MeasureText(line.Start, line.Length) * factor;
for (int i = line.Start; i < line.Start + line.Length;)
{
var fb = GetNextForegroundBrush(ref line, i, out len);
if (fb != null)
{
//TODO: figure out how to get the brush size
currentWrapper = context.CreatePaint(new SKPaint { IsAntialias = true }, fb,
new Size());
}
else
{
if (!currentWrapper.Equals(foreground)) currentWrapper.Dispose();
currentWrapper = foreground;
}
measure = MeasureText(i, len);
currX += measure * factor;
ApplyWrapperTo(ref currentPaint, currentWrapper, ref currd, paint, canUseLcdRendering);
DrawTextBlob(i, len, currX, origin.Y + line.Top + _lineOffset, canvas, paint);
i += len;
currX += measure * (1 - factor);
}
}
}
}
finally
{
if (!currentWrapper.Equals(foreground)) currentWrapper.Dispose();
currd?.Dispose();
}
}
}
private static void ApplyWrapperTo(ref SKPaint current, DrawingContextImpl.PaintWrapper wrapper,
ref IDisposable curr, SKPaint paint, bool canUseLcdRendering)
{
if (current == wrapper.Paint)
return;
curr?.Dispose();
curr = wrapper.ApplyTo(paint);
paint.LcdRenderText = canUseLcdRendering;
}
private static bool IsBreakChar(char c)
{
//white space or zero space whitespace
return char.IsWhiteSpace(c) || c == '\u200B';
}
private static int LineBreak(string textInput, int textIndex, int stop,
SKPaint paint, float maxWidth,
out int trailingCount)
{
int lengthBreak;
if (maxWidth == -1)
{
lengthBreak = stop - textIndex;
}
else
{
string subText = textInput.Substring(textIndex, stop - textIndex);
lengthBreak = (int)paint.BreakText(subText, maxWidth, out _);
}
//Check for white space or line breakers before the lengthBreak
int startIndex = textIndex;
int index = textIndex;
int word_start = textIndex;
bool prevBreak = true;
trailingCount = 0;
while (index < stop)
{
int prevText = index;
char currChar = textInput[index++];
bool currBreak = IsBreakChar(currChar);
if (!currBreak && prevBreak)
{
word_start = prevText;
}
prevBreak = currBreak;
if (index > startIndex + lengthBreak)
{
if (currBreak)
{
// eat the rest of the whitespace
while (index < stop && IsBreakChar(textInput[index]))
{
index++;
}
trailingCount = index - prevText;
}
else
{
// backup until a whitespace (or 1 char)
if (word_start == startIndex)
{
if (prevText > startIndex)
{
index = prevText;
}
}
else
{
index = word_start;
}
}
break;
}
if ('\n' == currChar)
{
int ret = index - startIndex;
int lineBreakSize = 1;
if (index < stop)
{
currChar = textInput[index++];
if ('\r' == currChar)
{
ret = index - startIndex;
++lineBreakSize;
}
}
trailingCount = lineBreakSize;
return ret;
}
if ('\r' == currChar)
{
int ret = index - startIndex;
int lineBreakSize = 1;
if (index < stop)
{
currChar = textInput[index++];
if ('\n' == currChar)
{
ret = index - startIndex;
++lineBreakSize;
}
}
trailingCount = lineBreakSize;
return ret;
}
}
return index - startIndex;
}
private void BuildRects()
{
// Build character rects
SKTextAlign align = _paint.TextAlign;
for (int li = 0; li < _skiaLines.Count; li++)
{
var line = _skiaLines[li];
float prevRight = TransformX(0, line.Width, align);
double nextTop = line.Top + line.Height;
if (li + 1 < _skiaLines.Count)
{
nextTop = _skiaLines[li + 1].Top;
}
for (int i = line.Start; i < line.Start + line.TextLength; i++)
{
var w = line.IsEmptyTrailingLine ? 0 : _advances[i];
_rects.Add(new Rect(
prevRight,
line.Top,
w,
nextTop - line.Top));
prevRight += w;
}
}
}
private IBrush GetNextForegroundBrush(ref AvaloniaFormattedTextLine line, int index, out int length)
{
IBrush result = null;
int len = length = line.Start + line.Length - index;
if (_foregroundBrushes.Any())
{
var bi = _foregroundBrushes.FindIndex(b =>
b.Key.StartIndex <= index &&
b.Key.EndIndex > index
);
if (bi > -1)
{
var match = _foregroundBrushes[bi];
len = match.Key.EndIndex - index;
result = match.Value;
if (len > 0 && len < length)
{
length = len;
}
}
int endIndex = index + length;
int max = bi == -1 ? _foregroundBrushes.Count : bi;
var next = _foregroundBrushes.Take(max)
.Where(b => b.Key.StartIndex < endIndex &&
b.Key.StartIndex > index)
.OrderBy(b => b.Key.StartIndex)
.FirstOrDefault();
if (next.Value != null)
{
length = next.Key.StartIndex - index;
}
}
return result;
}
private List<Rect> GetRects()
{
if (Text.Length > _rects.Count)
{
BuildRects();
}
return _rects;
}
private void Rebuild()
{
var length = Text.Length;
_lines.Clear();
_rects.Clear();
_skiaLines = new List<AvaloniaFormattedTextLine>();
int curOff = 0;
float curY = 0;
var metrics = _paint.FontMetrics;
var mTop = metrics.Top; // The greatest distance above the baseline for any glyph (will be <= 0).
var mBottom = metrics.Bottom; // The greatest distance below the baseline for any glyph (will be >= 0).
var mLeading = metrics.Leading; // The recommended distance to add between lines of text (will be >= 0).
var mDescent = metrics.Descent; //The recommended distance below the baseline. Will be >= 0.
var mAscent = metrics.Ascent; //The recommended distance above the baseline. Will be <= 0.
var lastLineDescent = mBottom - mDescent;
// This seems like the best measure of full vertical extent
// matches Direct2D line height
_lineHeight = mDescent - mAscent + metrics.Leading;
// Rendering is relative to baseline
_lineOffset = (-metrics.Ascent);
string subString;
float widthConstraint = double.IsPositiveInfinity(_constraint.Width)
? -1
: (float)_constraint.Width;
while(curOff < length)
{
float lineWidth = -1;
int measured;
int trailingnumber = 0;
float constraint = -1;
if (_wrapping == TextWrapping.Wrap)
{
constraint = widthConstraint <= 0 ? MAX_LINE_WIDTH : widthConstraint;
if (constraint > MAX_LINE_WIDTH)
constraint = MAX_LINE_WIDTH;
}
measured = LineBreak(Text, curOff, length, _paint, constraint, out trailingnumber);
AvaloniaFormattedTextLine line = new AvaloniaFormattedTextLine();
line.Start = curOff;
line.TextLength = measured;
subString = Text.Substring(line.Start, line.TextLength);
lineWidth = MeasureText(line.Start, line.TextLength);
line.Length = measured - trailingnumber;
line.Width = lineWidth;
line.Height = _lineHeight;
line.Top = curY;
_skiaLines.Add(line);
curY += _lineHeight;
curY += mLeading;
curOff += measured;
//if this is the last line and there are trailing newline characters then
//insert a additional line
if (curOff >= length)
{
var subStringMinusNewlines = subString.TrimEnd('\n', '\r');
var lengthDiff = subString.Length - subStringMinusNewlines.Length;
if (lengthDiff > 0)
{
AvaloniaFormattedTextLine lastLine = new AvaloniaFormattedTextLine();
lastLine.TextLength = lengthDiff;
lastLine.Start = curOff - lengthDiff;
var lastLineWidth = MeasureText(line.Start, line.TextLength);
lastLine.Length = 0;
lastLine.Width = lastLineWidth;
lastLine.Height = _lineHeight;
lastLine.Top = curY;
lastLine.IsEmptyTrailingLine = true;
_skiaLines.Add(lastLine);
curY += _lineHeight;
curY += mLeading;
}
}
}
// Now convert to Avalonia data formats
_lines.Clear();
float maxX = 0;
for (var c = 0; c < _skiaLines.Count; c++)
{
var w = _skiaLines[c].Width;
if (maxX < w)
maxX = w;
_lines.Add(new FormattedTextLine(_skiaLines[c].TextLength, _skiaLines[c].Height));
}
if (_skiaLines.Count == 0)
{
_lines.Add(new FormattedTextLine(0, _lineHeight));
_bounds = new Rect(0, 0, 0, _lineHeight);
}
else
{
var lastLine = _skiaLines[_skiaLines.Count - 1];
_bounds = new Rect(0, 0, maxX, lastLine.Top + lastLine.Height);
if (double.IsPositiveInfinity(Constraint.Width))
{
return;
}
switch (_paint.TextAlign)
{
case SKTextAlign.Center:
_bounds = new Rect(Constraint).CenterRect(_bounds);
break;
case SKTextAlign.Right:
_bounds = new Rect(
Constraint.Width - _bounds.Width,
0,
_bounds.Width,
_bounds.Height);
break;
}
}
}
private float MeasureText(int start, int length)
{
var width = 0f;
for (int i = start; i < start + length; i++)
{
var advance = _advances[i];
width += advance;
}
return width;
}
private void UpdateGlyphInfo(string text, GlyphTypeface glyphTypeface, float fontSize)
{
var glyphs = new ushort[text.Length];
var advances = new float[text.Length];
var scale = fontSize / glyphTypeface.DesignEmHeight;
var width = 0f;
var characters = text.AsSpan();
for (int i = 0; i < characters.Length; i++)
{
var c = characters[i];
float advance;
ushort glyph;
switch (c)
{
case (char)0:
{
glyph = glyphTypeface.GetGlyph(0x200B);
advance = 0;
break;
}
case '\t':
{
glyph = glyphTypeface.GetGlyph(' ');
advance = glyphTypeface.GetGlyphAdvance(glyph) * scale * 4;
break;
}
default:
{
glyph = glyphTypeface.GetGlyph(c);
advance = glyphTypeface.GetGlyphAdvance(glyph) * scale;
break;
}
}
glyphs[i] = glyph;
advances[i] = advance;
width += advance;
}
_glyphs = new ReadOnlySlice<ushort>(glyphs);
_advances = new ReadOnlySlice<float>(advances);
}
private float TransformX(float originX, float lineWidth, SKTextAlign align)
{
float x = 0;
if (align == SKTextAlign.Left)
{
x = originX;
}
else
{
double width = Constraint.Width > 0 && !double.IsPositiveInfinity(Constraint.Width) ?
Constraint.Width :
_bounds.Width;
switch (align)
{
case SKTextAlign.Center: x = originX + (float)(width - lineWidth) / 2; break;
case SKTextAlign.Right: x = originX + (float)(width - lineWidth); break;
}
}
return x;
}
private void SetForegroundBrush(IBrush brush, int startIndex, int length)
{
var key = new FBrushRange(startIndex, length);
int index = _foregroundBrushes.FindIndex(v => v.Key.Equals(key));
if (index > -1)
{
_foregroundBrushes.RemoveAt(index);
}
if (brush != null)
{
brush = brush.ToImmutable();
_foregroundBrushes.Insert(0, new KeyValuePair<FBrushRange, IBrush>(key, brush));
}
}
private struct AvaloniaFormattedTextLine
{
public float Height;
public int Length;
public int Start;
public int TextLength;
public float Top;
public float Width;
public bool IsEmptyTrailingLine;
};
private struct FBrushRange
{
public FBrushRange(int startIndex, int length)
{
StartIndex = startIndex;
Length = length;
}
public int EndIndex => StartIndex + Length;
public int Length { get; private set; }
public int StartIndex { get; private set; }
public bool Intersects(int index, int len) =>
(index + len) > StartIndex &&
(StartIndex + Length) > index;
public override string ToString()
{
return $"{StartIndex}-{EndIndex}";
}
}
}
}

44
src/Skia/Avalonia.Skia/PlatformRenderInterface.cs

@ -37,19 +37,6 @@ namespace Avalonia.Skia
_skiaGpu = new GlSkiaGpu(gl, maxResourceBytes);
}
/// <inheritdoc />
public IFormattedTextImpl CreateFormattedText(
string text,
Typeface typeface,
double fontSize,
TextAlignment textAlignment,
TextWrapping wrapping,
Size constraint,
IReadOnlyList<FormattedTextStyleSpan> spans)
{
return new FormattedTextImpl(text, typeface, fontSize, textAlignment, wrapping, constraint, spans);
}
public IGeometryImpl CreateEllipseGeometry(Rect rect) => new EllipseGeometryImpl(rect);
public IGeometryImpl CreateLineGeometry(Point p1, Point p2) => new LineGeometryImpl(p1, p2);
@ -208,7 +195,7 @@ namespace Avalonia.Skia
/// <inheritdoc />
public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun)
{
var count = glyphRun.GlyphIndices.Length;
var count = glyphRun.GlyphIndices.Count;
var textBlobBuilder = s_textBlobBuilderThreadLocal.Value;
var glyphTypeface = (GlyphTypefaceImpl)glyphRun.GlyphTypeface.PlatformImpl;
@ -224,11 +211,18 @@ namespace Avalonia.Skia
var scale = (float)(glyphRun.FontRenderingEmSize / glyphTypeface.DesignEmHeight);
if (glyphRun.GlyphOffsets.IsEmpty)
if (glyphRun.GlyphOffsets == null)
{
if (glyphTypeface.IsFixedPitch)
{
textBlobBuilder.AddRun(glyphRun.GlyphIndices.Buffer.Span, s_font);
var buffer = textBlobBuilder.AllocateRun(s_font, glyphRun.GlyphIndices.Count, 0, 0);
var glyphs = buffer.GetGlyphSpan();
for (int i = 0; i < glyphs.Length; i++)
{
glyphs[i] = glyphRun.GlyphIndices[i];
}
textBlob = textBlobBuilder.Build();
}
@ -244,7 +238,7 @@ namespace Avalonia.Skia
{
positions[i] = (float)width;
if (glyphRun.GlyphAdvances.IsEmpty)
if (glyphRun.GlyphAdvances == null)
{
width += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale;
}
@ -254,7 +248,12 @@ namespace Avalonia.Skia
}
}
buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span);
var glyphs = buffer.GetGlyphSpan();
for (int i = 0; i < glyphs.Length; i++)
{
glyphs[i] = glyphRun.GlyphIndices[i];
}
textBlob = textBlobBuilder.Build();
}
@ -273,7 +272,7 @@ namespace Avalonia.Skia
glyphPositions[i] = new SKPoint((float)(currentX + glyphOffset.X), (float)glyphOffset.Y);
if (glyphRun.GlyphAdvances.IsEmpty)
if (glyphRun.GlyphAdvances == null)
{
currentX += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale;
}
@ -283,7 +282,12 @@ namespace Avalonia.Skia
}
}
buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span);
var glyphs = buffer.GetGlyphSpan();
for (int i = 0; i < glyphs.Length; i++)
{
glyphs[i] = glyphRun.GlyphIndices[i];
}
textBlob = textBlobBuilder.Build();
}

135
src/Skia/Avalonia.Skia/TextShaperImpl.cs

@ -1,146 +1,139 @@
using System;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
using HarfBuzzSharp;
using Buffer = HarfBuzzSharp.Buffer;
using GlyphInfo = HarfBuzzSharp.GlyphInfo;
namespace Avalonia.Skia
{
internal class TextShaperImpl : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
public ShapedBuffer ShapeText(ReadOnlySlice<char> text, GlyphTypeface typeface, double fontRenderingEmSize,
CultureInfo culture, sbyte bidiLevel)
{
using (var buffer = new Buffer())
{
FillBuffer(buffer, text);
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
buffer.AddUtf16(text.Buffer.Span, text.Start, text.Length);
MergeBreakPair(buffer);
buffer.GuessSegmentProperties();
var glyphTypeface = typeface.GlyphTypeface;
buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft;
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
var font = ((GlyphTypefaceImpl)typeface.PlatformImpl).Font;
font.Shape(buffer);
if (buffer.Direction == Direction.RightToLeft)
{
buffer.Reverse();
}
font.GetScale(out var scaleX, out _);
var textScale = fontRenderingEmSize / scaleX;
var bufferLength = buffer.Length;
var shapedBuffer = new ShapedBuffer(text, bufferLength, typeface, fontRenderingEmSize, bidiLevel);
var glyphInfos = buffer.GetGlyphInfoSpan();
var glyphPositions = buffer.GetGlyphPositionSpan();
var glyphIndices = new ushort[bufferLength];
var clusters = new ushort[bufferLength];
for (var i = 0; i < bufferLength; i++)
{
var sourceInfo = glyphInfos[i];
double[] glyphAdvances = null;
var glyphIndex = (ushort)sourceInfo.Codepoint;
Vector[] glyphOffsets = null;
var glyphCluster = (int)sourceInfo.Cluster;
for (var i = 0; i < bufferLength; i++)
{
glyphIndices[i] = (ushort)glyphInfos[i].Codepoint;
var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale);
clusters[i] = (ushort)glyphInfos[i].Cluster;
var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale);
if (!glyphTypeface.IsFixedPitch)
{
SetAdvance(glyphPositions, i, textScale, ref glyphAdvances);
}
var targetInfo = new Media.TextFormatting.GlyphInfo(glyphIndex, glyphCluster, glyphAdvance, glyphOffset);
SetOffset(glyphPositions, i, textScale, ref glyphOffsets);
shapedBuffer[i] = targetInfo;
}
return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices),
new ReadOnlySlice<double>(glyphAdvances),
new ReadOnlySlice<Vector>(glyphOffsets),
text,
new ReadOnlySlice<ushort>(clusters),
buffer.Direction == Direction.LeftToRight ? 0 : 1);
return shapedBuffer;
}
}
private static void FillBuffer(Buffer buffer, ReadOnlySlice<char> text)
private static void MergeBreakPair(Buffer buffer)
{
buffer.ContentType = ContentType.Unicode;
var length = buffer.Length;
var i = 0;
var glyphInfos = buffer.GetGlyphInfoSpan();
var second = glyphInfos[length - 1];
while (i < text.Length)
if (!new Codepoint((int)second.Codepoint).IsBreakChar)
{
var codepoint = Codepoint.ReadAt(text, i, out var count);
return;
}
var cluster = (uint)(text.Start + i);
if (length > 1 && glyphInfos[length - 2].Codepoint == '\r' && second.Codepoint == '\n')
{
var first = glyphInfos[length - 2];
first.Codepoint = '\u200C';
second.Codepoint = '\u200C';
second.Cluster = first.Cluster;
if (codepoint.IsBreakChar)
unsafe
{
if (i + 1 < text.Length)
fixed (GlyphInfo* p = &glyphInfos[length - 2])
{
var nextCodepoint = Codepoint.ReadAt(text, i + 1, out _);
if (nextCodepoint == '\n' && codepoint == '\r')
{
count++;
buffer.Add('\u200C', cluster);
buffer.Add('\u200D', cluster);
}
else
{
buffer.Add('\u200C', cluster);
}
*p = first;
}
else
fixed (GlyphInfo* p = &glyphInfos[length - 1])
{
buffer.Add('\u200C', cluster);
*p = second;
}
}
else
}
else
{
second.Codepoint = '\u200C';
unsafe
{
buffer.Add(codepoint, cluster);
fixed (GlyphInfo* p = &glyphInfos[length - 1])
{
*p = second;
}
}
i += count;
}
}
private static void SetOffset(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale,
ref Vector[] offsetBuffer)
private static Vector GetGlyphOffset(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale)
{
var position = glyphPositions[index];
if (position.XOffset == 0 && position.YOffset == 0)
{
return;
}
offsetBuffer ??= new Vector[glyphPositions.Length];
var offsetX = position.XOffset * textScale;
var offsetY = position.YOffset * textScale;
offsetBuffer[index] = new Vector(offsetX, offsetY);
return new Vector(offsetX, offsetY);
}
private static void SetAdvance(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale,
ref double[] advanceBuffer)
private static double GetGlyphAdvance(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale)
{
advanceBuffer ??= new double[glyphPositions.Length];
// Depends on direction of layout
// advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale;
advanceBuffer[index] = glyphPositions[index].XAdvance * textScale;
// glyphPositions[index].YAdvance * textScale;
return glyphPositions[index].XAdvance * textScale;
}
}
}

25
src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs

@ -115,25 +115,6 @@ namespace Avalonia.Direct2D1
SharpDX.Configuration.EnableReleaseOnFinalizer = true;
}
public IFormattedTextImpl CreateFormattedText(
string text,
Typeface typeface,
double fontSize,
TextAlignment textAlignment,
TextWrapping wrapping,
Size constraint,
IReadOnlyList<FormattedTextStyleSpan> spans)
{
return new FormattedTextImpl(
text,
typeface,
fontSize,
textAlignment,
wrapping,
constraint,
spans);
}
public IRenderTarget CreateRenderTarget(IEnumerable<object> surfaces)
{
foreach (var s in surfaces)
@ -241,7 +222,7 @@ namespace Avalonia.Direct2D1
{
var glyphTypeface = (GlyphTypefaceImpl)glyphRun.GlyphTypeface.PlatformImpl;
var glyphCount = glyphRun.GlyphIndices.Length;
var glyphCount = glyphRun.GlyphIndices.Count;
var run = new SharpDX.DirectWrite.GlyphRun
{
@ -262,7 +243,7 @@ namespace Avalonia.Direct2D1
var scale = (float)(glyphRun.FontRenderingEmSize / glyphTypeface.DesignEmHeight);
if (glyphRun.GlyphAdvances.IsEmpty)
if (glyphRun.GlyphAdvances == null)
{
for (var i = 0; i < glyphCount; i++)
{
@ -281,7 +262,7 @@ namespace Avalonia.Direct2D1
}
}
if (glyphRun.GlyphOffsets.IsEmpty)
if (glyphRun.GlyphOffsets == null)
{
return new GlyphRunImpl(run);
}

23
src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs

@ -376,29 +376,6 @@ namespace Avalonia.Direct2D1.Media
}
}
/// <summary>
/// Draws text.
/// </summary>
/// <param name="foreground">The foreground brush.</param>
/// <param name="origin">The upper-left corner of the text.</param>
/// <param name="text">The text.</param>
public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text)
{
if (!string.IsNullOrEmpty(text.Text))
{
var impl = (FormattedTextImpl)text;
using (var brush = CreateBrush(foreground, impl.Bounds.Size))
using (var renderer = new AvaloniaTextRenderer(this, _deviceContext, brush.PlatformBrush))
{
if (brush.PlatformBrush != null)
{
impl.TextLayout.Draw(renderer, (float)origin.X, (float)origin.Y);
}
}
}
}
/// <summary>
/// Draws a glyph run.
/// </summary>

129
src/Windows/Avalonia.Direct2D1/Media/FormattedTextImpl.cs

@ -1,129 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media;
using Avalonia.Platform;
using DWrite = SharpDX.DirectWrite;
namespace Avalonia.Direct2D1.Media
{
internal class FormattedTextImpl : IFormattedTextImpl
{
public FormattedTextImpl(
string text,
Typeface typeface,
double fontSize,
TextAlignment textAlignment,
TextWrapping wrapping,
Size constraint,
IReadOnlyList<FormattedTextStyleSpan> spans)
{
Text = text;
var font = ((GlyphTypefaceImpl)typeface.GlyphTypeface.PlatformImpl).DWFont;
var familyName = font.FontFamily.FamilyNames.GetString(0);
using (var textFormat = new DWrite.TextFormat(
Direct2D1Platform.DirectWriteFactory,
familyName,
font.FontFamily.FontCollection,
(DWrite.FontWeight)typeface.Weight,
(DWrite.FontStyle)typeface.Style,
DWrite.FontStretch.Normal,
(float)fontSize))
{
textFormat.WordWrapping =
wrapping == TextWrapping.Wrap ? DWrite.WordWrapping.Wrap : DWrite.WordWrapping.NoWrap;
TextLayout = new DWrite.TextLayout(
Direct2D1Platform.DirectWriteFactory,
Text ?? string.Empty,
textFormat,
(float)constraint.Width,
(float)constraint.Height) { TextAlignment = textAlignment.ToDirect2D() };
}
if (spans != null)
{
foreach (var span in spans)
{
ApplySpan(span);
}
}
Bounds = Measure();
}
public Size Constraint => new Size(TextLayout.MaxWidth, TextLayout.MaxHeight);
public Rect Bounds { get; }
public string Text { get; }
public DWrite.TextLayout TextLayout { get; }
public IEnumerable<FormattedTextLine> GetLines()
{
var result = TextLayout.GetLineMetrics();
return from line in result select new FormattedTextLine(line.Length, line.Height);
}
public TextHitTestResult HitTestPoint(Point point)
{
var result = TextLayout.HitTestPoint(
(float)point.X,
(float)point.Y,
out var isTrailingHit,
out var isInside);
return new TextHitTestResult
{
IsInside = isInside,
TextPosition = result.TextPosition,
IsTrailing = isTrailingHit,
};
}
public Rect HitTestTextPosition(int index)
{
var result = TextLayout.HitTestTextPosition(index, false, out _, out _);
return new Rect(result.Left, result.Top, result.Width, result.Height);
}
public IEnumerable<Rect> HitTestTextRange(int index, int length)
{
var result = TextLayout.HitTestTextRange(index, length, 0, 0);
return result.Select(x => new Rect(x.Left, x.Top, x.Width, x.Height));
}
private void ApplySpan(FormattedTextStyleSpan span)
{
if (span.Length > 0)
{
if (span.ForegroundBrush != null)
{
TextLayout.SetDrawingEffect(
new BrushWrapper(span.ForegroundBrush.ToImmutable()),
new DWrite.TextRange(span.StartIndex, span.Length));
}
}
}
private Rect Measure()
{
var metrics = TextLayout.Metrics;
var width = metrics.WidthIncludingTrailingWhitespace;
if (float.IsNaN(width))
{
width = metrics.Width;
}
return new Rect(
TextLayout.Metrics.Left,
TextLayout.Metrics.Top,
width,
TextLayout.Metrics.Height);
}
}
}

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

@ -1,145 +1,142 @@
using System;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
using HarfBuzzSharp;
using Buffer = HarfBuzzSharp.Buffer;
using GlyphInfo = HarfBuzzSharp.GlyphInfo;
namespace Avalonia.Direct2D1.Media
{
internal class TextShaperImpl : ITextShaperImpl
internal class TextShaperImpl : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
public ShapedBuffer ShapeText(ReadOnlySlice<char> text, GlyphTypeface typeface, double fontRenderingEmSize,
CultureInfo culture, sbyte bidiLevel)
{
using (var buffer = new Buffer())
{
FillBuffer(buffer, text);
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
buffer.AddUtf16(text.Buffer.Span, text.Start, text.Length);
MergeBreakPair(buffer);
buffer.GuessSegmentProperties();
var glyphTypeface = typeface.GlyphTypeface;
buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft;
var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
var font = ((GlyphTypefaceImpl)typeface.PlatformImpl).Font;
font.Shape(buffer);
if (buffer.Direction == Direction.RightToLeft)
{
buffer.Reverse();
}
font.GetScale(out var scaleX, out _);
var textScale = fontRenderingEmSize / scaleX;
var bufferLength = buffer.Length;
var shapedBuffer = new ShapedBuffer(text, bufferLength, typeface, fontRenderingEmSize, bidiLevel);
var glyphInfos = buffer.GetGlyphInfoSpan();
var glyphPositions = buffer.GetGlyphPositionSpan();
var glyphIndices = new ushort[bufferLength];
var clusters = new ushort[bufferLength];
for (var i = 0; i < bufferLength; i++)
{
var sourceInfo = glyphInfos[i];
double[] glyphAdvances = null;
var glyphIndex = (ushort)sourceInfo.Codepoint;
Vector[] glyphOffsets = null;
var glyphCluster = (int)sourceInfo.Cluster;
for (var i = 0; i < bufferLength; i++)
{
glyphIndices[i] = (ushort)glyphInfos[i].Codepoint;
var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale);
clusters[i] = (ushort)glyphInfos[i].Cluster;
var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale);
if (!glyphTypeface.IsFixedPitch)
{
SetAdvance(glyphPositions, i, textScale, ref glyphAdvances);
}
var targetInfo =
new Avalonia.Media.TextFormatting.GlyphInfo(glyphIndex, glyphCluster, glyphAdvance,
glyphOffset);
SetOffset(glyphPositions, i, textScale, ref glyphOffsets);
shapedBuffer[i] = targetInfo;
}
return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices),
new ReadOnlySlice<double>(glyphAdvances),
new ReadOnlySlice<Vector>(glyphOffsets),
text,
new ReadOnlySlice<ushort>(clusters));
return shapedBuffer;
}
}
private static void FillBuffer(Buffer buffer, ReadOnlySlice<char> text)
private static void MergeBreakPair(Buffer buffer)
{
buffer.ContentType = ContentType.Unicode;
var length = buffer.Length;
var i = 0;
var glyphInfos = buffer.GetGlyphInfoSpan();
var second = glyphInfos[length - 1];
while (i < text.Length)
if (!new Codepoint((int)second.Codepoint).IsBreakChar)
{
var codepoint = Codepoint.ReadAt(text, i, out var count);
return;
}
var cluster = (uint)(text.Start + i);
if (length > 1 && glyphInfos[length - 2].Codepoint == '\r' && second.Codepoint == '\n')
{
var first = glyphInfos[length - 2];
first.Codepoint = '\u200C';
second.Codepoint = '\u200C';
second.Cluster = first.Cluster;
if (codepoint.IsBreakChar)
unsafe
{
if (i + 1 < text.Length)
fixed (GlyphInfo* p = &glyphInfos[length - 2])
{
var nextCodepoint = Codepoint.ReadAt(text, i + 1, out _);
if (nextCodepoint == '\r' && codepoint == '\n' || nextCodepoint == '\n' && codepoint == '\r')
{
count++;
buffer.Add('\u200C', cluster);
buffer.Add('\u200D', cluster);
}
else
{
buffer.Add('\u200C', cluster);
}
*p = first;
}
else
fixed (GlyphInfo* p = &glyphInfos[length - 1])
{
buffer.Add('\u200C', cluster);
*p = second;
}
}
else
}
else
{
second.Codepoint = '\u200C';
unsafe
{
buffer.Add(codepoint, cluster);
fixed (GlyphInfo* p = &glyphInfos[length - 1])
{
*p = second;
}
}
i += count;
}
}
private static void SetOffset(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale,
ref Vector[] offsetBuffer)
private static Vector GetGlyphOffset(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale)
{
var position = glyphPositions[index];
if (position.XOffset == 0 && position.YOffset == 0)
{
return;
}
offsetBuffer ??= new Vector[glyphPositions.Length];
var offsetX = position.XOffset * textScale;
var offsetY = position.YOffset * textScale;
offsetBuffer[index] = new Vector(offsetX, offsetY);
return new Vector(offsetX, offsetY);
}
private static void SetAdvance(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale,
ref double[] advanceBuffer)
private static double GetGlyphAdvance(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale)
{
advanceBuffer ??= new double[glyphPositions.Length];
// Depends on direction of layout
// advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale;
advanceBuffer[index] = glyphPositions[index].XAdvance * textScale;
return glyphPositions[index].XAdvance * textScale;
}
}
}

4
tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs

@ -43,10 +43,6 @@ namespace Avalonia.Benchmarks
{
}
public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text)
{
}
public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun)
{
}

36
tests/Avalonia.Benchmarks/NullFormattedTextImpl.cs

@ -1,36 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia.Media;
using Avalonia.Platform;
namespace Avalonia.Benchmarks
{
internal class NullFormattedTextImpl : IFormattedTextImpl
{
public Size Constraint { get; }
public Rect Bounds { get; }
public string Text { get; }
public IEnumerable<FormattedTextLine> GetLines()
{
throw new NotImplementedException();
}
public TextHitTestResult HitTestPoint(Point point)
{
throw new NotImplementedException();
}
public Rect HitTestTextPosition(int index)
{
throw new NotImplementedException();
}
public IEnumerable<Rect> HitTestTextRange(int index, int length)
{
throw new NotImplementedException();
}
}
}

6
tests/Avalonia.Benchmarks/NullRenderingPlatform.cs

@ -10,12 +10,6 @@ namespace Avalonia.Benchmarks
{
internal class NullRenderingPlatform : IPlatformRenderInterface
{
public IFormattedTextImpl CreateFormattedText(string text, Typeface typeface, double fontSize, TextAlignment textAlignment,
TextWrapping wrapping, Size constraint, IReadOnlyList<FormattedTextStyleSpan> spans)
{
return new NullFormattedTextImpl();
}
public IGeometryImpl CreateEllipseGeometry(Rect rect)
{
return new MockStreamGeometryImpl();

1
tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj

@ -13,6 +13,7 @@
<Import Project="..\..\build\Microsoft.Reactive.Testing.props" />
<Import Project="..\..\build\Base.props" />
<Import Project="..\..\build\SharedVersion.props" />
<Import Project="..\..\build\HarfBuzzSharp.props" />
<ItemGroup>
<ProjectReference Include="..\..\src\Markup\Avalonia.Markup.Xaml.Loader\Avalonia.Markup.Xaml.Loader.csproj" />
<ProjectReference Include="..\..\src\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />

14
tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs

@ -115,7 +115,10 @@ namespace Avalonia.Controls.UnitTests
Text = "1234"
};
target.ApplyTemplate();
target.CaretIndex = 3;
target.Measure(Size.Infinity);
RaiseKeyEvent(target, Key.Right, 0);
Assert.Equal(4, target.CaretIndex);
@ -892,7 +895,9 @@ namespace Avalonia.Controls.UnitTests
standardCursorFactory: Mock.Of<ICursorFactory>());
private static TestServices Services => TestServices.MockThreadingInterface.With(
standardCursorFactory: Mock.Of<ICursorFactory>());
standardCursorFactory: Mock.Of<ICursorFactory>(),
textShaperImpl: new MockTextShaperImpl(),
fontManagerImpl: new MockFontManagerImpl());
private IControlTemplate CreateTemplate()
{
@ -907,6 +912,13 @@ namespace Avalonia.Controls.UnitTests
Priority = BindingPriority.TemplatedParent,
RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent),
},
[!!TextPresenter.CaretIndexProperty] = new Binding
{
Path = "CaretIndex",
Mode = BindingMode.TwoWay,
Priority = BindingPriority.TemplatedParent,
RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent),
}
}.RegisterInNameScope(scope));
}

17
tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs

@ -1,4 +1,5 @@
using Avalonia.Controls.Presenters;
using System.Linq;
using Avalonia.Controls.Presenters;
using Avalonia.UnitTests;
using Xunit;
@ -16,7 +17,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
PasswordChar = '*'
};
Assert.NotNull(target.FormattedText);
Assert.NotNull(target.TextLayout);
}
}
@ -28,7 +29,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
var target = new TextPresenter();
Assert.NotNull(target.FormattedText);
Assert.NotNull(target.TextLayout);
}
}
@ -40,8 +41,14 @@ namespace Avalonia.Controls.UnitTests.Presenters
var target = new TextPresenter { PasswordChar = '*', Text = "Test" };
Assert.NotNull(target.FormattedText);
Assert.Equal("****", target.FormattedText.Text);
target.Measure(Size.Infinity);
Assert.NotNull(target.TextLayout);
var actual = string.Join(null,
target.TextLayout.TextLines.SelectMany(x => x.TextRuns).Select(x => x.Text.Span.ToString()));
Assert.Equal("****", actual);
}
}
}

53
tests/Avalonia.Controls.UnitTests/TextBoxTests.cs

@ -115,6 +115,10 @@ namespace Avalonia.Controls.UnitTests
Text = "1234"
};
target.ApplyTemplate();
target.Measure(Size.Infinity);
target.CaretIndex = 3;
RaiseKeyEvent(target, Key.Right, 0);
@ -209,9 +213,13 @@ namespace Avalonia.Controls.UnitTests
{
TextBox textBox = new TextBox
{
Template = CreateTemplate(),
Text = "First Second Third Fourth",
CaretIndex = 5
SelectionStart = 5,
SelectionEnd = 5
};
textBox.ApplyTemplate();
// (First| Second Third Fourth)
RaiseKeyEvent(textBox, Key.Back, KeyModifiers.Control);
@ -248,9 +256,12 @@ namespace Avalonia.Controls.UnitTests
{
TextBox textBox = new TextBox
{
Template = CreateTemplate(),
Text = "First Second Third Fourth",
CaretIndex = 19
CaretIndex = 19,
};
textBox.ApplyTemplate();
// (First Second Third |Fourth)
RaiseKeyEvent(textBox, Key.Delete, KeyModifiers.Control);
@ -335,6 +346,8 @@ namespace Avalonia.Controls.UnitTests
AcceptsReturn = false,
Text = "1234"
};
target.ApplyTemplate();
RaiseKeyEvent(target, Key.Enter, 0);
@ -352,6 +365,8 @@ namespace Avalonia.Controls.UnitTests
Template = CreateTemplate(),
AcceptsReturn = true
};
target.ApplyTemplate();
RaiseKeyEvent(target, Key.Enter, 0);
@ -370,6 +385,8 @@ namespace Avalonia.Controls.UnitTests
AcceptsReturn = true,
NewLine = "Test"
};
target.ApplyTemplate();
RaiseKeyEvent(target, Key.Enter, 0);
@ -409,6 +426,8 @@ namespace Avalonia.Controls.UnitTests
Template = CreateTemplate(),
Text = "0123456789"
};
target.ApplyTemplate();
target.SelectionStart = 0;
target.SelectionEnd = 9;
@ -431,6 +450,8 @@ namespace Avalonia.Controls.UnitTests
Template = CreateTemplate(),
Text = "0123456789"
};
target.ApplyTemplate();
target.SelectionStart = 8;
target.SelectionEnd = 9;
@ -474,6 +495,8 @@ namespace Avalonia.Controls.UnitTests
Template = CreateTemplate(),
Text = "0123456789"
};
target.ApplyTemplate();
Assert.True(target.SelectedText == "");
@ -494,6 +517,8 @@ namespace Avalonia.Controls.UnitTests
Template = CreateTemplate(),
Text = "0123"
};
target.ApplyTemplate();
target.SelectedText = "AA";
Assert.True(target.Text == "AA0123");
@ -701,7 +726,9 @@ namespace Avalonia.Controls.UnitTests
using (UnitTestApplication.Start(Services))
{
var source = new Class1 { Bar = "bar" };
var target = new TextBox { DataContext = source };
var target = new TextBox { Template = CreateTemplate(), DataContext = source };
target.ApplyTemplate();
target.Bind(TextBox.TextProperty, new Binding("Bar"));
@ -737,6 +764,8 @@ namespace Avalonia.Controls.UnitTests
SelectionEnd = selectionEnd
};
target.Measure(Size.Infinity);
if (fromClipboard)
{
AvaloniaLocator.CurrentMutable.Bind<IClipboard>().ToSingleton<ClipboardStub>();
@ -773,6 +802,7 @@ namespace Avalonia.Controls.UnitTests
AcceptsReturn = true,
AcceptsTab = true
};
target.ApplyTemplate();
target.SelectionStart = 1;
target.SelectionEnd = 3;
AvaloniaLocator.CurrentMutable
@ -837,13 +867,15 @@ namespace Avalonia.Controls.UnitTests
keyboardDevice: () => new KeyboardDevice(),
keyboardNavigation: new KeyboardNavigationHandler(),
inputManager: new InputManager(),
renderInterface: new MockPlatformRenderInterface(),
fontManagerImpl: new MockFontManagerImpl(),
standardCursorFactory: Mock.Of<ICursorFactory>(),
textShaperImpl: new MockTextShaperImpl(),
standardCursorFactory: Mock.Of<ICursorFactory>());
fontManagerImpl: new MockFontManagerImpl());
private static TestServices Services => TestServices.MockThreadingInterface.With(
standardCursorFactory: Mock.Of<ICursorFactory>());
standardCursorFactory: Mock.Of<ICursorFactory>(),
renderInterface: new MockPlatformRenderInterface(),
textShaperImpl: new MockTextShaperImpl(),
fontManagerImpl: new MockFontManagerImpl());
private IControlTemplate CreateTemplate()
{
@ -858,6 +890,13 @@ namespace Avalonia.Controls.UnitTests
Priority = BindingPriority.TemplatedParent,
RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent),
},
[!!TextPresenter.CaretIndexProperty] = new Binding
{
Path = "CaretIndex",
Mode = BindingMode.TwoWay,
Priority = BindingPriority.TemplatedParent,
RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent),
}
}.RegisterInNameScope(scope));
}

4
tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs

@ -86,7 +86,9 @@ namespace Avalonia.Controls.UnitTests
}
private static TestServices Services => TestServices.MockThreadingInterface.With(
standardCursorFactory: Mock.Of<ICursorFactory>());
standardCursorFactory: Mock.Of<ICursorFactory>(),
textShaperImpl: new MockTextShaperImpl(),
fontManagerImpl: new MockFontManagerImpl());
private IControlTemplate CreateTemplate()
{

267
tests/Avalonia.RenderTests/Media/FormattedTextImplTests.cs

@ -1,267 +0,0 @@
using Avalonia.Media;
using Avalonia.Platform;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Xunit;
#if AVALONIA_SKIA
namespace Avalonia.Skia.RenderTests
#else
using Avalonia.Direct2D1.RenderTests;
namespace Avalonia.Direct2D1.RenderTests.Media
#endif
{
public class FormattedTextImplTests : TestBase
{
private const string FontName = "Courier New";
private const double FontSize = 12;
private const double MediumFontSize = 18;
private const double BigFontSize = 32;
private const double FontSizeHeight = 13.594;//real value 13.59375
private const string stringword = "word";
private const string stringmiddle = "The quick brown fox jumps over the lazy dog";
private const string stringmiddle2lines = "The quick brown fox\njumps over the lazy dog";
private const string stringmiddle3lines = "01234567\n\n0123456789";
private const string stringmiddlenewlines = "012345678\r 1234567\r\n 12345678\n0123456789";
private const string stringlong =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis " +
"aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero" +
" at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus " +
"pretium ornare est.";
public FormattedTextImplTests()
: base(@"Media\FormattedText")
{
}
private IFormattedTextImpl Create(string text,
string fontFamily,
double fontSize,
FontStyle fontStyle,
TextAlignment textAlignment,
FontWeight fontWeight,
TextWrapping wrapping,
double widthConstraint)
{
var r = AvaloniaLocator.Current.GetService<IPlatformRenderInterface>();
return r.CreateFormattedText(text,
new Typeface(fontFamily, fontStyle, fontWeight),
fontSize,
textAlignment,
wrapping,
widthConstraint == -1 ? Size.Infinity : new Size(widthConstraint, double.PositiveInfinity),
null);
}
private IFormattedTextImpl Create(string text, double fontSize)
{
return Create(text, FontName, fontSize,
FontStyle.Normal, TextAlignment.Left,
FontWeight.Normal, TextWrapping.NoWrap,
-1);
}
private IFormattedTextImpl Create(string text, double fontSize, TextAlignment alignment, double widthConstraint)
{
return Create(text, FontName, fontSize,
FontStyle.Normal, alignment,
FontWeight.Normal, TextWrapping.NoWrap,
widthConstraint);
}
private IFormattedTextImpl Create(string text, double fontSize, TextWrapping wrap, double widthConstraint)
{
return Create(text, FontName, fontSize,
FontStyle.Normal, TextAlignment.Left,
FontWeight.Normal, wrap,
widthConstraint);
}
[Theory]
[InlineData("", FontSize, 0, FontSizeHeight)]
[InlineData("x", FontSize, 7.20, FontSizeHeight)]
[InlineData(stringword, FontSize, 28.80, FontSizeHeight)]
[InlineData(stringmiddle, FontSize, 309.65, FontSizeHeight)]
[InlineData(stringmiddle, MediumFontSize, 464.48, 20.391)]
[InlineData(stringmiddle, BigFontSize, 825.73, 36.25)]
[InlineData(stringmiddle2lines, FontSize, 165.63, 2 * FontSizeHeight)]
[InlineData(stringmiddle2lines, MediumFontSize, 248.44, 2 * 20.391)]
[InlineData(stringmiddle2lines, BigFontSize, 441.67, 2 * 36.25)]
[InlineData(stringlong, FontSize, 2160.35, FontSizeHeight)]
[InlineData(stringmiddlenewlines, FontSize, 72.01, 4 * FontSizeHeight)]
public void Should_Measure_String_Correctly(string input, double fontSize, double expWidth, double expHeight)
{
var fmt = Create(input, fontSize);
var size = fmt.Bounds.Size;
Assert.Equal(expWidth, size.Width, 2);
Assert.Equal(expHeight, size.Height, 2);
var linesHeight = fmt.GetLines().Sum(l => l.Height);
Assert.Equal(expHeight, linesHeight, 2);
}
[Theory]
[InlineData("", 1, -1, TextWrapping.NoWrap)]
[InlineData("x", 1, -1, TextWrapping.NoWrap)]
[InlineData(stringword, 1, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddle, 1, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddle, 3, 150, TextWrapping.Wrap)]
[InlineData(stringmiddle2lines, 2, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddle2lines, 3, 150, TextWrapping.Wrap)]
[InlineData(stringlong, 1, -1, TextWrapping.NoWrap)]
[InlineData(stringlong, 18, 150, TextWrapping.Wrap)]
[InlineData(stringmiddlenewlines, 4, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddlenewlines, 4, 150, TextWrapping.Wrap)]
public void Should_Break_Lines_String_Correctly(string input,
int linesCount,
double widthConstraint,
TextWrapping wrap)
{
var fmt = Create(input, FontSize, wrap, widthConstraint);
var constrained = fmt;
var lines = constrained.GetLines().ToArray();
Assert.Equal(linesCount, lines.Count());
}
[Theory]
[InlineData("x", 0, 0, true, false, 0)]
[InlineData(stringword, -1, -1, false, false, 0)]
[InlineData(stringword, 25, 13, true, false, 3)]
[InlineData(stringword, 28.70, 13.5, true, true, 3)]
[InlineData(stringword, 30, 13, false, true, 3)]
[InlineData(stringword + "\r\n", 30, 13, false, false, 4)]
[InlineData(stringword + "\r\nnext", 30, 13, false, false, 4)]
[InlineData(stringword, 300, 13, false, true, 3)]
[InlineData(stringword + "\r\n", 300, 13, false, false, 4)]
[InlineData(stringword + "\r\nnext", 300, 13, false, false, 4)]
[InlineData(stringword, 300, 300, false, true, 3)]
//TODO: Direct2D implementation return textposition 6
//but the text is 6 length, can't find the logic for me it should be 5
//[InlineData(stringword + "\r\n", 300, 300, false, false, 6)]
[InlineData(stringword + "\r\nnext", 300, 300, false, true, 9)]
[InlineData(stringword + "\r\nnext", 300, 25, false, true, 9)]
[InlineData(stringword, 28, 15, false, true, 3)]
[InlineData(stringword, 30, 15, false, true, 3)]
[InlineData(stringmiddle3lines, 30, 15, false, false, 9)]
[InlineData(stringmiddle3lines, 500, 13, false, false, 8)]
[InlineData(stringmiddle3lines, 30, 25, false, false, 9)]
[InlineData(stringmiddle3lines, -1, 30, false, false, 10)]
public void Should_HitTestPoint_Correctly(string input,
double x, double y,
bool isInside, bool isTrailing, int pos)
{
var fmt = Create(input, FontSize);
var htRes = fmt.HitTestPoint(new Point(x, y));
Assert.Equal(pos, htRes.TextPosition);
Assert.Equal(isInside, htRes.IsInside);
Assert.Equal(isTrailing, htRes.IsTrailing);
}
[Theory]
[InlineData("", 0, 0, 0, 0, FontSizeHeight)]
[InlineData("x", 0, 0, 0, 7.20, FontSizeHeight)]
[InlineData("x", -1, 7.20, 0, 0, FontSizeHeight)]
[InlineData(stringword, 3, 21.60, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 4, 21.60 + 7.20, 0, 0, FontSizeHeight)]
[InlineData(stringmiddlenewlines, 10, 0, FontSizeHeight, 7.20, FontSizeHeight)]
[InlineData(stringmiddlenewlines, 15, 36.01, FontSizeHeight, 7.20, FontSizeHeight)]
[InlineData(stringmiddlenewlines, 20, 0, 2 * FontSizeHeight, 7.20, FontSizeHeight)]
[InlineData(stringmiddlenewlines, -1, 72.01, 3 * FontSizeHeight, 0, FontSizeHeight)]
public void Should_HitTestPosition_Correctly(string input,
int index, double x, double y, double width, double height)
{
var fmt = Create(input, FontSize);
var r = fmt.HitTestTextPosition(index);
Assert.Equal(x, r.X, 2);
Assert.Equal(y, r.Y, 2);
Assert.Equal(width, r.Width, 2);
Assert.Equal(height, r.Height, 2);
}
[Theory]
[InlineData("x", 0, 200, 200 - 7.20, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 171.20, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 3, 200, 200 - 7.20, 0, 7.20, FontSizeHeight)]
public void Should_HitTestPosition_RigthAlign_Correctly(
string input, int index, double widthConstraint,
double x, double y, double width, double height)
{
//parse expected
var fmt = Create(input, FontSize, TextAlignment.Right, widthConstraint);
var constrained = fmt;
var r = constrained.HitTestTextPosition(index);
Assert.Equal(x, r.X, 2);
Assert.Equal(y, r.Y, 2);
Assert.Equal(width, r.Width, 2);
Assert.Equal(height, r.Height, 2);
}
[Theory]
[InlineData("x", 0, 200, 100 - 7.20 / 2, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 85.6, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 3, 200, 100 + 7.20, 0, 7.20, FontSizeHeight)]
public void Should_HitTestPosition_CenterAlign_Correctly(
string input, int index, double widthConstraint,
double x, double y, double width, double height)
{
//parse expected
var fmt = Create(input, FontSize, TextAlignment.Center, widthConstraint);
var constrained = fmt;
var r = constrained.HitTestTextPosition(index);
Assert.Equal(x, r.X, 2);
Assert.Equal(y, r.Y, 2);
Assert.Equal(width, r.Width, 2);
Assert.Equal(height, r.Height, 2);
}
[Theory]
[InlineData("x", 0, 1, "0,0,7.20,13.59")]
[InlineData(stringword, 0, 4, "0,0,28.80,13.59")]
[InlineData(stringmiddlenewlines, 10, 10, "0,13.59,57.61,13.59")]
[InlineData(stringmiddlenewlines, 10, 20, "0,13.59,57.61,13.59;0,27.19,64.81,13.59")]
[InlineData(stringmiddlenewlines, 10, 15, "0,13.59,57.61,13.59;0,27.19,36.01,13.59")]
[InlineData(stringmiddlenewlines, 15, 15, "36.01,13.59,21.60,13.59;0,27.19,64.81,13.59")]
public void Should_HitTestRange_Correctly(string input,
int index, int length,
string expectedRects)
{
//parse expected result
var rects = expectedRects.Split(';').Select(s =>
{
double[] v = s.Split(',')
.Select(sd => double.Parse(sd, CultureInfo.InvariantCulture)).ToArray();
return new Rect(v[0], v[1], v[2], v[3]);
}).ToArray();
var fmt = Create(input, FontSize);
var htRes = fmt.HitTestTextRange(index, length).ToArray();
Assert.Equal(rects.Length, htRes.Length);
for (int i = 0; i < rects.Length; i++)
{
var exr = rects[i];
var r = htRes[i];
Assert.Equal(exr.X, r.X, 2);
Assert.Equal(exr.Y, r.Y, 2);
Assert.Equal(exr.Width, r.Width, 2);
Assert.Equal(exr.Height, r.Height, 2);
}
}
}
}

249
tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs

@ -1,34 +1,265 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Utilities;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
using Xunit;
using System.Runtime.InteropServices;
#if AVALONIA_SKIA
namespace Avalonia.Skia.RenderTests
#else
using Avalonia.Direct2D1.RenderTests;
namespace Avalonia.Direct2D1.RenderTests.Media
#endif
{
public class TextLayoutTests : TestBase
{
private const string FontName = "Courier New";
private const double FontSize = 12;
private const double MediumFontSize = 18;
private const double BigFontSize = 32;
private const double FontSizeHeight = 13.594;//real value 13.59375
private const string stringword = "word";
private const string stringmiddle = "The quick brown fox jumps over the lazy dog";
private const string stringmiddle2lines = "The quick brown fox\njumps over the lazy dog";
private const string stringmiddle3lines = "01234567\n\n0123456789";
private const string stringmiddlenewlines = "012345678\r 1234567\r\n 12345678\n0123456789";
private const string stringlong =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis " +
"aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero" +
" at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus " +
"pretium ornare est.";
public TextLayoutTests()
: base(@"Media\TextFormatting\TextLayout")
{
}
[Fact]
private TextLayout Create(string text,
string fontFamily,
double fontSize,
FontStyle fontStyle,
TextAlignment textAlignment,
FontWeight fontWeight,
TextWrapping wrapping,
double widthConstraint)
{
var typeface = new Typeface(fontFamily, fontStyle, fontWeight);
var formattedText = new TextLayout(text, typeface, fontSize, null, textAlignment, wrapping,
maxWidth: widthConstraint == -1 ? double.PositiveInfinity : widthConstraint);
return formattedText;
}
private TextLayout Create(string text, double fontSize)
{
return Create(text, FontName, fontSize,
FontStyle.Normal, TextAlignment.Left,
FontWeight.Normal, TextWrapping.NoWrap,
-1);
}
private TextLayout Create(string text, double fontSize, TextAlignment alignment, double widthConstraint)
{
return Create(text, FontName, fontSize,
FontStyle.Normal, alignment,
FontWeight.Normal, TextWrapping.NoWrap,
widthConstraint);
}
private TextLayout Create(string text, double fontSize, TextWrapping wrap, double widthConstraint)
{
return Create(text, FontName, fontSize,
FontStyle.Normal, TextAlignment.Left,
FontWeight.Normal, wrap,
widthConstraint);
}
[Theory]
[InlineData("", FontSize, 0, FontSizeHeight)]
[InlineData("x", FontSize, 7.20, FontSizeHeight)]
[InlineData(stringword, FontSize, 28.80, FontSizeHeight)]
[InlineData(stringmiddle, FontSize, 309.65, FontSizeHeight)]
[InlineData(stringmiddle, MediumFontSize, 464.48, 20.391)]
[InlineData(stringmiddle, BigFontSize, 825.73, 36.25)]
[InlineData(stringmiddle2lines, FontSize, 165.63, 2 * FontSizeHeight)]
[InlineData(stringmiddle2lines, MediumFontSize, 248.44, 2 * 20.391)]
[InlineData(stringmiddle2lines, BigFontSize, 441.67, 2 * 36.25)]
[InlineData(stringlong, FontSize, 2160.35, FontSizeHeight)]
[InlineData(stringmiddlenewlines, FontSize, 72.01, 4 * FontSizeHeight)]
public void Should_Measure_String_Correctly(string input, double fontSize, double expWidth, double expHeight)
{
var fmt = Create(input, fontSize);
Assert.Equal(expWidth, fmt.Size.Width, 2);
Assert.Equal(expHeight, fmt.Size.Height, 2);
}
[Theory]
[InlineData("", 1, -1, TextWrapping.NoWrap)]
[InlineData("x", 1, -1, TextWrapping.NoWrap)]
[InlineData(stringword, 1, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddle, 1, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddle, 3, 150, TextWrapping.Wrap)]
[InlineData(stringmiddle2lines, 2, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddle2lines, 3, 150, TextWrapping.Wrap)]
[InlineData(stringlong, 1, -1, TextWrapping.NoWrap)]
[InlineData(stringlong, 18, 150, TextWrapping.Wrap)]
[InlineData(stringmiddlenewlines, 4, -1, TextWrapping.NoWrap)]
[InlineData(stringmiddlenewlines, 4, 150, TextWrapping.Wrap)]
public void Should_Break_Lines_String_Correctly(string input,
int linesCount,
double widthConstraint,
TextWrapping wrap)
{
var fmt = Create(input, FontSize, wrap, widthConstraint);
var constrained = fmt;
var lines = constrained.TextLines.ToArray();
Assert.Equal(linesCount, lines.Count());
}
[Theory]
[InlineData("x", 0, 0, true, false, 0)]
[InlineData(stringword, -1, -1, false, false, 0)]
[InlineData(stringword, 25, 13, true, false, 3)]
[InlineData(stringword, 28.70, 13.5, true, true, 4)]
[InlineData(stringword, 30, 13, false, true, 4)]
[InlineData(stringword + "\r\n", 30, 13, false, false, 4)]
[InlineData(stringword + "\r\nnext", 30, 13, false, false, 4)]
[InlineData(stringword, 300, 13, false, true, 4)]
[InlineData(stringword + "\r\n", 300, 13, false, false, 4)]
[InlineData(stringword + "\r\nnext", 300, 13, false, false, 4)]
[InlineData(stringword, 300, 300, false, true, 4)]
//TODO: Direct2D implementation return textposition 6
//but the text is 6 length, can't find the logic for me it should be 5
//[InlineData(stringword + "\r\n", 300, 300, false, false, 6)]
[InlineData(stringword + "\r\nnext", 300, 300, false, true, 10)]
[InlineData(stringword + "\r\nnext", 300, 25, false, true, 10)]
[InlineData(stringword, 28, 15, false, true, 4)]
[InlineData(stringword, 30, 15, false, true, 4)]
[InlineData(stringmiddle3lines, 30, 15, false, false, 9)]
[InlineData(stringmiddle3lines, 500, 13, false, false, 8)]
[InlineData(stringmiddle3lines, 30, 25, false, false, 9)]
[InlineData(stringmiddle3lines, -1, 30, false, false, 10)]
public void Should_HitTestPoint_Correctly(string input,
double x, double y,
bool isInside, bool isTrailing, int pos)
{
var fmt = Create(input, FontSize);
var htRes = fmt.HitTestPoint(new Point(x, y));
Assert.Equal(pos, htRes.TextPosition);
Assert.Equal(isInside, htRes.IsInside);
Assert.Equal(isTrailing, htRes.IsTrailing);
}
[Theory]
[InlineData("", 0, 0, 0, 0, FontSizeHeight)]
[InlineData("x", 0, 0, 0, 7.20, FontSizeHeight)]
[InlineData("x", -1, 7.20, 0, 0, FontSizeHeight)]
[InlineData(stringword, 3, 21.60, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 4, 21.60 + 7.20, 0, 0, FontSizeHeight)]
[InlineData(stringmiddlenewlines, 10, 0, FontSizeHeight, 7.20, FontSizeHeight)]
[InlineData(stringmiddlenewlines, 15, 36.01, FontSizeHeight, 7.20, FontSizeHeight)]
[InlineData(stringmiddlenewlines, 20, 0, 2 * FontSizeHeight, 7.20, FontSizeHeight)]
[InlineData(stringmiddlenewlines, -1, 72.01, 3 * FontSizeHeight, 0, FontSizeHeight)]
public void Should_HitTestPosition_Correctly(string input,
int index, double x, double y, double width, double height)
{
var fmt = Create(input, FontSize);
var r = fmt.HitTestTextPosition(index);
Assert.Equal(x, r.X, 2);
Assert.Equal(y, r.Y, 2);
Assert.Equal(width, r.Width, 2);
Assert.Equal(height, r.Height, 2);
}
[Theory]
[InlineData("x", 0, 200, 200 - 7.20, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 171.20, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 3, 200, 200 - 7.20, 0, 7.20, FontSizeHeight)]
public void Should_HitTestPosition_RigthAlign_Correctly(
string input, int index, double widthConstraint,
double x, double y, double width, double height)
{
//parse expected
var fmt = Create(input, FontSize, TextAlignment.Right, widthConstraint);
var constrained = fmt;
var r = constrained.HitTestTextPosition(index);
Assert.Equal(x, r.X, 2);
Assert.Equal(y, r.Y, 2);
Assert.Equal(width, r.Width, 2);
Assert.Equal(height, r.Height, 2);
}
[Theory]
[InlineData("x", 0, 200, 100 - 7.20 / 2, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 85.6, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 3, 200, 100 + 7.20, 0, 7.20, FontSizeHeight)]
public void Should_HitTestPosition_CenterAlign_Correctly(
string input, int index, double widthConstraint,
double x, double y, double width, double height)
{
//parse expected
var fmt = Create(input, FontSize, TextAlignment.Center, widthConstraint);
var constrained = fmt;
var r = constrained.HitTestTextPosition(index);
Assert.Equal(x, r.X, 2);
Assert.Equal(y, r.Y, 2);
Assert.Equal(width, r.Width, 2);
Assert.Equal(height, r.Height, 2);
}
[Theory]
[InlineData("x", 0, 1, "0,0,7.20,13.59")]
[InlineData(stringword, 0, 4, "0,0,28.80,13.59")]
[InlineData(stringmiddlenewlines, 10, 10, "0,13.59,57.61,13.59")]
[InlineData(stringmiddlenewlines, 10, 20, "0,13.59,57.61,13.59;0,27.19,64.81,13.59")]
[InlineData(stringmiddlenewlines, 10, 15, "0,13.59,57.61,13.59;0,27.19,36.01,13.59")]
[InlineData(stringmiddlenewlines, 15, 15, "36.01,13.59,21.60,13.59;0,27.19,64.81,13.59")]
public void Should_HitTestRange_Correctly(string input,
int index, int length,
string expectedRects)
{
//parse expected result
var rects = expectedRects.Split(';').Select(s =>
{
double[] v = s.Split(',')
.Select(sd => double.Parse(sd, CultureInfo.InvariantCulture)).ToArray();
return new Rect(v[0], v[1], v[2], v[3]);
}).ToArray();
var fmt = Create(input, FontSize);
var htRes = fmt.HitTestTextRange(index, length).ToArray();
Assert.Equal(rects.Length, htRes.Length);
for (int i = 0; i < rects.Length; i++)
{
var exr = rects[i];
var r = htRes[i];
Assert.Equal(exr.X, r.X, 2);
Assert.Equal(exr.Y, r.Y, 2);
Assert.Equal(exr.Width, r.Width, 2);
Assert.Equal(exr.Height, r.Height, 2);
}
}
[Fact]
public async Task TextLayout_Basic()
{
// Skip test on OSX: text rendering is subtly different.

223
tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
@ -9,30 +10,222 @@ namespace Avalonia.Skia.UnitTests.Media
{
public class GlyphRunTests
{
[InlineData("ABC \r", 29, 4, 1)]
[InlineData("ABC \r", 23, 3, 1)]
[InlineData("ABC \r", 17, 2, 1)]
[InlineData("ABC \r", 11, 1, 1)]
[InlineData("ABC \r", 7, 1, 0)]
[InlineData("ABC \r", 5, 0, 1)]
[InlineData("ABC \r", 2, 0, 0)]
[InlineData("ABC012345", 0)] //LeftToRight
[InlineData("זה כיף סתם לשמוע איך תנצח קרפד עץ טוב בגן", 1)] //RightToLeft
[Theory]
public void Should_Get_Distance_From_CharacterHit(string text, double distance, int expectedIndex,
int expectedTrailingLength)
public void Should_Get_Next_CharacterHit(string text, sbyte direction)
{
using (Start())
{
var glyphRun =
TextShaper.Current.ShapeText(text.AsMemory(), Typeface.Default, 10, CultureInfo.CurrentCulture);
var shapedBuffer =
TextShaper.Current.ShapeText(text.AsMemory(), Typeface.Default.GlyphTypeface, 10, CultureInfo.CurrentCulture, direction);
var characterHit = glyphRun.GetCharacterHitFromDistance(distance, out _);
Assert.Equal(expectedIndex, characterHit.FirstCharacterIndex);
var glyphRun = CreateGlyphRun(shapedBuffer);
var characterHit = new CharacterHit(0);
var rects = BuildRects(glyphRun);
if (glyphRun.IsLeftToRight)
{
foreach (var rect in rects)
{
characterHit = glyphRun.GetNextCaretCharacterHit(characterHit);
var distance = glyphRun.GetDistanceFromCharacterHit(characterHit);
Assert.Equal(rect.Right, distance);
}
}
else
{
shapedBuffer.GlyphInfos.Span.Reverse();
foreach (var rect in rects)
{
characterHit = glyphRun.GetNextCaretCharacterHit(characterHit);
var distance = glyphRun.GetDistanceFromCharacterHit(characterHit);
Assert.Equal(rect.Left, distance);
}
}
}
}
[InlineData("ABC012345", 0)] //LeftToRight
[InlineData("זה כיף סתם לשמוע איך תנצח קרפד עץ טוב בגן", 1)] //RightToLeft
[Theory]
public void Should_Get_Previous_CharacterHit(string text, sbyte direction)
{
using (Start())
{
var shapedBuffer =
TextShaper.Current.ShapeText(text.AsMemory(), Typeface.Default.GlyphTypeface, 10, CultureInfo.CurrentCulture, direction);
var glyphRun = CreateGlyphRun(shapedBuffer);
var characterHit = new CharacterHit(text.Length);
var rects = BuildRects(glyphRun);
rects.Reverse();
Assert.Equal(expectedTrailingLength, characterHit.TrailingLength);
if (glyphRun.IsLeftToRight)
{
foreach (var rect in rects)
{
characterHit = glyphRun.GetPreviousCaretCharacterHit(characterHit);
var distance = glyphRun.GetDistanceFromCharacterHit(characterHit);
Assert.Equal(rect.Left, distance);
}
}
else
{
shapedBuffer.GlyphInfos.Span.Reverse();
foreach (var rect in rects)
{
characterHit = glyphRun.GetPreviousCaretCharacterHit(characterHit);
var distance = glyphRun.GetDistanceFromCharacterHit(characterHit);
Assert.Equal(rect.Right, distance);
}
}
}
}
[InlineData("ABC012345", 0)] //LeftToRight
[InlineData("זה כיף סתם לשמוע איך תנצח קרפד עץ טוב בגן", 1)] //RightToLeft
[Theory]
public void Should_Get_CharacterHit_From_Distance(string text, sbyte direction)
{
using (Start())
{
var shapedBuffer =
TextShaper.Current.ShapeText(text.AsMemory(), Typeface.Default.GlyphTypeface, 10, CultureInfo.CurrentCulture, direction);
var glyphRun = CreateGlyphRun(shapedBuffer);
if (glyphRun.IsLeftToRight)
{
var characterHit =
glyphRun.GetCharacterHitFromDistance(glyphRun.Metrics.WidthIncludingTrailingWhitespace, out _);
Assert.Equal(glyphRun.Characters.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
}
else
{
shapedBuffer.GlyphInfos.Span.Reverse();
var characterHit =
glyphRun.GetCharacterHitFromDistance(0, out _);
Assert.Equal(glyphRun.Characters.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
}
var rects = BuildRects(glyphRun);
var lastCluster = -1;
var index = 0;
if (!glyphRun.IsLeftToRight)
{
rects.Reverse();
}
foreach (var rect in rects)
{
var currentCluster = glyphRun.GlyphClusters[index];
while (currentCluster == lastCluster && index + 1 < glyphRun.GlyphClusters.Count)
{
currentCluster = glyphRun.GlyphClusters[++index];
}
//Non trailing edge
var distance = glyphRun.IsLeftToRight ? rect.Left : rect.Right;
var characterHit = glyphRun.GetCharacterHitFromDistance(distance, out _);
Assert.Equal(currentCluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
lastCluster = currentCluster;
index++;
}
}
}
private static List<Rect> BuildRects(GlyphRun glyphRun)
{
var height = glyphRun.Size.Height;
var currentX = glyphRun.IsLeftToRight ? 0d : glyphRun.Metrics.WidthIncludingTrailingWhitespace;
var rects = new List<Rect>(glyphRun.GlyphAdvances!.Count);
var lastCluster = -1;
for (var index = 0; index < glyphRun.GlyphAdvances.Count; index++)
{
var currentCluster = glyphRun.GlyphClusters![index];
var advance = glyphRun.GlyphAdvances[index];
if (lastCluster != currentCluster)
{
if (glyphRun.IsLeftToRight)
{
rects.Add(new Rect(currentX, 0, advance, height));
}
else
{
rects.Add(new Rect(currentX - advance, 0, advance, height));
}
}
else
{
var rect = rects[index - 1];
rects.Remove(rect);
rect = glyphRun.IsLeftToRight ?
rect.WithWidth(rect.Width + advance) :
new Rect(rect.X - advance, 0, rect.Width + advance, height);
rects.Add(rect);
}
if (glyphRun.IsLeftToRight)
{
currentX += advance;
}
else
{
currentX -= advance;
}
lastCluster = currentCluster;
}
return rects;
}
private static GlyphRun CreateGlyphRun(ShapedBuffer shapedBuffer)
{
return new GlyphRun(
shapedBuffer.GlyphTypeface,
shapedBuffer.FontRenderingEmSize,
shapedBuffer.Text,
shapedBuffer.GlyphIndices,
shapedBuffer.GlyphAdvances,
shapedBuffer.GlyphOffsets,
shapedBuffer.GlyphClusters,
shapedBuffer.BidiLevel);
}
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

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

@ -163,8 +163,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Equal(4, textLine.TextRuns[0].Text.Length);
var firstRun = textLine.TextRuns[0];
Assert.Equal(4, firstRun.Text.Length);
}
}
@ -204,40 +206,6 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(expectedNumberOfLines, numberOfLines);
}
}
[Fact]
public void Should_Wrap_RightToLeft()
{
using (Start())
{
const string text =
"قطاعات الصناعة على الشبكة العالمية انترنيت ويونيكود، حيث ستتم، على الصعيدين الدولي والمحلي على حد سواء";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var currentTextSourceIndex = 0;
while (currentTextSourceIndex < text.Length)
{
var textLine =
formatter.FormatLine(textSource, currentTextSourceIndex, 50,
new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap));
var glyphClusters = textLine.TextRuns.Cast<ShapedTextCharacters>()
.SelectMany(x => x.GlyphRun.GlyphClusters).ToArray();
Assert.True(glyphClusters[0] >= glyphClusters[^1]);
Assert.Equal(currentTextSourceIndex, glyphClusters[^1]);
currentTextSourceIndex += textLine.TextRange.Length;
}
}
}
[InlineData("Whether to turn off HTTPS. This option only applies if Individual, " +
"IndividualB2C, SingleOrg, or MultiOrg aren't used for &#8209;&#8209;auth."
@ -435,25 +403,61 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[InlineData(TextAlignment.Left)]
[InlineData(TextAlignment.Center)]
[InlineData(TextAlignment.Right)]
[InlineData("0123456789", TextAlignment.Left, FlowDirection.LeftToRight)]
[InlineData("0123456789", TextAlignment.Center, FlowDirection.LeftToRight)]
[InlineData("0123456789", TextAlignment.Right, FlowDirection.LeftToRight)]
[InlineData("0123456789", TextAlignment.Left, FlowDirection.RightToLeft)]
[InlineData("0123456789", TextAlignment.Center, FlowDirection.RightToLeft)]
[InlineData("0123456789", TextAlignment.Right, FlowDirection.RightToLeft)]
[InlineData("שנבגק", TextAlignment.Left, FlowDirection.RightToLeft)]
[InlineData("שנבגק", TextAlignment.Center, FlowDirection.RightToLeft)]
[InlineData("שנבגק", TextAlignment.Right, FlowDirection.RightToLeft)]
[Theory]
public void Should_Align_TextLine(TextAlignment textAlignment)
public void Should_Align_TextLine(string text, TextAlignment textAlignment, FlowDirection flowDirection)
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textAlignment);
var paragraphProperties = new GenericTextParagraphProperties(flowDirection, textAlignment, true, true,
defaultProperties, TextWrapping.NoWrap, 0, 0);
var textSource = new SingleBufferTextSource("0123456789", defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, 100, paragraphProperties);
var expectedOffset = TextLine.GetParagraphOffsetX(textLine.Width, 100, textAlignment);
var expectedOffset = 0d;
if (flowDirection == FlowDirection.LeftToRight)
{
switch (textAlignment)
{
case TextAlignment.Center:
expectedOffset = 50 - textLine.Width / 2;
break;
case TextAlignment.Right:
expectedOffset = 100 - textLine.WidthIncludingTrailingWhitespace;
break;
}
}
else
{
switch (textAlignment)
{
case TextAlignment.Left:
expectedOffset = 100 - textLine.WidthIncludingTrailingWhitespace;
break;
case TextAlignment.Center:
expectedOffset = 50 - textLine.Width / 2;
break;
}
}
Assert.Equal(expectedOffset, textLine.Start);
}
}
@ -475,7 +479,57 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.NotNull(textLine.TextLineBreak?.RemainingCharacters);
}
}
[InlineData("פעילות הבינאום, W3C!")]
[InlineData("abcABC")]
[InlineData("זה כיף סתם לשמוע איך תנצח קרפד עץ טוב בגן")]
[InlineData("טטטט abcDEF טטטט")]
[Theory]
public void Should_Not_Alter_TextRuns_After_TextStyles_Were_Applied(string text)
{
using (Start())
{
var formatter = new TextFormatterImpl();
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var paragraphProperties =
new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.NoWrap);
var foreground = new SolidColorBrush(Colors.Red).ToImmutable();
var expectedTextLine = formatter.FormatLine(new SingleBufferTextSource(text, defaultProperties),
0, double.PositiveInfinity, paragraphProperties);
var expectedRuns = expectedTextLine.TextRuns.Cast<ShapedTextCharacters>().ToList();
var expectedGlyphs = expectedRuns.SelectMany(x => x.GlyphRun.GlyphIndices).ToList();
for (var i = 0; i < text.Length; i++)
{
for (var j = 1; i + j < text.Length; j++)
{
var spans = new[]
{
new ValueSpan<TextRunProperties>(i, j,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var textSource = new FormattedTextSource(text.AsMemory(), defaultProperties, spans);
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProperties);
var shapedRuns = textLine.TextRuns.Cast<ShapedTextCharacters>().ToList();
var actualGlyphs = shapedRuns.SelectMany(x => x.GlyphRun.GlyphIndices).ToList();
Assert.Equal(expectedGlyphs, actualGlyphs);
}
}
}
}
public static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

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

@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
@ -13,6 +14,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
private const string SingleLineText = "0123456789";
private const string MultiLineText = "01 23 45 678\r\rabc def gh ij";
private const string RightToLeftText = "זה כיף סתם לשמוע איך תנצח קרפד עץ טוב בגן";
[InlineData("01234\r01234\r", 3)]
[InlineData("01234\r01234", 2)]
@ -59,7 +61,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(2, textRun.Text.Length);
var actual = textRun.Text.Buffer.Span.ToString();
var actual = textRun.Text.Span.ToString();
Assert.Equal("1 ", actual);
@ -67,54 +69,127 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[Fact]
public void Should_Not_Alter_Lines_After_TextStyleSpan_Was_Applied()
[InlineData(27)]
[InlineData(22)]
[Theory]
public void Should_Wrap_And_Apply_Style(int length)
{
using (Start())
{
var text = "Multiline TextBox with TextWrapping.";
var foreground = new SolidColorBrush(Colors.Red).ToImmutable();
var expected = new TextLayout(
MultiLineText,
text,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textWrapping: TextWrapping.Wrap,
maxWidth: 25);
var expectedLines = expected.TextLines.Select(x => MultiLineText.Substring(x.TextRange.Start,
maxWidth: 200);
var expectedLines = expected.TextLines.Select(x => text.Substring(x.TextRange.Start,
x.TextRange.Length)).ToList();
for (var i = 4; i < MultiLineText.Length; i++)
var spans = new[]
{
var spans = new[]
{
new ValueSpan<TextRunProperties>(0, i,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
new ValueSpan<TextRunProperties>(0, length,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var actual = new TextLayout(
MultiLineText,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textWrapping: TextWrapping.Wrap,
maxWidth: 25,
textStyleOverrides: spans);
var actual = new TextLayout(
text,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textWrapping: TextWrapping.Wrap,
maxWidth: 200,
textStyleOverrides: spans);
var actualLines = actual.TextLines.Select(x => text.Substring(x.TextRange.Start,
x.TextRange.Length)).ToList();
Assert.Equal(expectedLines.Count, actualLines.Count);
var actualLines = actual.TextLines.Select(x => MultiLineText.Substring(x.TextRange.Start,
x.TextRange.Length)).ToList();
Assert.Equal(expectedLines.Count, actualLines.Count);
for (var j = 0; j < actual.TextLines.Count; j++)
{
var expectedText = expectedLines[j];
var actualText = actualLines[j];
for (var j = 0; j < actual.TextLines.Count; j++)
Assert.Equal(expectedText, actualText);
}
}
}
[Fact]
public void Should_Not_Alter_Lines_After_TextStyleSpan_Was_Applied()
{
using (Start())
{
const string text = "אחד !\ntwo !\nשְׁלוֹשָׁה !";
var red = new SolidColorBrush(Colors.Red).ToImmutable();
var black = Brushes.Black.ToImmutable();
var expected = new TextLayout(
text,
Typeface.Default,
12.0f,
black,
textWrapping: TextWrapping.Wrap);
var expectedGlyphs = expected.TextLines.Select(x => string.Join('|', x.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.ShapedBuffer.GlyphIndices))).ToList();
var outer = new GraphemeEnumerator(text.AsMemory());
var inner = new GraphemeEnumerator(text.AsMemory());
var i = 0;
var j = 0;
while (true)
{
while (inner.MoveNext())
{
var expectedText = expectedLines[j];
var actualText = actualLines[j];
j += inner.Current.Text.Length;
if(j + i > text.Length)
{
break;
}
var spans = new[]
{
new ValueSpan<TextRunProperties>(i, j,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: red))
};
var actual = new TextLayout(
text,
Typeface.Default,
12.0f,
black,
textWrapping: TextWrapping.Wrap,
textStyleOverrides: spans);
var actualGlyphs = actual.TextLines.Select(x => string.Join('|', x.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.ShapedBuffer.GlyphIndices))).ToList();
Assert.Equal(expectedGlyphs.Count, actualGlyphs.Count);
for (var k = 0; k < expectedGlyphs.Count; k++)
{
Assert.Equal(expectedGlyphs[k], actualGlyphs[k]);
}
}
Assert.Equal(expectedText, actualText);
if (!outer.MoveNext())
{
break;
}
inner = new GraphemeEnumerator(text.AsMemory());
i += outer.Current.Text.Length;
}
}
}
@ -184,7 +259,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(2, textRun.Text.Length);
var actual = textRun.Text.Buffer.Span.ToString();
var actual = textRun.Text.Span.ToString();
Assert.Equal("89", actual);
@ -254,7 +329,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(2, textRun.Text.Length);
var actual = textRun.Text.Buffer.Span.ToString();
var actual = textRun.Text.Span.ToString();
Assert.Equal("😄", actual);
@ -384,12 +459,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[Theory]
[InlineData("☝🏿", new ushort[] { 0 })]
[InlineData("☝🏿 ab", new ushort[] { 0, 3, 4, 5 })]
[InlineData("ab ☝🏿", new ushort[] { 0, 1, 2, 3 })]
public void Should_Create_Valid_Clusters_For_Text(string text, ushort[] clusters)
[InlineData("☝🏿", new int[] { 0 })]
[InlineData("☝🏿 ab", new int[] { 0, 3, 4, 5 })]
[InlineData("ab ☝🏿", new int[] { 0, 1, 2, 3 })]
public void Should_Create_Valid_Clusters_For_Text(string text, int[] clusters)
{
using (Start())
{
@ -407,15 +481,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
var shapedRun = (ShapedTextCharacters)textRun;
var glyphRun = shapedRun.GlyphRun;
var glyphClusters = shapedRun.ShapedBuffer.GlyphClusters;
var glyphClusters = glyphRun.GlyphClusters;
var expected = clusters.Skip(index).Take(glyphClusters.Count).ToArray();
var expected = clusters.Skip(index).Take(glyphClusters.Length).ToArray();
Assert.Equal(expected, glyphClusters);
Assert.Equal(expected, glyphRun.GlyphClusters);
index += glyphClusters.Length;
index += glyphClusters.Count;
}
}
}
@ -440,13 +512,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(1, layout.TextLines[0].TextRuns.Count);
Assert.Equal(expectedLength, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters.Length);
Assert.Equal(expectedLength, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters.Count);
Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[5]);
Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).ShapedBuffer.GlyphClusters[5]);
if (expectedLength == 7)
{
Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[6]);
Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).ShapedBuffer.GlyphClusters[6]);
}
}
}
@ -618,6 +690,146 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[Fact]
public void Should_HitTestTextRange_RightToLeft()
{
using (Start())
{
const int start = 0;
const int length = 10;
var layout = new TextLayout(
RightToLeftText,
Typeface.Default,
12,
Brushes.Black);
var selectedText = new TextLayout(
RightToLeftText.Substring(start, length),
Typeface.Default,
12,
Brushes.Black);
var rects = layout.HitTestTextRange(start, length).ToArray();
Assert.Equal(1, rects.Length);
var selectedRect = rects[0];
Assert.Equal(selectedText.Size.Width, selectedRect.Width);
}
}
[Fact]
public void Should_HitTestTextRange_BiDi()
{
const string text = "זה כיףabcDEFזה כיף";
using (Start())
{
var layout = new TextLayout(
text,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable());
var textLine = layout.TextLines[0];
var firstStart = textLine.GetDistanceFromCharacterHit(new CharacterHit(5, 1));
var firstEnd = textLine.GetDistanceFromCharacterHit(new CharacterHit(0));
var secondStart = textLine.GetDistanceFromCharacterHit(new CharacterHit(6));
var secondEnd = textLine.GetDistanceFromCharacterHit(new CharacterHit(6, 1));
var rects = layout.HitTestTextRange(0, 7).ToArray();
Assert.Equal(2, rects.Length);
var firstExpected = rects[0];
Assert.Equal(firstExpected.Left, firstStart);
Assert.Equal(firstExpected.Right, firstEnd);
var secondExpected = rects[1];
Assert.Equal(secondExpected.Left, secondStart);
Assert.Equal(secondExpected.Right, secondEnd);
}
}
[Fact]
public void Should_HitTestTextRange()
{
using (Start())
{
var layout = new TextLayout(
SingleLineText,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable());
var lineRects = layout.HitTestTextRange(0, SingleLineText.Length).ToList();
Assert.Equal(layout.TextLines.Count, lineRects.Count);
for (var i = 0; i < layout.TextLines.Count; i++)
{
var textLine = layout.TextLines[i];
var rect = lineRects[i];
Assert.Equal(textLine.WidthIncludingTrailingWhitespace, rect.Width);
}
var rects = layout.TextLines.SelectMany(x => x.TextRuns.Cast<ShapedTextCharacters>())
.SelectMany(x => x.ShapedBuffer.GlyphAdvances).ToArray();
for (var i = 0; i < SingleLineText.Length; i++)
{
for (var j = 1; i + j < SingleLineText.Length; j++)
{
var expected = rects.AsSpan(i, j).ToArray().Sum();
var actual = layout.HitTestTextRange(i, j).Sum(x => x.Width);
Assert.Equal(expected, actual);
}
}
}
}
[Fact]
public void Should_Wrap_RightToLeft()
{
const string text =
"يَجِبُ عَلَى الإنْسَانِ أن يَكُونَ أمِيْنَاً وَصَادِقَاً مَعَ نَفْسِهِ وَمَعَ أَهْلِهِ وَجِيْرَانِهِ وَأَنْ يَبْذُلَ كُلَّ جُهْدٍ فِي إِعْلاءِ شَأْنِ الوَطَنِ وَأَنْ يَعْمَلَ عَلَى مَا يَجْلِبُ السَّعَادَةَ لِلنَّاسِ . ولَن يَتِمَّ لَهُ ذلِك إِلا بِأَنْ يُقَدِّمَ المَنْفَعَةَ العَامَّةَ عَلَى المَنْفَعَةِ الخَاصَّةِ وَهذَا مِثَالٌ لِلتَّضْحِيَةِ .";
using (Start())
{
for (var maxWidth = 366; maxWidth < 900; maxWidth += 33)
{
var layout = new TextLayout(
text,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textWrapping: TextWrapping.Wrap,
flowDirection: FlowDirection.RightToLeft,
maxWidth: maxWidth);
foreach (var textLine in layout.TextLines)
{
Assert.True(textLine.Width <= maxWidth);
var actual = new string(textLine.TextRuns.Cast<ShapedTextCharacters>().OrderBy(x => x.Text.Start).SelectMany(x => x.Text).ToArray());
var expected = text.Substring(textLine.TextRange.Start, textLine.TextRange.Length);
Assert.Equal(expected, actual);
}
}
}
}
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

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

@ -5,6 +5,7 @@ using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
@ -69,7 +70,105 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
}
[Fact]
public void Should_Get_Next_Caret_CharacterHit_Bidi()
{
const string text = "אבג 1 ABC";
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 = new List<int>();
foreach (var textRun in textLine.TextRuns.OrderBy(x=> x.Text.Start))
{
var shapedRun = (ShapedTextCharacters)textRun;
clusters.AddRange(shapedRun.IsReversed ?
shapedRun.ShapedBuffer.GlyphClusters.Reverse() :
shapedRun.ShapedBuffer.GlyphClusters);
}
var nextCharacterHit = new CharacterHit(0, clusters[1] - clusters[0]);
foreach (var cluster in clusters)
{
Assert.Equal(cluster, nextCharacterHit.FirstCharacterIndex);
nextCharacterHit = textLine.GetNextCaretCharacterHit(nextCharacterHit);
}
var lastCharacterHit = nextCharacterHit;
nextCharacterHit = textLine.GetNextCaretCharacterHit(lastCharacterHit);
Assert.Equal(lastCharacterHit.FirstCharacterIndex, nextCharacterHit.FirstCharacterIndex);
Assert.Equal(lastCharacterHit.TrailingLength, nextCharacterHit.TrailingLength);
}
}
[Fact]
public void Should_Get_Previous_Caret_CharacterHit_Bidi()
{
const string text = "אבג 1 ABC";
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 = new List<int>();
foreach (var textRun in textLine.TextRuns.OrderBy(x=> x.Text.Start))
{
var shapedRun = (ShapedTextCharacters)textRun;
clusters.AddRange(shapedRun.IsReversed ?
shapedRun.ShapedBuffer.GlyphClusters.Reverse() :
shapedRun.ShapedBuffer.GlyphClusters);
}
clusters.Reverse();
var nextCharacterHit = new CharacterHit(text.Length - 1);
foreach (var cluster in clusters)
{
var currentCaretIndex = nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength;
Assert.Equal(cluster, currentCaretIndex);
nextCharacterHit = textLine.GetPreviousCaretCharacterHit(nextCharacterHit);
}
var lastCharacterHit = nextCharacterHit;
nextCharacterHit = textLine.GetPreviousCaretCharacterHit(lastCharacterHit);
Assert.Equal(lastCharacterHit.FirstCharacterIndex, nextCharacterHit.FirstCharacterIndex);
Assert.Equal(lastCharacterHit.TrailingLength, nextCharacterHit.TrailingLength);
}
}
[InlineData("𐐷𐐷𐐷𐐷𐐷")]
[InlineData("01234567🎉\n")]
[InlineData("𐐷1234")]
@ -88,7 +187,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters)
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.ShapedBuffer.GlyphClusters)
.ToArray();
var nextCharacterHit = new CharacterHit(0);
@ -145,7 +244,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters)
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.ShapedBuffer.GlyphClusters)
.ToArray();
var previousCharacterHit = new CharacterHit(text.Length);
@ -193,7 +292,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new MultiBufferTextSource(defaultProperties);
var textSource = new SingleBufferTextSource(s_multiLineText, defaultProperties);
var formatter = new TextFormatterImpl();
@ -209,13 +308,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
for (var i = 0; i < glyphRun.GlyphClusters!.Count; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
var advance = glyphRun.GlyphAdvances[i];
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
@ -225,19 +322,20 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
Assert.Equal(currentDistance,
textLine.GetDistanceFromCharacterHit(new CharacterHit(MultiBufferTextSource.TextRange.Length)));
Assert.Equal(currentDistance,textLine.GetDistanceFromCharacterHit(new CharacterHit(s_multiLineText.Length)));
}
}
[Fact]
public void Should_Get_CharacterHit_From_Distance()
[InlineData("ABC012345")] //LeftToRight
[InlineData("זה כיף סתם לשמוע איך תנצח קרפד עץ טוב בגן")] //RightToLeft
[Theory]
public void Should_Get_CharacterHit_From_Distance(string text)
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new MultiBufferTextSource(defaultProperties);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
@ -245,35 +343,20 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var currentDistance = 0.0;
var isRightToLeft = IsRightToLeft(textLine);
var rects = BuildRects(textLine);
var glyphClusters = BuildGlyphClusters(textLine);
CharacterHit characterHit;
foreach (var run in textLine.TextRuns)
for (var i = 0; i < rects.Count; i++)
{
var textRun = (ShapedTextCharacters)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var cluster = glyphClusters[i];
var rect = rects[i];
var glyph = glyphRun.GlyphIndices[i];
var characterHit = textLine.GetCharacterHitFromDistance(rect.Left);
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
characterHit = textLine.GetCharacterHitFromDistance(currentDistance);
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
currentDistance += advance;
}
Assert.Equal(isRightToLeft ? cluster + 1 : cluster,
characterHit.FirstCharacterIndex + characterHit.TrailingLength);
}
characterHit = textLine.GetCharacterHitFromDistance(textLine.Width);
Assert.Equal(MultiBufferTextSource.TextRange.End, characterHit.FirstCharacterIndex);
}
}
@ -335,15 +418,15 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
[Fact(Skip = "Verify this")]
public void Should_Ignore_NewLine_Characters()
[Fact]
public void TextLineBreak_Should_Contain_TextEndOfLine()
{
using (Start())
{
var defaultTextRunProperties =
new GenericTextRunProperties(Typeface.Default);
const string text = "01234567🎉\n";
const string text = "0123456789";
var source = new SingleBufferTextSource(text, defaultTextRunProperties);
@ -353,32 +436,88 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textLine = formatter.FormatLine(source, 0, double.PositiveInfinity, textParagraphProperties);
var nextCharacterHit = textLine.GetNextCaretCharacterHit(new CharacterHit(8, 2));
Assert.NotNull(textLine.TextLineBreak.TextEndOfLine);
}
}
private static bool IsRightToLeft(TextLine textLine)
{
return textLine.TextRuns.Cast<ShapedTextCharacters>().Any(x => !x.ShapedBuffer.IsLeftToRight);
}
private static List<int> BuildGlyphClusters(TextLine textLine)
{
var glyphClusters = new List<int>();
var shapedTextRuns = textLine.TextRuns.Cast<ShapedTextCharacters>().ToList();
var lastCluster = -1;
foreach (var textRun in shapedTextRuns)
{
var shapedBuffer = textRun.ShapedBuffer;
Assert.Equal(new CharacterHit(8, 2), nextCharacterHit);
var currentClusters = shapedBuffer.GlyphClusters.ToList();
foreach (var currentCluster in currentClusters)
{
if (lastCluster == currentCluster)
{
continue;
}
glyphClusters.Add(currentCluster);
lastCluster = currentCluster;
}
}
return glyphClusters;
}
[Fact]
public void TextLineBreak_Should_Contain_TextEndOfLine()
private static List<Rect> BuildRects(TextLine textLine)
{
using (Start())
{
var defaultTextRunProperties =
new GenericTextRunProperties(Typeface.Default);
var rects = new List<Rect>();
var height = textLine.Height;
const string text = "0123456789";
var currentX = 0d;
var source = new SingleBufferTextSource(text, defaultTextRunProperties);
var lastCluster = -1;
var textParagraphProperties = new GenericTextParagraphProperties(defaultTextRunProperties);
var shapedTextRuns = textLine.TextRuns.Cast<ShapedTextCharacters>().ToList();
var formatter = TextFormatter.Current;
foreach (var textRun in shapedTextRuns)
{
var shapedBuffer = textRun.ShapedBuffer;
for (var index = 0; index < shapedBuffer.GlyphAdvances.Count; index++)
{
var currentCluster = shapedBuffer.GlyphClusters[index];
var advance = shapedBuffer.GlyphAdvances[index];
var textLine = formatter.FormatLine(source, 0, double.PositiveInfinity, textParagraphProperties);
if (lastCluster != currentCluster)
{
rects.Add(new Rect(currentX, 0, advance, height));
}
else
{
var rect = rects[index - 1];
Assert.NotNull(textLine.TextLineBreak.TextEndOfLine);
rects.Remove(rect);
rect = rect.WithWidth(rect.Width + advance);
rects.Add(rect);
}
currentX += advance;
lastCluster = currentCluster;
}
}
return rects;
}
private static IDisposable Start()

16
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs

@ -16,17 +16,17 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
var text = "\n\r\n".AsMemory();
var glyphRun = TextShaper.Current.ShapeText(
var shapedBuffer = TextShaper.Current.ShapeText(
text,
Typeface.Default,
Typeface.Default.GlyphTypeface,
12,
CultureInfo.CurrentCulture);
CultureInfo.CurrentCulture, 0);
Assert.Equal(glyphRun.Characters.Length, text.Length);
Assert.Equal(glyphRun.GlyphClusters.Length, text.Length);
Assert.Equal(0, glyphRun.GlyphClusters[0]);
Assert.Equal(1, glyphRun.GlyphClusters[1]);
Assert.Equal(1, glyphRun.GlyphClusters[2]);
Assert.Equal(shapedBuffer.Text.Length, text.Length);
Assert.Equal(shapedBuffer.GlyphClusters.Count, text.Length);
Assert.Equal(0, shapedBuffer.GlyphClusters[0]);
Assert.Equal(1, shapedBuffer.GlyphClusters[1]);
Assert.Equal(1, shapedBuffer.GlyphClusters[2]);
}
}

1
tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj

@ -8,6 +8,7 @@
<LangVersion>latest</LangVersion>
<AssemblyOriginatorKeyFile>..\..\build\avalonia.snk</AssemblyOriginatorKeyFile>
<SignAssembly>false</SignAssembly>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="..\Avalonia.UnitTests\Assets\*.ttf" />

133
tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs

@ -1,6 +1,7 @@
using System;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
@ -11,137 +12,127 @@ namespace Avalonia.UnitTests
{
public class HarfBuzzTextShaperImpl : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize,
CultureInfo culture)
public ShapedBuffer ShapeText(ReadOnlySlice<char> text, GlyphTypeface typeface, double fontRenderingEmSize,
CultureInfo culture, sbyte bidiLevel)
{
using (var buffer = new Buffer())
{
FillBuffer(buffer, text);
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
buffer.AddUtf16(text.Buffer.Span, text.Start, text.Length);
MergeBreakPair(buffer);
buffer.GuessSegmentProperties();
var glyphTypeface = typeface.GlyphTypeface;
buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft;
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
var font = ((HarfBuzzGlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
var font = ((HarfBuzzGlyphTypefaceImpl)typeface.PlatformImpl).Font;
font.Shape(buffer);
if (buffer.Direction == Direction.RightToLeft)
{
buffer.Reverse();
}
font.GetScale(out var scaleX, out _);
var textScale = fontRenderingEmSize / scaleX;
var bufferLength = buffer.Length;
var shapedBuffer = new ShapedBuffer(text, bufferLength, typeface, fontRenderingEmSize, bidiLevel);
var glyphInfos = buffer.GetGlyphInfoSpan();
var glyphPositions = buffer.GetGlyphPositionSpan();
var glyphIndices = new ushort[bufferLength];
var clusters = new ushort[bufferLength];
for (var i = 0; i < bufferLength; i++)
{
var sourceInfo = glyphInfos[i];
double[] glyphAdvances = null;
var glyphIndex = (ushort)sourceInfo.Codepoint;
Vector[] glyphOffsets = null;
var glyphCluster = (int)sourceInfo.Cluster;
for (var i = 0; i < bufferLength; i++)
{
glyphIndices[i] = (ushort)glyphInfos[i].Codepoint;
var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale);
clusters[i] = (ushort)glyphInfos[i].Cluster;
var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale);
if (!glyphTypeface.IsFixedPitch)
{
SetAdvance(glyphPositions, i, textScale, ref glyphAdvances);
}
var targetInfo = new Media.TextFormatting.GlyphInfo(glyphIndex, glyphCluster, glyphAdvance, glyphOffset);
SetOffset(glyphPositions, i, textScale, ref glyphOffsets);
shapedBuffer[i] = targetInfo;
}
return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices),
new ReadOnlySlice<double>(glyphAdvances),
new ReadOnlySlice<Vector>(glyphOffsets),
text,
new ReadOnlySlice<ushort>(clusters),
buffer.Direction == Direction.LeftToRight ? 0 : 1);
return shapedBuffer;
}
}
private static void FillBuffer(Buffer buffer, ReadOnlySlice<char> text)
private static void MergeBreakPair(Buffer buffer)
{
buffer.ContentType = ContentType.Unicode;
var length = buffer.Length;
var i = 0;
var glyphInfos = buffer.GetGlyphInfoSpan();
var second = glyphInfos[length - 1];
while (i < text.Length)
if (!new Codepoint((int)second.Codepoint).IsBreakChar)
{
var codepoint = Codepoint.ReadAt(text, i, out var count);
return;
}
var cluster = (uint)(text.Start + i);
if (length > 1 && glyphInfos[length - 2].Codepoint == '\r' && second.Codepoint == '\n')
{
var first = glyphInfos[length - 2];
first.Codepoint = '\u200C';
second.Codepoint = '\u200C';
second.Cluster = first.Cluster;
if (codepoint.IsBreakChar)
unsafe
{
if (i + 1 < text.Length)
fixed (HarfBuzzSharp.GlyphInfo* p = &glyphInfos[length - 2])
{
var nextCodepoint = Codepoint.ReadAt(text, i + 1, out _);
if (nextCodepoint == '\n' && codepoint == '\r')
{
count++;
buffer.Add('\u200C', cluster);
buffer.Add('\u200D', cluster);
}
else
{
buffer.Add('\u200C', cluster);
}
*p = first;
}
else
fixed (HarfBuzzSharp.GlyphInfo* p = &glyphInfos[length - 1])
{
buffer.Add('\u200C', cluster);
*p = second;
}
}
else
}
else
{
second.Codepoint = '\u200C';
unsafe
{
buffer.Add(codepoint, cluster);
fixed (HarfBuzzSharp.GlyphInfo* p = &glyphInfos[length - 1])
{
*p = second;
}
}
i += count;
}
}
private static void SetOffset(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale,
ref Vector[] offsetBuffer)
private static Vector GetGlyphOffset(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale)
{
var position = glyphPositions[index];
if (position.XOffset == 0 && position.YOffset == 0)
{
return;
}
offsetBuffer ??= new Vector[glyphPositions.Length];
var offsetX = position.XOffset * textScale;
var offsetY = position.YOffset * textScale;
offsetBuffer[index] = new Vector(offsetX, offsetY);
return new Vector(offsetX, offsetY);
}
private static void SetAdvance(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale,
ref double[] advanceBuffer)
private static double GetGlyphAdvance(ReadOnlySpan<GlyphPosition> glyphPositions, int index, double textScale)
{
advanceBuffer ??= new double[glyphPositions.Length];
// Depends on direction of layout
// advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale;
advanceBuffer[index] = glyphPositions[index].XAdvance * textScale;
return glyphPositions[index].XAdvance * textScale;
}
}
}

12
tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs

@ -10,18 +10,6 @@ namespace Avalonia.UnitTests
{
public class MockPlatformRenderInterface : IPlatformRenderInterface
{
public IFormattedTextImpl CreateFormattedText(
string text,
Typeface typeface,
double fontSize,
TextAlignment textAlignment,
TextWrapping wrapping,
Size constraint,
IReadOnlyList<FormattedTextStyleSpan> spans)
{
return Mock.Of<IFormattedTextImpl>();
}
public IGeometryImpl CreateEllipseGeometry(Rect rect)
{
return Mock.Of<IGeometryImpl>();

27
tests/Avalonia.UnitTests/MockTextShaperImpl.cs

@ -1,6 +1,6 @@
using System;
using System.Globalization;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
@ -9,29 +9,24 @@ namespace Avalonia.UnitTests
{
public class MockTextShaperImpl : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
public ShapedBuffer ShapeText(ReadOnlySlice<char> text, GlyphTypeface typeface, double fontRenderingEmSize,
CultureInfo culture, sbyte bidiLevel)
{
var glyphTypeface = typeface.GlyphTypeface;
var glyphIndices = new ushort[text.Length];
var glyphCount = 0;
var shapedBuffer = new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel);
for (var i = 0; i < text.Length;)
for (var i = 0; i < shapedBuffer.Length;)
{
var index = i;
var glyphCluster = i + text.Start;
var codepoint = Codepoint.ReadAt(text, i, out var count);
i += count;
var glyph = glyphTypeface.GetGlyph(codepoint);
var glyphIndex = typeface.GetGlyph(codepoint);
glyphIndices[index] = glyph;
shapedBuffer[i] = new GlyphInfo(glyphIndex, glyphCluster);
glyphCount++;
i += count;
}
return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices.AsMemory(0, glyphCount)), characters: text);
return shapedBuffer;
}
}
}

8
tests/Avalonia.UnitTests/TestServices.cs

@ -183,14 +183,6 @@ namespace Avalonia.UnitTests
private static IPlatformRenderInterface CreateRenderInterfaceMock()
{
return Mock.Of<IPlatformRenderInterface>(x =>
x.CreateFormattedText(
It.IsAny<string>(),
It.IsAny<Typeface>(),
It.IsAny<double>(),
It.IsAny<TextAlignment>(),
It.IsAny<TextWrapping>(),
It.IsAny<Size>(),
It.IsAny<IReadOnlyList<FormattedTextStyleSpan>>()) == Mock.Of<IFormattedTextImpl>() &&
x.CreateStreamGeometry() == Mock.Of<IStreamGeometryImpl>(
y => y.Open() == Mock.Of<IStreamGeometryContextImpl>()));
}

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

@ -1,4 +1,5 @@
using Avalonia.Media;
using System.Linq;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.UnitTests;
using Avalonia.Utilities;
@ -14,13 +15,13 @@ namespace Avalonia.Visuals.UnitTests.Media
.Bind<IPlatformRenderInterface>().ToSingleton<MockPlatformRenderInterface>();
}
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 3, 30)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 1, 0, 10)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 2, 0, 20)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 2, 1, 30)]
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 0, 0, 0)]
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 0, 3, 30)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 1, 0, 10)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 2, 0, 20)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 2, 1, 30)]
[Theory]
public void Should_Get_Distance_From_CharacterHit(double[] advances, ushort[] clusters, int start, int trailingLength, double expectedDistance)
public void Should_Get_Distance_From_CharacterHit(double[] advances, int[] clusters, int start, int trailingLength, double expectedDistance)
{
using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters))
@ -33,12 +34,12 @@ namespace Avalonia.Visuals.UnitTests.Media
}
}
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 25.0, 0, 3, true)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 20.0, 1, 1, true)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 26.0, 2, 1, true)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 35.0, 2, 1, false)]
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 26.0, 0, 3, true)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 20.0, 1, 1, true)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 26.0, 2, 1, true)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 35.0, 2, 1, false)]
[Theory]
public void Should_Get_CharacterHit_FromDistance(double[] advances, ushort[] clusters, double distance, int start,
public void Should_Get_CharacterHit_FromDistance(double[] advances, int[] clusters, double distance, int start,
int trailingLengthExpected, bool isInsideExpected)
{
using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
@ -54,15 +55,15 @@ namespace Avalonia.Visuals.UnitTests.Media
}
}
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 10, 11, 12 }, 0, -1, 10, 1, 10)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 10, 11, 12 }, 0, 15, 12, 1, 10)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 3, 30.0)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 1, 2 }, 0, 1, 1, 1, 10.0)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 3 }, 0, 2, 1, 2, 20.0)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 3 }, 0, 1, 1, 2, 20.0)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 3, 1, 1, 0 }, 1, 1, 1, 2, 20.0)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 10, 11, 12 }, 0, -1, 10, 1, 10)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 10, 11, 12 }, 0, 15, 12, 1, 10)]
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 0, 0, 0, 3, 30.0)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 0, 1, 1, 1, 10.0)]
[InlineData(new double[] { 10, 20, 0, 10 }, new int[] { 0, 1, 1, 3 }, 0, 2, 1, 2, 20.0)]
[InlineData(new double[] { 10, 20, 0, 10 }, new int[] { 0, 1, 1, 3 }, 0, 1, 1, 2, 20.0)]
[InlineData(new double[] { 10, 0, 20, 10 }, new int[] { 3, 1, 1, 0 }, 1, 1, 1, 2, 20.0)]
[Theory]
public void Should_Find_Nearest_CharacterHit(double[] advances, ushort[] clusters, int bidiLevel,
public void Should_Find_Nearest_CharacterHit(double[] advances, int[] clusters, int bidiLevel,
int index, int expectedIndex, int expectedLength, double expectedWidth)
{
using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
@ -78,22 +79,22 @@ namespace Avalonia.Visuals.UnitTests.Media
}
}
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 3, 0)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 3, 1)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 0, 0, 3 }, 3, 0, 3, 1, 0)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 3, 0, 0, 0 }, 3, 0, 3, 1, 1)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 1, 4 }, 4, 0, 4, 1, 0)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 4, 1, 1, 1, 0 }, 4, 0, 4, 1, 1)]
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 0, 0, 0, 3, 0)]
[InlineData(new double[] { 0, 0, 30 }, new int[] { 0, 0, 0 }, 0, 0, 0, 3, 1)]
[InlineData(new double[] { 30, 0, 0, 10 }, new int[] { 0, 0, 0, 3 }, 3, 0, 3, 1, 0)]
[InlineData(new double[] { 10, 0, 0, 30 }, new int[] { 3, 0, 0, 0 }, 3, 0, 3, 1, 1)]
[InlineData(new double[] { 10, 30, 0, 0, 10 }, new int[] { 0, 1, 1, 1, 4 }, 1, 0, 4, 0, 0)]
[InlineData(new double[] { 10, 0, 0, 30, 10 }, new int[] { 4, 1, 1, 1, 0 }, 1, 0, 4, 0, 1)]
[Theory]
public void Should_Get_Next_CharacterHit(double[] advances, ushort[] clusters,
int currentIndex, int currentLength,
public void Should_Get_Next_CharacterHit(double[] advances,int[] clusters,
int firstCharacterIndex, int trailingLength,
int nextIndex, int nextLength,
int bidiLevel)
{
using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
{
var characterHit = glyphRun.GetNextCaretCharacterHit(new CharacterHit(currentIndex, currentLength));
var characterHit = glyphRun.GetNextCaretCharacterHit(new CharacterHit(firstCharacterIndex, trailingLength));
Assert.Equal(nextIndex, characterHit.FirstCharacterIndex);
@ -101,14 +102,14 @@ namespace Avalonia.Visuals.UnitTests.Media
}
}
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 0, 0)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0, 0, 0, 0, 1)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 0, 0, 3 }, 3, 1, 3, 0, 0)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 3, 0, 0, 0 }, 3, 1, 3, 0, 1)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 1, 4 }, 4, 1, 4, 0, 0)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 4, 1, 1, 1, 0 }, 4, 1, 4, 0, 1)]
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 0, 0, 0, 0, 0)]
[InlineData(new double[] { 0, 0, 30 }, new int[] { 0, 0, 0 }, 0, 0, 0, 0, 1)]
[InlineData(new double[] { 30, 0, 0, 10 }, new int[] { 0, 0, 0, 3 }, 3, 1, 3, 0, 0)]
[InlineData(new double[] { 0, 0, 30, 10 }, new int[] { 3, 0, 0, 0 }, 3, 1, 3, 0, 1)]
[InlineData(new double[] { 10, 30, 0, 0, 10 }, new int[] { 0, 1, 1, 1, 4 }, 4, 1, 4, 0, 0)]
[InlineData(new double[] { 10, 0, 0, 30, 10 }, new int[] { 4, 1, 1, 1, 0 }, 4, 1, 4, 0, 1)]
[Theory]
public void Should_Get_Previous_CharacterHit(double[] advances, ushort[] clusters,
public void Should_Get_Previous_CharacterHit(double[] advances, int[] clusters,
int currentIndex, int currentLength,
int previousIndex, int previousLength,
int bidiLevel)
@ -124,14 +125,14 @@ namespace Avalonia.Visuals.UnitTests.Media
}
}
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 0)]
[InlineData(new double[] { 10, 10, 10 }, new ushort[] { 0, 0, 0 }, 1)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 0, 0, 0, 3 }, 0)]
[InlineData(new double[] { 10, 10, 10, 10 }, new ushort[] { 3, 0, 0, 0 }, 1)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 0, 1, 1, 1, 4 }, 0)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new ushort[] { 4, 1, 1, 1, 0 }, 1)]
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 0)]
[InlineData(new double[] { 0, 0, 30 }, new int[] { 0, 0, 0 }, 1)]
[InlineData(new double[] { 10, 10, 10, 10 }, new int[] { 0, 0, 0, 3 }, 0)]
[InlineData(new double[] { 10, 10, 10, 10 }, new int[] { 3, 0, 0, 0 }, 1)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new int[] { 0, 1, 1, 1, 4 }, 0)]
[InlineData(new double[] { 10, 10, 10, 10, 10 }, new int[] { 4, 1, 1, 1, 0 }, 1)]
[Theory]
public void Should_Find_Glyph_Index(double[] advances, ushort[] clusters, int bidiLevel)
public void Should_Find_Glyph_Index(double[] advances, int[] clusters, int bidiLevel)
{
using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
@ -175,17 +176,17 @@ namespace Avalonia.Visuals.UnitTests.Media
}
}
private static GlyphRun CreateGlyphRun(double[] glyphAdvances, ushort[] glyphClusters, int bidiLevel = 0)
private static GlyphRun CreateGlyphRun(double[] glyphAdvances, int[] glyphClusters, int bidiLevel = 0)
{
var count = glyphAdvances.Length;
var glyphIndices = new ushort[count];
var start = bidiLevel == 0 ? glyphClusters[0] : glyphClusters[glyphClusters.Length - 1];
var start = bidiLevel == 0 ? glyphClusters[0] : glyphClusters[^1];
var characters = new ReadOnlySlice<char>(new char[count], start, count);
var characters = new ReadOnlySlice<char>(Enumerable.Repeat('a', count).ToArray(), start, count);
return new GlyphRun(new GlyphTypeface(new MockGlyphTypeface()), 10, glyphIndices, glyphAdvances,
glyphClusters: glyphClusters, characters: characters, biDiLevel: bidiLevel);
return new GlyphRun(new GlyphTypeface(new MockGlyphTypeface()), 10, characters, glyphIndices, glyphAdvances,
glyphClusters: glyphClusters, biDiLevel: bidiLevel);
}
}
}

85
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs

@ -0,0 +1,85 @@
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
using Xunit;
using Xunit.Abstractions;
namespace Avalonia.Visuals.UnitTests.Media.TextFormatting
{
public class BiDiAlgorithmTests
{
private readonly ITestOutputHelper _outputHelper;
public BiDiAlgorithmTests(ITestOutputHelper outputHelper)
{
_outputHelper = outputHelper;
}
[Fact(Skip = "Only run when the Unicode spec changes.")]
public void Should_Process()
{
var generator = new BiDiTestDataGenerator();
foreach(var testData in generator)
{
Assert.True(Run(testData));
}
}
private bool Run(BiDiTestData testData)
{
var bidi = BidiAlgorithm.Instance.Value;
// Run the algorithm...
ArraySlice<sbyte> resultLevels;
bidi.Process(
testData.Classes,
ArraySlice<BidiPairedBracketType>.Empty,
ArraySlice<int>.Empty,
testData.ParagraphEmbeddingLevel,
false,
null,
null,
null);
resultLevels = bidi.ResolvedLevels;
// Check the results match
var pass = true;
if (resultLevels.Length == testData.Levels.Length)
{
for (var i = 0; i < testData.Levels.Length; i++)
{
if (testData.Levels[i] == -1)
{
continue;
}
if (resultLevels[i] != testData.Levels[i])
{
pass = false;
break;
}
}
}
else
{
pass = false;
}
if (!pass)
{
_outputHelper.WriteLine($"Failed line {testData.LineNumber}");
_outputHelper.WriteLine($" Data: {string.Join(" ", testData.Classes)}");
_outputHelper.WriteLine($" Embed Level: {testData.ParagraphEmbeddingLevel}");
_outputHelper.WriteLine($" Expected: {string.Join(" ", testData.Levels)}");
_outputHelper.WriteLine($" Actual: {string.Join(" ", resultLevels)}");
return false;
}
return true;
}
}
}

111
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTestDataGenerator.cs

@ -0,0 +1,111 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
namespace Avalonia.Visuals.UnitTests.Media.TextFormatting
{
internal class BiDiClassTestDataGenerator : IEnumerable<BiDiClassData>
{
private readonly List<BiDiClassData> _testData;
public BiDiClassTestDataGenerator()
{
_testData = ReadData();
}
public IEnumerator<BiDiClassData> GetEnumerator()
{
return _testData.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
private static List<BiDiClassData> ReadData()
{
var testData = new List<BiDiClassData>();
using (var client = new HttpClient())
{
var url = Path.Combine(UnicodeDataGenerator.Ucd, "BidiCharacterTest.txt");
using (var result = client.GetAsync(url).GetAwaiter().GetResult())
{
if (!result.IsSuccessStatusCode)
return testData;
using (var stream = result.Content.ReadAsStreamAsync().GetAwaiter().GetResult())
using (var reader = new StreamReader(stream))
{
var lineNumber = 0;
// Process each line
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
lineNumber++;
if (line == null)
{
break;
}
if (line.StartsWith("#") || string.IsNullOrEmpty(line))
{
continue;
}
// Split into fields
var fields = line.Split(';');
// Parse field 0 - code points
var codePoints = fields[0].Split(' ').Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).Select(x => Convert.ToInt32(x, 16)).ToArray();
// Parse field 1 - paragraph level
var paragraphLevel = sbyte.Parse(fields[1]);
// Parse field 2 - resolved paragraph level
var resolvedParagraphLevel = sbyte.Parse(fields[2]);
// Parse field 3 - resolved levels
var resolvedLevels = fields[3].Split(' ').Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).Select(x => x == "x" ? (sbyte)-1 : Convert.ToSByte(x)).ToArray();
// Parse field 4 - resolved levels
var resolvedOrder = fields[4].Split(' ').Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).Select(x => Convert.ToInt32(x)).ToArray();
testData.Add(new BiDiClassData
{
LineNumber = lineNumber,
CodePoints = codePoints,
ParagraphLevel = paragraphLevel,
ResolvedParagraphLevel = resolvedParagraphLevel,
ResolvedLevels = resolvedLevels,
ResolvedOrder = resolvedOrder
});
}
}
}
}
return testData;
}
}
internal struct BiDiClassData
{
public int LineNumber { get; set; }
public int[] CodePoints{ get; set; }
public sbyte ParagraphLevel{ get; set; }
public sbyte ResolvedParagraphLevel{ get; set; }
public sbyte[] ResolvedLevels{ get; set; }
public int[] ResolvedOrder{ get; set; }
}
}

94
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTests.cs

@ -0,0 +1,94 @@
using System;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using Avalonia.Media.TextFormatting.Unicode;
using Xunit;
using Xunit.Abstractions;
namespace Avalonia.Visuals.UnitTests.Media.TextFormatting
{
public class BiDiClassTests
{
private readonly ITestOutputHelper _outputHelper;
public BiDiClassTests(ITestOutputHelper outputHelper)
{
_outputHelper = outputHelper;
}
[Fact(Skip = "Only run when the Unicode spec changes.")]
public void Should_Resolve()
{
var generator = new BiDiClassTestDataGenerator();
foreach (var testData in generator)
{
Assert.True(Run(testData));
}
}
private bool Run(BiDiClassData t)
{
var bidi = BidiAlgorithm.Instance.Value;
var bidiData = new BidiData(t.ParagraphLevel);
var text = Encoding.UTF32.GetString(MemoryMarshal.Cast<int, byte>(t.CodePoints).ToArray());
// Append
bidiData.Append(text.AsMemory());
// Act
bidi.Process(bidiData);
var resultLevels = bidi.ResolvedLevels;
var resultParagraphLevel = bidi.ResolvedParagraphEmbeddingLevel;
// Assert
var passed = true;
if (t.ResolvedParagraphLevel != resultParagraphLevel)
{
return false;
}
for (var i = 0; i < t.ResolvedLevels.Length; i++)
{
if (t.ResolvedLevels[i] == -1)
{
continue;
}
if (t.ResolvedLevels[i] != resultLevels[i])
{
passed = false;
break;
}
}
if (passed)
{
return true;
}
_outputHelper.WriteLine($"Failed line {t.LineNumber}");
_outputHelper.WriteLine(
$" Code Points: {string.Join(" ", t.CodePoints.Select(x => x.ToString("X4")))}");
_outputHelper.WriteLine(
$" Pair Bracket Types: {string.Join(" ", bidiData.PairedBracketTypes.Select(x => " " + x.ToString()))}");
_outputHelper.WriteLine(
$" Pair Bracket Values: {string.Join(" ", bidiData.PairedBracketValues.Select(x => x.ToString("X4")))}");
_outputHelper.WriteLine($" Embed Level: {t.ParagraphLevel}");
_outputHelper.WriteLine($" Expected Embed Level: {t.ResolvedParagraphLevel}");
_outputHelper.WriteLine($" Actual Embed Level: {resultParagraphLevel}");
_outputHelper.WriteLine($" Directionality: {string.Join(" ", bidiData.Classes)}");
_outputHelper.WriteLine($" Expected Levels: {string.Join(" ", t.ResolvedLevels)}");
_outputHelper.WriteLine($" Actual Levels: {string.Join(" ", resultLevels)}");
return false;
}
}
}

7
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs

@ -0,0 +1,7 @@
namespace Avalonia.Visuals.UnitTests.Media.TextFormatting
{
public class BiDiPairedBracketTypeTests
{
}
}

148
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiTestDataGenerator.cs

@ -0,0 +1,148 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Visuals.UnitTests.Media.TextFormatting
{
internal class BiDiTestDataGenerator : IEnumerable<BiDiTestData>
{
private readonly List<BiDiTestData> _testData;
public BiDiTestDataGenerator()
{
_testData = ReadTestData();
}
public IEnumerator<BiDiTestData> GetEnumerator()
{
return _testData.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
private static List<BiDiTestData> ReadTestData()
{
var testData = new List<BiDiTestData>();
using (var client = new HttpClient())
{
var url = Path.Combine(UnicodeDataGenerator.Ucd, "BidiTest.txt");
using (var result = client.GetAsync(url).GetAwaiter().GetResult())
{
if (!result.IsSuccessStatusCode)
return testData;
using (var stream = result.Content.ReadAsStreamAsync().GetAwaiter().GetResult())
using (var reader = new StreamReader(stream))
{
var lineNumber = 0;
// Process each line
int[] levels = null;
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
lineNumber++;
if (line == null)
{
break;
}
if (line.StartsWith("#") || string.IsNullOrEmpty(line))
{
continue;
}
// Directive?
if (line.StartsWith("@"))
{
if (line.StartsWith("@Levels:"))
{
levels = line.Substring(8).Trim().Split(' ').Where(x => x.Length > 0).Select(x =>
{
if (x == "x")
{
return -1;
}
return int.Parse(x);
}).ToArray();
}
continue;
}
// Split data line
var parts = line.Split(';');
// Get the directions
var directions = parts[0].Split(' ').Select(PropertyValueAliasHelper.GetBiDiClass)
.ToArray();
// Get the bit set
var bitset = Convert.ToInt32(parts[1].Trim(), 16);
for (var bit = 1; bit < 8; bit <<= 1)
{
if ((bitset & bit) == 0)
{
continue;
}
sbyte paragraphEmbeddingLevel;
switch (bit)
{
case 1:
paragraphEmbeddingLevel = 2; // Auto
break;
case 2:
paragraphEmbeddingLevel = 0; // LTR
break;
case 4:
paragraphEmbeddingLevel = 1; // RTL
break;
default:
throw new NotSupportedException();
}
testData.Add(new BiDiTestData
{
LineNumber = lineNumber,
Classes = directions,
ParagraphEmbeddingLevel = paragraphEmbeddingLevel,
Levels = levels
});
}
}
}
}
}
return testData;
}
}
internal class BiDiTestData
{
public int LineNumber { get; set; }
public BidiClass[] Classes { get; set; }
public sbyte ParagraphEmbeddingLevel { get; set; }
public int[] Levels { get; set; }
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save