From b815a22d5a9e2a9d2f0b9b083f05709573edc618 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 28 Jan 2022 11:41:55 +0100 Subject: [PATCH 01/18] Port WPF's FormattedText to Avalonia and rework TextPresenter --- samples/ControlCatalog/Pages/ScreenPage.cs | 37 +- samples/RenderDemo/MainWindow.xaml | 3 + samples/RenderDemo/Pages/CustomSkiaPage.cs | 13 +- .../RenderDemo/Pages/FormattedTextPage.axaml | 7 + .../Pages/FormattedTextPage.axaml.cs | 60 + samples/RenderDemo/Pages/GlyphRunPage.xaml.cs | 7 +- .../ImmutableReadOnlyListStructEnumerator.cs | 2 +- src/Avalonia.Controls/ApiCompatBaseline.txt | 6 +- src/Avalonia.Controls/Control.cs | 19 +- .../Presenters/TextPresenter.cs | 487 +++- src/Avalonia.Controls/TextBlock.cs | 52 +- src/Avalonia.Controls/TextBox.cs | 241 +- .../TextBoxTextInputMethodClient.cs | 24 +- src/Avalonia.Controls/Utils/StringUtils.cs | 53 +- .../HeadlessPlatformRenderInterface.cs | 36 - .../HeadlessPlatformStubs.cs | 12 +- src/Avalonia.Visuals/ApiCompatBaseline.txt | 61 +- src/Avalonia.Visuals/Assets/BiDi.trie | Bin 0 -> 3004 bytes src/Avalonia.Visuals/Assets/UnicodeData.trie | Bin 11728 -> 9464 bytes src/Avalonia.Visuals/Media/DrawingContext.cs | 10 +- src/Avalonia.Visuals/Media/FormattedText.cs | 1413 +++++++++- .../Media/FormattedTextLine.cs | 29 - .../Media/FormattedTextStyleSpan.cs | 39 - src/Avalonia.Visuals/Media/GlyphRun.cs | 280 +- src/Avalonia.Visuals/Media/TextDecoration.cs | 25 +- .../Media/TextFormatting/FontMetrics.cs | 8 +- .../TextFormatting/FormattedTextSource.cs | 24 +- .../TextFormatting/ShapeableTextCharacters.cs | 5 +- .../Media/TextFormatting/ShapedBuffer.cs | 293 ++ .../TextFormatting/ShapedTextCharacters.cs | 200 +- .../Media/TextFormatting/SplitResult.cs | 28 + .../Media/TextFormatting/TextCharacters.cs | 72 +- .../TextFormatting/TextEndOfParagraph.cs | 9 + .../Media/TextFormatting/TextFormatterImpl.cs | 668 +++-- .../Media/TextFormatting/TextLayout.cs | 534 ++-- .../Media/TextFormatting/TextLine.cs | 28 +- .../Media/TextFormatting/TextLineBreak.cs | 17 +- .../Media/TextFormatting/TextLineImpl.cs | 626 ++++- .../TextFormatting/TextParagraphProperties.cs | 2 +- .../Media/TextFormatting/TextRun.cs | 2 +- .../Media/TextFormatting/TextShaper.cs | 6 +- .../TextFormatting/Unicode/BiDiAlgorithm.cs | 1717 ++++++++++++ .../Media/TextFormatting/Unicode/BiDiClass.cs | 2 +- .../Media/TextFormatting/Unicode/BiDiData.cs | 182 ++ .../Unicode/BiDiPairedBracketType.cs | 9 + .../Media/TextFormatting/Unicode/Codepoint.cs | 58 +- .../Unicode/PropertyValueAliasHelper.cs | 331 +++ .../TextFormatting/Unicode/UnicodeData.cs | 71 +- .../Media/TextHitTestResult.cs | 27 +- src/Avalonia.Visuals/Media/Typeface.cs | 2 +- .../Platform/IDrawingContextImpl.cs | 7 - .../Platform/IFormattedTextImpl.cs | 59 - .../Platform/IPlatformRenderInterface.cs | 21 - .../Platform/ITextShaperImpl.cs | 6 +- .../Rendering/DeferredRenderer.cs | 5 +- .../Rendering/ImmediateRenderer.cs | 2 +- .../Rendering/RendererBase.cs | 30 +- .../SceneGraph/DeferredDrawingContextImpl.cs | 15 - .../Rendering/SceneGraph/TextNode.cs | 89 - .../Utilities/ArrayBuilder.cs | 184 ++ src/Avalonia.Visuals/Utilities/ArraySlice.cs | 197 ++ .../Utilities/BinarySearchExtension.cs | 93 + src/Avalonia.Visuals/Utilities/FrugalList.cs | 2360 +++++++++++++++++ .../Utilities/MappedArraySlice.cs | 58 + .../Utilities/ReadOnlySlice.cs | 80 +- src/Avalonia.Visuals/Utilities/Span.cs | 596 +++++ src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 12 +- src/Skia/Avalonia.Skia/FormattedTextImpl.cs | 838 ------ .../Avalonia.Skia/PlatformRenderInterface.cs | 44 +- src/Skia/Avalonia.Skia/TextShaperImpl.cs | 135 +- .../Avalonia.Direct2D1/Direct2D1Platform.cs | 25 +- .../Media/DrawingContextImpl.cs | 23 - .../Media/FormattedTextImpl.cs | 129 - .../Media/TextShaperImpl.cs | 137 +- .../NullDrawingContextImpl.cs | 4 - .../NullFormattedTextImpl.cs | 36 - .../NullRenderingPlatform.cs | 6 - .../Avalonia.Controls.UnitTests.csproj | 1 + .../MaskedTextBoxTests.cs | 14 +- .../Presenters/TextPresenter_Tests.cs | 17 +- .../TextBoxTests.cs | 53 +- .../TextBoxTests_DataValidation.cs | 4 +- .../Media/FormattedTextImplTests.cs | 267 -- .../Media/TextFormatting/TextLayoutTests.cs | 249 +- .../Media/GlyphRunTests.cs | 223 +- .../TextFormatting/TextFormatterTests.cs | 144 +- .../Media/TextFormatting/TextLayoutTests.cs | 306 ++- .../Media/TextFormatting/TextLineTests.cs | 245 +- .../Media/TextFormatting/TextShaperTests.cs | 16 +- .../Avalonia.UnitTests.csproj | 1 + .../HarfBuzzTextShaperImpl.cs | 133 +- .../MockPlatformRenderInterface.cs | 12 - .../Avalonia.UnitTests/MockTextShaperImpl.cs | 27 +- tests/Avalonia.UnitTests/TestServices.cs | 8 - .../Media/GlyphRunTests.cs | 97 +- .../TextFormatting/BiDiAlgorithmTests.cs | 85 + .../BiDiClassTestDataGenerator.cs | 111 + .../Media/TextFormatting/BiDiClassTests.cs | 94 + .../BiDiPairedBracketTypeTests.cs | 7 + .../TextFormatting/BiDiTestDataGenerator.cs | 148 ++ .../TextFormatting/UnicodeDataGenerator.cs | 250 +- .../UnicodeDataGeneratorTests.cs | 42 +- .../TextFormatting/UnicodeEnumsGenerator.cs | 45 +- .../Rendering/SceneGraph/TextNodeTests.cs | 26 - .../VisualTree/MockRenderInterface.cs | 12 - 105 files changed, 11816 insertions(+), 3849 deletions(-) create mode 100644 samples/RenderDemo/Pages/FormattedTextPage.axaml create mode 100644 samples/RenderDemo/Pages/FormattedTextPage.axaml.cs create mode 100644 src/Avalonia.Visuals/Assets/BiDi.trie delete mode 100644 src/Avalonia.Visuals/Media/FormattedTextLine.cs delete mode 100644 src/Avalonia.Visuals/Media/FormattedTextStyleSpan.cs rename {tests/Avalonia.Skia.UnitTests => src/Avalonia.Visuals}/Media/TextFormatting/FormattedTextSource.cs (83%) create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/ShapedBuffer.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/SplitResult.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiAlgorithm.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiData.cs create mode 100644 src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiPairedBracketType.cs delete mode 100644 src/Avalonia.Visuals/Platform/IFormattedTextImpl.cs delete mode 100644 src/Avalonia.Visuals/Rendering/SceneGraph/TextNode.cs create mode 100644 src/Avalonia.Visuals/Utilities/ArrayBuilder.cs create mode 100644 src/Avalonia.Visuals/Utilities/ArraySlice.cs create mode 100644 src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs create mode 100644 src/Avalonia.Visuals/Utilities/FrugalList.cs create mode 100644 src/Avalonia.Visuals/Utilities/MappedArraySlice.cs create mode 100644 src/Avalonia.Visuals/Utilities/Span.cs delete mode 100644 src/Skia/Avalonia.Skia/FormattedTextImpl.cs delete mode 100644 src/Windows/Avalonia.Direct2D1/Media/FormattedTextImpl.cs delete mode 100644 tests/Avalonia.Benchmarks/NullFormattedTextImpl.cs delete mode 100644 tests/Avalonia.RenderTests/Media/FormattedTextImplTests.cs create mode 100644 tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs create mode 100644 tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTestDataGenerator.cs create mode 100644 tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTests.cs create mode 100644 tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs create mode 100644 tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiTestDataGenerator.cs delete mode 100644 tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/TextNodeTests.cs diff --git a/samples/ControlCatalog/Pages/ScreenPage.cs b/samples/ControlCatalog/Pages/ScreenPage.cs index 4edb0f137a..caad8b0854 100644 --- a/samples/ControlCatalog/Pages/ScreenPage.cs +++ b/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); + } } } diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml index a4c6299278..4a8fb819ca 100644 --- a/samples/RenderDemo/MainWindow.xaml +++ b/samples/RenderDemo/MainWindow.xaml @@ -57,6 +57,9 @@ + + + diff --git a/samples/RenderDemo/Pages/CustomSkiaPage.cs b/samples/RenderDemo/Pages/CustomSkiaPage.cs index 2e59d934a1..9c524a7932 100644 --- a/samples/RenderDemo/Pages/CustomSkiaPage.cs +++ b/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); } diff --git a/samples/RenderDemo/Pages/FormattedTextPage.axaml b/samples/RenderDemo/Pages/FormattedTextPage.axaml new file mode 100644 index 0000000000..92775bec9e --- /dev/null +++ b/samples/RenderDemo/Pages/FormattedTextPage.axaml @@ -0,0 +1,7 @@ + + diff --git a/samples/RenderDemo/Pages/FormattedTextPage.axaml.cs b/samples/RenderDemo/Pages/FormattedTextPage.axaml.cs new file mode 100644 index 0000000000..25e29c67a9 --- /dev/null +++ b/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)); + } + } +} diff --git a/samples/RenderDemo/Pages/GlyphRunPage.xaml.cs b/samples/RenderDemo/Pages/GlyphRunPage.xaml.cs index 857358f6b2..7f85606957 100644 --- a/samples/RenderDemo/Pages/GlyphRunPage.xaml.cs +++ b/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); diff --git a/src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs b/src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs index 90d1c52ff5..251dfe4351 100644 --- a/src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs +++ b/src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace Avalonia.Utilities { - public struct ImmutableReadOnlyListStructEnumerator : IEnumerator, IEnumerator + public struct ImmutableReadOnlyListStructEnumerator : IEnumerator { private readonly IReadOnlyList _readOnlyList; private int _pos; diff --git a/src/Avalonia.Controls/ApiCompatBaseline.txt b/src/Avalonia.Controls/ApiCompatBaseline.txt index a7560c37f2..2c206b53f6 100644 --- a/src/Avalonia.Controls/ApiCompatBaseline.txt +++ b/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' 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.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 diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 441421181c..35648dd0b6 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/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 ContextRequestedEvent = RoutedEvent.Register(nameof(ContextRequested), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); - + + /// + /// Defines the property. + /// + public static readonly AttachedProperty FlowDirectionProperty = + AvaloniaProperty.RegisterAttached(nameof(FlowDirection), inherits: true); + private DataTemplates? _dataTemplates; private IControl? _focusAdorner; @@ -108,6 +116,15 @@ namespace Avalonia.Controls get => GetValue(TagProperty); set => SetValue(TagProperty, value); } + + /// + /// Gets or sets the text flow direction. + /// + public FlowDirection FlowDirection + { + get => GetValue(FlowDirectionProperty); + set => SetValue(FlowDirectionProperty, value); + } /// /// Occurs when the user has completed a context input gesture, such as a right-click. diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index ff63e5644f..3dcaff5171 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/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(SelectionBrushProperty, TextBlock.ForegroundProperty, - SelectionForegroundBrushProperty, CaretBrushProperty, - SelectionStartProperty, SelectionEndProperty); - - AffectsMeasure(TextProperty, PasswordCharProperty, RevealPasswordProperty, - TextAlignmentProperty, TextWrappingProperty, TextBlock.FontSizeProperty, - TextBlock.FontStyleProperty, TextBlock.FontWeightProperty, TextBlock.FontFamilyProperty); + private CharacterHit _lastCharacterHit; + private Rect _caretBounds; + private Point _navigationPosition; - Observable.Merge(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((x, _) => x.InvalidateFormattedText()); + private ScrollViewer _scrollViewer; - CaretIndexProperty.Changed.AddClassHandler((x, e) => x.CaretIndexChanged((int)e.NewValue)); + static TextPresenter() + { + AffectsRender(CaretBrushProperty, SelectionBrushProperty); } public TextPresenter() @@ -106,6 +99,8 @@ namespace Avalonia.Controls.Presenters _caretTimer.Tick += CaretTimerTick; } + public event EventHandler CaretBoundsChanged; + /// /// Gets or sets a brush used to paint the control's background. /// @@ -189,13 +184,22 @@ namespace Avalonia.Controls.Presenters } /// - /// Gets the used to render the text. + /// Gets the used to render the text. /// - 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); - } - /// - /// Creates the used to render the text. + /// Creates the used to render the text. /// /// The constraint of the text. /// The text to format. - /// A object. - 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, - }; - } + /// + /// + /// A object. + private TextLayout CreateTextLayoutInternal(Size constraint, string text, Typeface typeface, + IReadOnlyList> 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); - /// - /// Invalidates . - /// - protected void InvalidateFormattedText() - { - _formattedText = null; + return textLayout; } /// @@ -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 } /// - /// Creates the used to render the text. + /// Creates the used to render the text. /// - /// A object. - protected virtual FormattedText CreateFormattedText() + /// A object. + 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> textStyleOverrides = null; + if (length > 0) { - result.Spans = new[] + textStyleOverrides = new[] { - new FormattedTextStyleSpan(start, length, SelectionForegroundBrush), + new ValueSpan(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; } - /// - /// Measures the control. - /// - /// The available size for the control. - /// The desired size. - 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(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + _scrollViewer = null; + + _caretTimer.Stop(); + + _caretTimer.Tick -= CaretTimerTick; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs 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; + } + } } } } diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 14cde774f4..8291433d45 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -131,21 +131,8 @@ namespace Avalonia.Controls static TextBlock() { ClipToBoundsProperty.OverrideDefaultValue(true); - - AffectsRender(BackgroundProperty, ForegroundProperty, - TextAlignmentProperty, TextDecorationsProperty); - - AffectsMeasure(FontSizeProperty, FontWeightProperty, - FontStyleProperty, TextWrappingProperty, FontFamilyProperty, - TextTrimmingProperty, TextProperty, PaddingProperty, LineHeightProperty, MaxLinesProperty); - - Observable.Merge(TextProperty.Changed, ForegroundProperty.Changed, - TextAlignmentProperty.Changed, TextWrappingProperty.Changed, - TextTrimmingProperty.Changed, FontSizeProperty.Changed, - FontStyleProperty.Changed, FontWeightProperty.Changed, - FontFamilyProperty.Changed, TextDecorationsProperty.Changed, - PaddingProperty.Changed, MaxLinesProperty.Changed, LineHeightProperty.Changed - ).AddClassHandler((x, _) => x.InvalidateTextLayout()); + + AffectsRender(BackgroundProperty, ForegroundProperty); } /// @@ -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(); } /// @@ -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(AvaloniaPropertyChangedEventArgs 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; + } + } + } } } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 20d8a94c1a..cf5427732b 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/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("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(AvaloniaPropertyChangedEventArgs 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(); @@ -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; } /// @@ -1360,25 +1334,6 @@ namespace Avalonia.Controls return text.Substring(start, end - start); } - private int GetLine(int caretIndex, IList 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) diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 334db2cafd..279efa2934 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/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); } diff --git a/src/Avalonia.Controls/Utils/StringUtils.cs b/src/Avalonia.Controls/Utils/StringUtils.cs index 8cf2e836bb..53937003c8 100644 --- a/src/Avalonia.Controls/Utils/StringUtils.cs +++ b/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; } diff --git a/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index 48d0ef9da9..90221bb922 100644 --- a/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/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 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 GetLines() - { - return new[] { new FormattedTextLine(Text.Length, 10) }; - } - - public TextHitTestResult HitTestPoint(Point point) => new TextHitTestResult(); - - public Rect HitTestTextPosition(int index) => new Rect(); - - public IEnumerable HitTestTextRange(int index, int length) => new Rect[length]; - } } } diff --git a/src/Avalonia.Headless/HeadlessPlatformStubs.cs b/src/Avalonia.Headless/HeadlessPlatformStubs.cs index 605659d464..b619b9d129 100644 --- a/src/Avalonia.Headless/HeadlessPlatformStubs.cs +++ b/src/Avalonia.Headless/HeadlessPlatformStubs.cs @@ -133,14 +133,10 @@ namespace Avalonia.Headless class HeadlessTextShaperStub : ITextShaperImpl { - public GlyphRun ShapeText(ReadOnlySlice text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture) - { - return new GlyphRun(new GlyphTypeface(typeface), 10, - new ReadOnlySlice(new ushort[] { 1, 2, 3 }), - new ReadOnlySlice(new double[] { 1, 2, 3 }), - new ReadOnlySlice(new Vector[] { new Vector(1, 1), new Vector(2, 2), new Vector(3, 3) }), - text, - new ReadOnlySlice(new ushort[] { 1, 2, 3 })); + public ShapedBuffer ShapeText(ReadOnlySlice text, GlyphTypeface typeface, double fontRenderingEmSize, + CultureInfo culture, sbyte bidiLevel) + { + return new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel); } } diff --git a/src/Avalonia.Visuals/ApiCompatBaseline.txt b/src/Avalonia.Visuals/ApiCompatBaseline.txt index 68e3673cfe..828ea1f184 100644 --- a/src/Avalonia.Visuals/ApiCompatBaseline.txt +++ b/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.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.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.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)' 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, Avalonia.Utilities.ReadOnlySlice, Avalonia.Utilities.ReadOnlySlice, Avalonia.Utilities.ReadOnlySlice, Avalonia.Utilities.ReadOnlySlice, System.Int32)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice 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)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice 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)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice 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)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.Utilities.ReadOnlySlice 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)' 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(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, 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, 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>)' 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)' 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.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, 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)' 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)' 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)' 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, 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, 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, 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)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public void Avalonia.Utilities.ReadOnlySlice..ctor(System.ReadOnlyMemory, System.Int32, System.Int32)' does not exist in the implementation but it does exist in the contract. +Total Issues: 146 diff --git a/src/Avalonia.Visuals/Assets/BiDi.trie b/src/Avalonia.Visuals/Assets/BiDi.trie new file mode 100644 index 0000000000000000000000000000000000000000..1c6122e2f1f5aee63a4451193f4139eaad62a33b GIT binary patch literal 3004 zcmV;t3q$k(5C8xG000000IuKxs|!^0?VS&d6~!IL$MoIZdykVl^omD0+tt$^r+`+446iQVJX-Y6gidYb&2x6-ZG^Ul5D)ztD(n738WB+KY79>{s zhZayuYiqO#aQ$v~*0(q8%+9}^eFytXKKbp;&dl#OGryVLx3}-zoOcj%1Nb7i1>6HR zfz9Aya64F|pnnX04xR+R0WX3-f|tOb!2$3(I0)W|sec4C6!`5A!nsqyaL@_P1!F)3 zTnH`(Q^956DliMo0rSBkun;T(%fKq|uOcCDfy1CsBII=Boe#!=jYzMD-rygD2f$YF zFn9!P2T#%Tv*3kD`em?}mOlvIp!JW?zBM!uQUXK3sbDzhY^b$=ZbMMM4`t752<(OS z{rck?0_`&i$5SHdkK*_;n!W=1YI^)x=y_lPI9#ZeUySrpuoB!AX~!n_(su7>+TUW? z-Vd1e9%~5rSs}pB+ky1c4S_GuG39q6{a~Sg-X7%bW6B*s`X`8w*J=KLp-*ZIj!V#| z(EQiX){w?v-iI{?^LYj>H-hR>jmP$54Du$#epqxG5QeN{TP@Et^|wGkFDSu z9A68T;&?e&gg(qidJ(vp`f^)i(C?7BrT%G_ZQu@CcP;eS8jmg0hrGw>@y1A>za3Ln zhAo_W&qR)Y2X>W`$BJ#^PgL&%hZOyK6C7dkgt5%b|6@%0Me1Jz;$R1Dt5~kJ-BRZJ zavIK^4jxCF&qUh&ePeA+ZcP}MpQ7wGn%<4$ztDDGh5mO;?7kVv^ViQq7T0}F&WVJ~ z{*jZDlarH^larHE@9b`@Jrj^AUudcwmzsiSX<_Y=Pp6sP4MAM;8x~-b3+|G zKlTO(%mSMR*U~IIL3;}!yl8O6C>Ga_<>bJD!H)b>MmMEbO&`)|2JhNsDRCx&7RJZa=r5+t14W zrML#n%IG>!5B*QNKUhKUS;D_}uWhQmpR&0iu6sFifAjS^eBS0iC;TS-+U!3wYzp2F z`+mev|9Ru1H2n*D{+H0dij;XSreFTMLc5x3?+^O#qwV4J-?zUna_*lveoHV`hjFe@ ztfiZaLHl>pI&G12{(FBNMSfdn2+sM8!1*4g%ou8K0`v#Lhw1sL32nu3MWoIQ9M5Lj zxDM$XY2HGG_TMLXuW>QjS*qcy614r*Y1@%tJB^EV5nt}B1HV|F##;N4<~x--+>bHX z0=^d+i*TQt#PK6Mzuoa?xA0xSWppmT6P}~#N70Ar7JTyGY1~l^^i$Bk1ur<96H{J> zuX_{x)m@W+sKYhG;>YGEn0HV=39c*$@0`9;ti{e=+Mnf|Yt5RNzX9sJ4{U?!<>1~U zniqY)b~cXpNdC?8cx8Vv`2K{5p(XM8cjNq-oHFOo{E-Q5i143_Js??sEG;*YDepha z^S?t<5VUgvbM7JopJj9I_`IGQ7b z?Tx2+5rVvKQY9lug{C_|TSWV`OVSpx!{SC}pfA3?>2LSPt^Fm`{^Gaow>q<3zru@A z-S}1|j7yKi#ee@836ERJv1=xbbGGbwn$PCBh&^|)U7z+{iOgK`_P!)+)mQb!#AcMS zCpf&ZPm-j$6O}n0MW?SS^K!gN+}`1V$t3$Vp~GZG^NuGU=M*`Fb6g;`dlLV+jcT3! zxKQV$5of&dW$_}%tWQ~wOFfb_=AzhS+lVV0mXZBDYP)asIIgN>9V7KJP9}CH4V79A08#COw^t&+omFl`#3-2 z){3XZSS?AxVm=9?xQm8ux*;|onB1ax_{fZ`7Toy`>klt zm2F4I2D_eJycZ-RF8qkQ&ZcQAYEyLWXMNFKW2$R3jcdE>d#yNscz#9C>$aU%e?)T> z*C*S;+7O*fa}3#XrV=;*Ix(s;=Sgcc&c7@r?)^K}`X>9gt@^(7DiD1y*O$1zJF3Ea z?{_5lI5-PjASl<)^o{pEK?h|#x!J3dC0-?=Jiq>G)L-SxoVcTW@U5=m0UgdIb)3V8~`SCG%JY@ZG zxd({SygXaB!?xKNQMKt2^?O+rQH^2K=R+o+kCQ%!yL>JsW7CCwo-b^^YP%}Wa&-GB zQ^)q$iJpCl`eD*X+x=vHHfbvjiN4eA?z%XsFZh1^guZOPpFe}zpHA)1LV6}Wo{i(r z^z+N5Wcj(r^d7NZ@3CF&@iKO8TyqA(=Ni*%9B+)WxORtj(`?&TzeMA1Q$#kBeCAyT zvzXT;+ezh2?GfUpe`&NSTBE%3>~R(%JBL~OoTg3L^`;x6arIPVM%1>AX`B9>+CrM<3o#zSUbiyJprW5|}_`C(%} z)|Dh*(~zXkMjjFWHe8mUx6)ndCkD5`x)`fWm6drcbR!j8&1X}5&# zzOQm^2*p-AaqY^A%NWtIWi!q^q81Bj?%P>Dhl`VN&vErzGrZrQVe>`hviO}G_MFLY z1=;$&ppF##UJw1#Wjts8L^0$#&P2A7d~`XEJY;__FAa3ZI<5J05j!L@_8wtBla%_Z zq}Z3{@3oIrXu}4qxU?vu4Dp?@CX|He$gkR-o)N$~seBfv+% zM9>2+B|%JkF{t!AuD(l5KZBUYO3-OC8vFPCCybdu^0mKW*ejX(62%tfIbNQ8uTIPe^~bE%iUG7w7WuXgI)%GJM?ns70_RSz5{wC^cv_p zp;tq%f?f;#Rp@olcR}9`y&n1==ndU&$88sE?u5alj-FG&U$lAUvL@{5bT@%sqS=UEdTfDOA%l`m$1TQ)O literal 0 HcmV?d00001 diff --git a/src/Avalonia.Visuals/Assets/UnicodeData.trie b/src/Avalonia.Visuals/Assets/UnicodeData.trie index f96106a5fadbaf7451963aca766dde9d3cd9aa8e..46175ea644604070b68508261a7b088d18b70412 100644 GIT binary patch literal 9464 zcmV24oL_k@!-nt z!Ij;E3+GKjkeOyObi$AiCL}WuKso9@Jh=Phpl`xMG;J6rAq(QTKomruvaX2k>Z&h% zsHBN;2qP#1NDz2Fm3_Ob>|>wed;eQ?Z_n-9_g3BN`buvOu z;7<4~{2hD^z6p=QVVHzx;U)M5{0ja9anCpaJaVH0eJ zE8%K*|7>sU*CE^myXo=;sCymOV=tC(gFE3~xS!@4J|>v@Pjg!uXvue}Wjx56;I8{P+pkp5Q)uZJy*4dX_H zPatg%!u{|mxCg%2;?3`mk@qawcd-0TcnR@e!mr^`EdLhaVT6UQ6JJii?UyUFT>a2Avgq2z>nbt_!+zk*T4s12yMQG@C~pL zZ-utSUiw97a}Zue`>(*e7JFXh;_roP;DfLPR={bn3wFa^I0N>z zXzw?j-tsPt<74pc#r}S_sNNV2Z-+YNyi!z^S_4c5l_LUCLF~r}B@Ccb3#Qy~>jG+~tTI|K!;CaMfgr8HtTm@#9tn2VM z7iH$>z0VF0;63x_p|8WU21 zmj4dnm*DT=%kThv1s?2}$(zZW$(zZW$%{Mh0M5IggD=1}cD9BWWozq<88ei^Tu*-` zKOO%E#J>&Sg(L7(-us@K_$Kn7=az#!it?pdUi_!D-v2`QD!fLnFFOt64TR2|`g(J) zyZ{d4diO?LbFReo>1oh~v{T`a=45~Vd6Zs{+Zf-vc5j^IyIA*q3C4OY`R){6fRix~ zL$Dtvp=F_A^uT|Ce}vbe1#@vWTni7vui@?J=UuQLCSfk#U-ZEq@)?pP=7(@8;#a_qR$P0~I=m0e@ve0gI$!6WeN4)0p%w+N3)`m+}Xdj|Dhgr7_5{~OX?gEwF< z$~xq_X|9*I5aCi-HFvsv$3pM=>r7aJYq@g}u7f@}33)3Qdgb1VZ~$p28tXrXtV`!_WI zZxPTvL+l=%T*mrjz{MEvAT_ud^ zxz1^S=`)_c)?DY`XTMn(k2h%ObgIj=DDwX6aj+eVoy}P&EPu4gbz$G{MR5!Iz8&u3 z#_$=$KL<-OwiWRAi2oxTOlZ#k4d=Og$lu-y?Vr}^nLzqC5I)7_KacnsoqxDqNn!oZ zYSKT`?ey;nAERr~*IFOHqy5jCk83F?b?R%IpZ)nI$i~iTOEBJLusX$Y^MAiD`s4Xt z_-8cShcSK<0_^bK0ltyg@%}oeRG~+-&#ijH7G^;wQnW@D^AE%V9N~PJUy6G7n=t>?P5B z4sCp)(+eLUakTrD6JWn%{t07!#Am#hV4WUE{*xis=`z%tIsxY4>Q1?z(y^b3edQeZ zMGEWkEA(+1uBBh+`Z9{RwkoRY%tPKnSPHA)bU2fiKij7sLw>iit~mW0mL9~*FaTTO z4Dubr67P3Q?KB)Ndfx}V3*mo;YvFphk*4#+-^jcPZJYvcfijo=HY}e7YoK?@bovIw zx4?E7CjHaA_aXcMSMGYmN9gj$5bnWP_805VXP@Gh??L>F@E}e51`QuZ_&sht#?aO$ zXuCh4^K>_spQ3TT?dRbY_$B-+Z9_b#{s--c{6>s?=OWHmnp)ek|GT}Uw9&cEdK_t3 z;q$ris@(iygH!ZP{Os zMwRv77-gxqE&I=OvR1ZpHEX&Z4Ysjnq}5n+u*K8es#!1C6(f0La3p689>n#oVHw-I zO)Q&koTP2T+nX&TUfvMNGfZQ%VY}hr!JM)2psB?z4@0CK%Iz27;kI;;bs5AukoDPt zem4OLA8Sho>3;~@a!a@2u0NUoKlFdn{qkUZ`*+~}Z;*Wu?_IIalYO5VcK6Q~V+W9U zb#IGt$w6XGAO0ObbU^0v`K>$3VJoz&lo`m7v5`b~0wA3`5~`AP5Z zr2n8Yhkwt*xXK*Qq2GG>l@a6Oe2D2mbyQ_^$E`a)Ss9`#8BbL}@*n5wtKv!Tb79+( zjV)+?PsC6~wO^^`Bx-*rifYRJ9n#Qi1lu8S)A4%Mi`Q>2Y^h)7%Sh_i(AWGLmga`G zbiA^5mfN0QZhXo$BGF5i8S-ALkIQFB zU9z3~+%jIskbFMm`;Lll(@#%=`h>WwPjS9g;o(_0-^>ahs>U=VnQz%x`(~fmdNS5` zoP@(rKHqV%W1Yk=q_Q4gl3}xTvmSRGAvq7uHLX;zbABu0T?#G7fiBZD7Ok~w<6VI? zV%b{LT`sP7PB@F!+iuu3&#I->#c$5Jq4@eN%CEOl6Vx{uKM^!$-VHgc!CK_potrkk z%XHnj%liy?JZBmQkWP%PakP=oi*H!t&FT2AoV#2>8(AXPYr17aT^nl7$-ak7E$%jW zh!Vz>^&B2g$bMA`*e}Wc?YHmlM&52NUsjg&r@Q_5F|uRF9q)?fxW_KS3XBaqZkPqq zcU;&xsjiRXV4sSjwDR~=-sxx~<-@$D@Q(h-)KXn+Wo}}N6X+1i5yxQ4Wg1hT$hm23 zok_Xpx0tSpOPxB0mRi2c3~Or&`OPBrOC46?$^%Xf8~0#`)p#riXm%fqQxfNZFztpJ zYnW@V+Uu5ubN0E8F-o~(Lz$ZzxOk`ci@+h}XV**x|UuS-qwGlMm zd|la`@Z%Hv-uH;9h1I|FznQN6h&Mp`0aE5%ulzXeuXg^qz;PY9z)P#nv82Rx5z|%m zf&|qS`%=Z(x{&Y)U7sU#-B>zLOrKDcmDQEagE%hV*SW_{Eo?k|`($}4p#8ez9_5Kq zx*iqO4I-A)t*6tLU%sj=zb<5Xl&;SxT{n?zz4`LB<1xfMO3(jvd}Bhp`>^eFjHB+I z7np&4$kvhdSG>eA3KUt+zsd5&+Ep!w6;xO3%Wq6%UC1(77q(tS0V~U|Gr!E2ktuR* zi+QSXihZ{~V`^DJ>vt&V?q?I1;T&)ZJ+~l~sXHeL={jXuTuhUdQ}rWD*CDR&5v(sU zK|?~8&(itj39hWHZN3fN`9J!isbv!VF~=01zsz()>ACuHoTE5oxpJ!I%RNW{0H~G7BRhzp5+9JkRTE@PYOfBO(ZI^WZ+VU$RJPsy1rZ2~U_>RQ*-4&HT8>bU>M?#O*(^(`m{P>`$`vDK1^sf0#a^ zj4v)^49BoeE{tD>lvCxo^=W+S9^12%hDam7E#XbA%(-O8>fu&Pish;5u=rz$M^Vw> z?)Q>3S-PxVv-P9)uPf{I3p1`X*Dr3Y&#_*oW*q6`y|x!B?2lR9Yh}cBI-p5)te1k1l-t8J8)SgF`X?lZGhxK>$eX&daeYi`9d6I9RdS~HLHby%``MZ8`x zp{{l3TCo`Gb86Te>GYp>tXLG!b6jDs^{Xqa(8PM~HN~*w3fq67n3pOFaa~{y5%a6Yg)hVSJOf@7#Wy?d9E;^B_Kk_y5cd zo!<4#KD@`*q1^w`Zqj^@KaKiieO2vLuZ4aM3t=9vT47EP1=ZvGb^G4n7#p83wWzj#)cS)Mk4mpk z9;@H%Zv}7R;$g25;xxL}agEWCsF;>g!}0g9sbzev?Liy0ICTJNI>zWY{phq)_j+Yn zQsr}Tu7@(!&k^(Z{Gj_H7*lhIh{gHV*e*P>6|!IPN{zx((ydURsvBhg6vQq(7be zXNc;~A>C#q&0c%)#I$D4P0e}|^TDutxI8Hsp!WZ zKaKH(=|9XE<8D{hk50L{`R0*6S`cQ%y|xnWe?c5m5X(-y)2U%;vVCCcrkpYLPP=9j zaG_&#&9~$(w5JJqq%OG^QTq=} zEe-2V+o>$dHyDH6l60AUPn%j~b=~cc_jW;wc)uu9pj69R=X;?aov{?Sw%fmJYN^dT z+UUajSf+Gp-Y5eJlR6(VCE8Cf3{v?;mbSuX%qvx(x;EP`lHW?Pa=PORdcPVqznQ*u z->=4Tk7M#Vw0O^BcK=>+ysE#?)Fp1ug0MZsZ7Xieb{xw(l*ilLS#`qh!}&e4V$;y} z;aRC`I7TC0b7yglpC!NJD(t=`yO*VOA6)O<_fhvgj3>|2D<`{uqhC6ouZjnWdat6p zs5oAq;<|TIl(v$74GudVau3Y|+_<8CPwzgLsoU_-XEd?DIt#kEr+CO6q=J1o^JnTZde2 zJ^6JJ=Xi+D<7RGNH)>+@&d(p|H?Gg>n*Drl`gPvFOq}_AzwEhCWIlF`c%K=vXGD>F z@?L&O5cc~}{_g{Z4e$G3mRD`JLTRQ>R&Dd`MQyv;`HHK4lsa(Rd@%?Fi z*F8ti0L7{SxSgGUJzv@_#MVbFG`ZrZ+5&NGkSao#9 zTt&H>d~{E0mXVF6)MX{2Jm@r#_P@%?gnO>N!Zu2)tvU_k6Y@oaSK7u!2t~U4Z{{pVXPUqRb9kL&p;!ki1cVtBt=!F5Sevg|*u^^ZEg zf&0sBON!6;D;F2O4{>~ayqZe}X@6>sZA>EWr?fabLECZwWr9TAU#fGh*zoPi`kF+# zsB}^?1{C=53~sZH3a&@fAe)bIlD9BfVxRkwwx7%2ACcH@Sk?KKF>#?&L)zlwBEBp^ z>hs)v&}TU*vhm5rK8dnH{EpYD3vGV^^(!4#gRud})v&K1Pc%MY%T4N?i%O=Ih`Qr? zt*unWIUotOa~$G+s$KDso@0^jCx22r{I+oy@_AujlR8jGCGzXt5-U*DKK$&#d-k|A z)Bnd%FD`!l>_@&#X=%OkrB0oo@o#+2)S?_0uKBX!?tgf1uY;7GxYBVwedY9LxqN;z z22Qq=N+)Z)A4Qos6o(vb9VpN1l8Ed0tjw|^+wUXp^GT`IN{mkb<#mqI;5yr5TMZia zOQc1y@ljJtl({DB)V0BOg{fz9e>+maz8aNZU;AD#wJ7%|$5JYv=QQY-q>0?~_IFGz zgXh_GNWcBGpOVIB{Z+iDip#`BjxmLB>Q>ub*4^Hd%dJ?2Iv*6RSTx&Kf7z)ibNDnq zZ?J58P?wLF>Am(#i!3FUFDn;RH%OTJgPYaog9^4^6r($wny~=V^TAI{EvoSsFK}E1 zilpV|SiV?Y)pA%tb;Z6^Wyp7ZWL?NITOWSi_;n;y%X}KYej>gcPc}ZWT{iDBvhAbF z4>G#NagA}i8?=@~NrqbBHtj zR9PTPE9V-2Q?v0QQ_BGA5`$~(nqe>dYtDJx)zg17EJIrEy<4igS~WNt_FVgx&U4 z)Tz!^tCbVtL3L#DW*s@-ks@vLabg1T37SSiR-c6=ZR&ixMrV$We$UjRoMT*{`q3>H z#gWZU&4i#fcXauPfTWQ&rf#=uZ6$Rsk{Nt6UjMGL>)mI&>E)|}$5K5R?<0pCsUsAh zM#}T1?#8|{xh=0cDOT;yUwN$y-W%eo)yc#!8M9KTfvH=c(!u ztlgmX-&ZxYsOp!{j|`vZS1vC7SXUA0w~Vk%<1D?*g=~B(8?o*>5<4H`7>Y{~#{^NJ zx{%h@5$}8Pe2C9YWuE`EseJBj7|rxOb(w8cR$Hl1jp0`0HH*|AztV0HW)5`6nUpHt zvowp;f4C**1Nl7x>p#y@N@e{O_uq+=Z~vWEW=Q`>pE9*H9J`xrSJe5>lY6$ufVEe| zHK3>~#P#1H?03AW^|o;=#J+g+`=%CBb`tHXxcdgexJmcHgxe3JW1*Fpu8VCCiA(-= z(>))ZWN8!Yj%{)Vj1`0LImPSmb6iay@1L;} z71V#FXeA=f^^bZC`AOxznF_ItpWaK8zn^9p;{6^2x7Qt3sv5?N>nvT;^$k(qubr^% zvGNBHAM0^k<2`o6{q~dG*JYBtpLo>%lOAK(u{Jfo^ta>X3!IuXe*>QM?}@Y8zjU`- z7QeCP_2K_!^!Om{`sMR<+KW>k9bX?8XUpQ8sOCs#-c)lEG&f=9uE(eUX1Al?I_mB- zL1n_OW7O^Kw?2O3*F8?+Sf8lJV>8=(C}7_Bacw+k#^S&-tIpVVrxqXIZr58D?RAK> zEmEcHiu!t2EICR+`MSdPr7D}m>wUgpg;qCz{f<&A%BryX?MF;4vT?6y@;63x?X0#! zDPHN+X@AAkBI|qEm8MZzXf>!^iy$7Pe6gdXv;L3af8GS%Q>Yv=8t3~c&LKL)dQ(&! z`*4(NvEdQ3Zh5<~+{`s>n)mBbu2;nRY+bUxcOc&%i}Pa(GakS75!)8Y@>KPk8NW_@ zvif1Rjc%Q|=C;}GEt}_{ev<4zjJwgs-4WUe{rn$6*!p4H4-!XTQQL@n4x-G9?AeFG z!q0o)IE>lpxVia#C#`K4_4v^lf79D3;{FzSKk1$wZVp41Ph$JWTHlCQ_~SGIA+|wM z=fR}bKkoU9YkbYtPiy~3`g|1&dTxo*Pwkw>h|9Vnu8ZG%spd26e5bX&nr%Ps{VVPB zse66iM4c#fKgU(J(d4{GM*Gj=d5lRvA12H^M16kDiT>=G%*SaMnf#pu6ZQEeQTw;M zTQb4=Kiq0%s&7`d#AkrU6$WAD$g|e`^aY4opS=!}?=Oexvq9a@I9M7@7r#4~eV^XL z*X#D$8WPI%@$J^yZ)xb0jg2k4&uiV=QdghvpX9S~l>|LpU*fojBxK+7vvpzZkZni8 zhR^1)9xhd$5TBdRmh8E>;yx-1RnO23ItJMDZ)^2&3_dn1yRdIIw(solVF}9TJH~~a zL7Jw6l6|P#m2#&>lpPs7uR|7kkGE*xl`O9b=vPWEaf^^V_rYiapHIU1cl_8 zT8HuHo|o3wlC7SE#d&|#W*lCXt+@-cmGs|pss4^H?!TWqQOB>d zK0_(>?blBlbDY$AroCQCZm+|~iN~_inw~-QcSuZETq8-}56+&x{*FuU9FHoUr}Ms8 zM6OkvsTT*?`Ubtq`HqV{D|X-aOfBQ6yPLMrl+J0p zJL{KIu_Rf$6~?#PuDD;n!@S1JUgy&u$0Y|1W9z}jXR$Xto1J@(v1=sfe)dya7UP1= z2W?kf8%V#xjT^P+o0IRkd;4-m3E@S1)X$0+L-rkcP`SvLzIzV`qt)1bVa`R;^7qBB^{SkH`31>p zRp`3Nc2)2i6vl2CzXIoudvk6Ww!VmccAA7<4V6XtNOt?=9pI4VQ5d5kE3<_j`_tzc z!`)Bo_G2Gpe^wp`De7hSN1?Cd#OV1>^RLShvHr*C7)I&xC^tTpQ30Rkwk>{d&(#eY zSH3en#Z8Wj#0vPpKvc8^^%MIdde7SBz`cd}&(q#twZc_A~g({b@>yr{=poJP50 zk|#@(<%#u4oBN(NwTy4JJ&UY5x?>gd$Ar95j@X8bsx4VxI%M;s>YE?4?)-`M*?BhK zH!E9vGIlsyl~yZLx1Wb=6305O9Dwn-+FP2fd@-M?B30nxG6gF2_wy2Cc-F<<$ zrb=zu8e8ks8HI{zrH1t>j)PAh z?b)iHnytEf4wCx>rhJb>@_Cf6tbQ-TB=PxQ7*Rhzizk;`yR<;gf8_u1To|OBG>%LWYH7TzC!(!hFSIK*jEIAn~WUI6~TT%ahE2?~J#&X%J zv}ddOrsN!>CVf;FW-GreeH~O6SzesJ4o;=R-+_Sqs7hwGs!Os}TAHou>TH!xPTv5h ze;wYh0SQaCmAW$RU!}OgQ-FM%mAV4$yRcz;Ec}@G^e&VqOcHLeUFGwfni8(J(pKEz zxC)S8Ze^-K$5EhT3Q|yvyK9H-p>eTZ7wwD0+kaweaofGiu5TYISkB5A#$O??Q5hTE z;?#@<$gi?8Rk*^bDJm4WP%K}&bLe%DvPn|) z+fVxz(+BUf8=iNcm5CuIv?E-6YRIl3?M>ZnyZoMepN6!aU#t@4`MvQlruA^dat&#$ zoT7ZvV^)mQ?|H=OGK|W4|K}s?Na7i?VN9%dl*qGnYfzRrCJ6I9)+8kTywV^^J+D;T zEmo$_3>BsQJWf$F&(n-kGtWDU)#_}?o;Q@Hu{DlstuY->)6thqEt4xPEys4+wcoe! z;j=3Z<3~GF&jlXKcZ`kFD4qw3FhVP1RqFtPaxdA=SP(Ua6OHiEam{uq-Q zca;A3FU;2DDe*fN;Q3FRHCaE0vbl2(zx|tee%ds0KNTe-SGT;@aEu#pEq24f*|lrP z%}C#ja&Fk|-Lqvg+8rc}`uoL@xVSvXm31BDpuNivwir7>cKu4mH#lPYd+zIuA&K{SvitwD{$zPc z(Y-FpqR+5lmh+Zo->Q9u-?wCO)qa@7eqCFp$LCPcaT-&<+AyA4$l`n+pC-#^VYlg# zl@rrgyQ=oZG0Doy`tWO8wNAS8qgoD1*J)pOZv2*`inD#)Ig2`XQOA|FgVEba@?4U} zc{0&++i^O7v5sRz5W4>Kv!8t5D~4%@E=ynQ!j==tO7r7*O>L$D?AJ+gT;nyo&O{;E@A&JbxJlO`#XeN&{A;Uh z9%T7(lJ%`C&i>;Nv@eNeb=QS2A7$T=g{r<(b(`IX4Ifdrp^VQ_*Gaw) KSzIV9{`>!hP~f-# literal 11728 zcmV;>Eicjl5C8xG000000iciowk=80?41jA99Nm=tNl5)RF>PNM`Sx9wq!fDvk6PU zVIZ@xNOnS6old(WcWgPWSY2s5kmWHB9^SBNdkK+CPN$GfAPABu1H@ngWY~eQ3}A*~ zXmx~W9Kj~5z{bmaJiJcku;ehy?7yn7b2HOI7<83&NYg zTfuw52f@d{C%_}%@4#2VH^JlJ2`~$O49xgDEKn?2ABlj15bg|;FsXn;P>D;5Lp77 zKr2`Y)`CsoCEyj{HQ;ocAY2Q%9qa-bkOQ}YhdW_kyCA$C><7O@+S?KSc5nx{3)}-f z2tEcr0UiNg0AB&$1doI7fu}TGOHc;*9e551O9i0`L_sH512%%o!Ij`u;A-#==uSYU z!4SxSH-H1+-QW&z7bt*x!NOKt&p}3-&>x_H@(Z99?rFq*Hy~!f zYLxjjn$TwR$c^-y*IRl;M98tcJS>Tne7iZ0it&UqJpA zJO>;80QqlVaWm!;xImi|ZD>2_RUkhAn%gV$s-w9wKY8wLt@ghNY3@f@H{@n;AM#%T z`AX0W4y?o&0{;!f;7@@=sMjE57JL$UKMTGH{vG4!^$2?gX@3gNfM>xU0U^&a&<-vF z>%b;(0&!meAA$QN;1%FMgKgkC@DTVbl=*gW5z=*oPb2KpTK<(7vyguaz68Dkz6o}K z49J1U!4qH>ya7B7egggl{44l3rbAc_{V4C{D=Yr5K>h=0LU@x6>sVdZF-j{d@_Ud^ zGWR+g9s&ozyTBc&K~#eE&v4EN>WO2%J+SAlJet3d+XhO%xuk8r*Xg!66C z;l2&#){omf|X_iiu6J>Knb z-+qp8F>FfXzHkHNRj}=y2z#dueMob;SUEfV0q&W91P&tnAh;6Y{fPe=I}VhW~ZoD{y}u{1x192j7PK4#>N}4bb&n z_@4mx!2coe0C*UD3it7!g?tq9Q80@%Uxfe1kUs%G1wR8n2fqNn1V26>C!B8=;ry8B zI6oiGuLVM2Yr)sB?tTjt!JW7++!-R{I4d0wk)!7k&bNVZz71BIw}F@abXh#>o`HK- z+uw=UcY4!7(rcT3}-W90C6b7Nh=Gfc@a}Y)#*AA%3F- z9|lFxgf@tQL2v~8BUp^`uLkb}kAa_n4LCC&0v~0+6=u;74}xbvH`*f$M#1Au{))*_ zju*FJuaA3(8^D|3ex#*3UgK8?TfY3P|9ZIZ2Hye8qws^f!Gn=t`3@`df)#>r&5DXU z#pHvG_rw25@G~xaqf`lNBz@Atc55UD&+8Y?x4#ft8yq}8{)89$`1=O`aGZs$L_2&Q?n%hUAm53&lU(|Y4Mn6o zi1cMH{bBf@g#UkopRw?NhWs6P4txOdN45T5jy)_Ghx-8_!QBZShIz?;BZxpW1_<489R-ix?9!4Kg6Ul&&Ps&i=y z9RCM#A4J^8So}Z3|7-9G_#Xkkhx_j!zslmj#rC`fld=lnPuiGUULCd;ZT~&+Lxlen zoB_{rY5oZJzk{XN>n_1QUj!@QZi8F}R>Iv4xi(r|t{Iz<=5lZ)_)nl0yq3$CgnIzo z1a1MZ2m4)=TLAB{NOuRqGZ?#fL2ibfn;XbI2AkSoM;qfqHugN=vd)D+W3E6uToC{- zWAZO;(!AOt{NZz;AFhEN*VI7V;Qw5WbvcOp|ARjH5_EqZe4Df3ak$IceD4&5o#08h zp8>z9Q=4zX8ut|Vt)cuq(7gxz0pY)a{1X?y1ozFiBJ9=|DA5Mg|IIbhxuViXYnUw5 ziMK)5+ilp$^#4c5mxDVHc85!@sN#H=5Y|*DKCG+xs54-)EBMC0H~50vtiyRoc1f3Y=$xkAp4n ze+KfugTDu_gul1#Y@V03Rr2&{{|C~B^WnBio^K*;KjaNy5DbF^JD1IXTfpN;^M6r~ zy|8_+4}OR;p6RT}pMxh*?ra15ui%p(!`3r4_TGa!J&X81daTojkdGw)_SKsQVCMrS zER!ntH~$4=FoWMk(VRx)!-U%J-89v4EBQ8yRdl` z#_)crA~pUR?)QK^xD&hq_PlX*<$svo!sOjj<@catkRJsPg5%)xEPR$@6)SfY<6Yno zP&8JZ4IcydfeG*_F8!n6G4M6;4Nzp^)8PADx~IW2%>4^3kA!<&srvtuik!a;_mkim z7WOkHe+~Jpbk_dgqO3o#a{nk*q;QcSENZQ|`LacXwQ1q4c=oHclAqWA5V)kZBDMb! zdofEh8xk*X4ga~ldhu%3wzr)Fw%k@{u4%PzhkEP3?HuO%kn4YA$a8%59N@Nq=JM?G zke4@b{N4OKZl~FR+PZKK>?sQ&u@Cis6BxywZItn5_}|QNIeU-AXOM{r`Kl~dUt|hP()m@0SrNZHa`me%k=v9p+8>ee|@H7 z^p&-bZ^0Y;dV4S?2;+W0ANzY4vOYfBBf~(3yVNR0Zo+)X!o5{Etj7-n8TqnEpU$^N zG=}p>g&0CI$d_UH(+Dpi|8C?@>;86~4D?RbalB`tW6CDJSZA`(Uvx+_^=B@N0i~Hb zhvk$_y^1zzrVQ>WlRQ%%@)RA?7aih?E_F?rq$z@ui9IC)Q{Ztf|Kk?!q7PXNuz!m- zwoL`7XX+f0MGK~E`hCizO{RS6D?U#sHCW%I3;JPW)iPMGLSxRg^%u-_w5Qwe$&2V>F&~y&awWwEEd1!Rr-kw7_ zoi&T%0`)Eyr0Y*S?^t>L5nXm&zjr>YtIB<)E(!G`8^r71*f7XJcNgsM0_0#baU43B zUBK3SR(SAWu0mf{7>^9-+~1ed&wo(WM_k_*ZJ&gQ^ zxiTh9(ke{f*umYdP&gYW7>Z`ETpQXPIs~ZzRfavxtuz=Fy48Rn)QNz?>*#xa6W@Do zlG=;?tV`lZPpogqBc}$3Tu~a_8#?8|6OK$G-6S~u;hZq5vkh#~!-!o|%SiX5!Guov zX5{0Tycv3I{AC8QvStxKTO)-0@$}xhlS*TpFo@ZmK{!Dw9@Y{v9Yc#lMXAG@K-wDD z=RU1lRGVv_YJoyn!?&Ff3UpkDu)kLjrr1F2K0vYXD;^<-yu{*(J10{tY*JAd1^xo7 zPXVY1Q#pvGEpT-v&abjE$Z$4C1`1qWGHDZqvrjeX9wY4QT%fT0_Y?Nt;27M;7LXDq z^fYlj?Ksm%Ojr3jtSMl6S;gKG&Z$Z!kfAOmLwLy|U&#S-$V&!YB?~vPx)9_v>N3qJ zv-*+2{<0yw%=~3T+OkPrGHHjB!7j4Nb86Y3E6U8C5?6j1{#l3ei9QZ2+tbQs7nbf<$rKh?1xXgV!nxP=S0#r^fY4dj( z=BEqBk@x;(I45z?p_P6*M9j_VNo;ze0oh?Of^_(hM9LtZlI5^ZdfP!|#Y zDC#1ydP$HH2eGtKuFk~yB`yt_AhB|yTwXG16Opq|GU#r*XwVfsEPsau8Wlh##-yGcvTO@=TsNk0%8%B?bxCGxbq?#*daP3n6@Fzs))*T|TO-z6 z`@KRw^DC?jnp?ltWBo!sR7QcTUjY!Sx5^-cUu9urfy$*(pi5zp*;=~3636EuZM|NP z_1FbuW5@Vwu%<434fV6Z{wSUfCM*cD&n|7V%@{t84gRNylxu+z|PZtnZ2Dw}th(p?m>ja2) z;#vg6POSe95b}vOe$fL_=#-cZ5`$1)l-UynWbjK!BO-4P(@h+rq3jOG4huvMiPKG> ztrOQn7j)Q^-@)kVOxd<&c^LC+EyfHWtv}bIKLddH@wPUYGWoJg9@3EuJBYKlj5ZQpnH zeKUbr99iTc<}Sir^nv06(#C!B!Z5RN#gInfq~bBZVkk$cF~4$-*H7_am(sxcDFH)F zr`nHuL&iA5$D48O)MeI`V*x|_wX)e?ohAt z2HFN)5ntCE2?Cnyk9F2@Zcvb3@c{YEuedCi7-RSiX3Pc03}s9_v@N1hT8uTjPL!|4 zH2_R6##t9A!%a;8EaGQD9QW+;fJuTB&J>bNrYsVu`Lmps|o46tiN-Dm>p^Ty;ZUvp61A& zWZ)eFL;5@5mLZ?S(zfdFv`Wt-yUI_{H|g>n9+MnYwT8rfq1L$fx`#tG@v9!!CaRGq z&>l}Prr0}Co-mMR?<|u68S-Zlmo*@pkY(@alWm{|>^Efx8{|6MBYV&0$XON@w^DI;2`XnDY&=9Cjy67CKF9mizIN%8~DI>nTI}%^M z--X;?XSzM;?RJRkUU0@WoWD$`fB9yeIMR|ydx(qoHw@DBwZI(!q>Tq*zfRhK?3TRq zc(FrS(H-$a59-m=XpS7vA#E5$~dzvgITy|aTYvlfLz)!<>H;46mZ?ipu5D~;})5} zWFU?+!8jw?gEM20!gB&Ckc3;F!2W_GWq5`ljdQrP2{L1kFaUXr_4?9@( zXNwLwv{iBq>dcYagSrE9$d_4zXGp-n8vLH)0aJ~(!zSU1 z31cgN6~Ykr@kXcJCcJXyF!2Naj@IZ`ZmfI&6Be~dVk8uU{LIu#TB ziqHIHg%I(D;?bV_`%?P*6lw0ARlWY66VTqHp22UE8J%s6EjmN&cgsxv-IjB55b;AS z-Ozmc13E(ne}=J>Bh%Yl3GZ#z%gf-rEYo}GybZg!a)yk0gtyF>nLT+szRPtxezSv{ zL3()yxNmwLSh&CFGw*Y+{h|a2vA*4k-?~Y!!p7h_JiN%%h%vi|-1f3<1c}gLbO1fLxAWjJW{KY_9m0YZlIV+e~i5Hjc&agHe3Ko0#YMv%@2zWSh*KihXin1Or7 zhWKE3@a;k-q>(1=0Qr2sdMSbS3JBuLBNJUS2%j+!AF2;&3y8BP^U+a3*|E1FkV$)t zYjHQ$7o*N&2s_5bAM*(r%01?h()PVVc{`p-VUYIyhV5tFM#?O%%qSs$iG#E;xZT=c zFUZ6`GO>LIX=fZDoB2As{fGm5ZSyN>^t%Byjk8?t+YvO^r)l(&f${Va3*O^uy@_wd z{Hz1S&+9`t|FEEhHK0@nXl{)gLLb^t$^^%pPU6?t4D{F_!Uiz_$fl3G5Z}c?9J^aZUBfF&il0^(fwteyU1iw}^YzMd`*lf=|ds*H|~s>s&w< z`Qtc`k6Vzs2K{Ovc>$hDHVIF05Xv360PXLB%mo-P9C7GPLPrwBUHn#1!tWEMfGF=c zA0bl8eho=x3d5l{>}0 zO%TU(kZ}u$#gj=oa>yHZfIQ^S;5kgPI!|1SIqQSM+T-)6!?@40fy~x}wV3ZNAPf7Y z>u?PR(Pen%lzk^k#BaBv4K#O_xy;<}6>`W+23?{>8nw4l7tJ4K^^v%`MY%c>h+6og zT$sq!j|@b)bi|+WE?a1ymNv?zmsq}YA5t}BCSqV zh+Dj;lPnnM#J<4=WRZVde^2UBPf&p4Ph5v_<`U@^j8%M-OJXw0+>!}V7AM+#-;1>I zyAI>i1yrZ`7mxHt8U@XbxxQ_YIg$uZf)p2)V&N%6SPGDF{6#RnT#!b2X^@FvoHJzj z#U}Kv2ck{rcZL*0Upo-x(sdy}8SFoWwds@#X0R5{I6xlzzJxrbrPj=+TUzaQ%2P6N zmuh5s>2XsCWlwW%0_AIQ-UFr)R)#FG{3RyK2%B9xG7q_Eqf<-K{w^S^HeQK(*g!7R zm3%P0679q&??M}K&|KT?s`&Y|(=2@nWz4R`Js2yC>a-S-37Ji}{%yj1twPnqQ?GO0 zYn5=nUd9<%*@WqC)De`quoBXjIA*(5fbjyTf%PVah-bOPIzx8tM`#BJ$Yr{cPo@cd zg?E>hEg)?iZ!?e6^Agqqlru}{PXpw#KTZ+my9;KZXQoDo(>L3J@z-&MQ0hP%1OUy= z_cYq221r|XJJ6OkX{NdOY0gb9+E>AIl8Oby#gju?#RqB}_hBXwXQ#^iDi^0(bBZU0_(9~^%0GjOUzZ~_pfErQ%MW{=i zAv^t@ND~qS{ikqtbIJu|oV%UaR{`?b{v!56VgL{;Pr_bMGSKB=|2K(oH_0)(d_*|y zVJx0nj`nf^A?*yxow0zlv9TQe6fk5q2A9{jd@podh-Vn;MAwD*ZCNL)LuTvIi?H^% zgbaDrA?yPYUU(7C{A+}4+D1kBD#u~yI_#0=^_zv})tmMIMeY|i3uw|;SRS$T6L70I8&|=2)5wDF1yCKwI+-8`{J+97g96Y`b+%FV zXe-syzN3}@K~Ld#@)SdcpBmJ^Y1A(rAmp|F`TD+d--&d<^-p_jlLX=uH4w9bP;Sg2 zO^lInQC=)S63o5?=O*SSLz|FA8AhOjN<&qBgD5~61G{;jI_-b z5X;{d#q&EZAQQWLT)xkQ?CQS}b@G68E9%D4wh{KZfJ}6+*%%k%H||{k&HXRUje8wQ z-H1J+1?1xYBZ|Fyt0?0c)UpMWh$ln-Q@um3AVa=c2Kh!1(!JPM)rj1is`BqK_|t$q z{*O=Ehdt?lkbUMSi}zAegWo+!oIYZ1;%p-i_K;0|$WS(!yyufF!W0v!c(k8flt(7{ z6%TzwF7r@8ZL9crwpqZuv>gR9{&K$BSFUq^$v(@G8>C-$sYmwN zk8%z3P4>99kPkgHzowm;FHf%WpK-|nUyx0Ekwtrw$M&WM^%vDxJ_S6!sKM(g;mk*_ zArJXrz~wnGS(dRsBqBQT27Qkv-oWqSkcW1N`}|%`{CWPZo_OeX3Lz1hXRH34N93G9 z_4z#)@~MBZ&=d&+V*00=v!Rk$5-wAQZ?H}UB#RDAao@U&*^qD`<}<&{uFDDh7Axm> zFn@dGY*u*jUw1hXx{{8M_HNfFJT(87dU zs`O8_R-_gdHxT3W#SC_5nT=VFgvsyKGK-TL#n#HRQ)#%=?!v9A@*Y=wqFm3P?_RSUaZ-T`1p-XQDYKO~T4R!FsF!6=w&kc8(#8=!`8Lbj3LG9%f}Iwa|H%!<<`b6szmY zVGG7rkLXMq*2BpL(9d1NCqvLb$=PvJzDw8*hAh5OKQ#Y+_E7^{^s}GLygz4f=c?G1 zAJ@rLm5~$nzhyJdnfD7j@SEL`gIz;R$}I1YL%Lo0%jVsKEZ+|3 z+2Md{_?;@+a$IMZX0Nc{!0~x|c5r3%>-E|^Ur!k*EB14ATXd;-%&!<2X6MDjb)eq` z%F5n|9}e5ZO>iin0MuREbg3`~+1wwRUuTx-<5Z~{$K96nV>~OC^I(6&=*PV7*WUMX zZRI+yQCNDs_UrOef6VDuAr<%wa2FUVXQOIDflFIp=?l!QXl3^rez)iHDh_suxaNxu zLfUykM}#}tiS-(kHe`j<8`ODbb+SyVpqq1#F#i=MbR)l(?+RU>W|a40Uk6TeVW&A1 zpYu%#kODU+RgQv5m}(MNFyvR@9$SfhsSCzeV*jO+twM2Wt73>(IU5x&eZi!Rf(eR6 zS*nRH#RaNK`xGp6tDMbaF59nQ$Uni>S+S)kgiASOo(vTQXF5uVeCST>N${O1zH6;!iXQT}NxJ6jpCrVeiB-b3* ziQ~7>cnBn!{-jG(hqE))C5pxQ*f@uxOSS4uZ(zlsE8;6qzdDlIj^~|i$Y{S~t{4$S z?Vjo4+L2I}#E7oI9ud05kdT-SQ6O=4NhaY@<`?mdgBT+5^|&txkyLKmyi76TTwIFt z#|`;XK*X7#7$z-`fs+m7v4-kX0(uYE12v+A#31|4sifV5w1o)Hv`S$T4VU=FaPj;6 zR>>zK&TPdniG+)SIiQ3{=_Wj{RVPj}W^5?k7%t+T#?!&*()@#sr-MUD+cV)1ulOXs zHC#%&!zF$**5ME--3mKGB%WL46m+L-kZ!0DZN>$d zOcZ*f42jtyaW+M{@Ghk90*iDxqLaD?^G?de|7HzAO)um*}*Tr;6aJQ|+?=LP8^|Dk$`6Ge?zY-EE>{nL79WsSg_^pni z4&waE1`%hfVu(a9!I)u4`!F6%h_bjSmp016;%N7Hm~@6q63@sb87cf`pRyszrAsm? zu0ubBNmIDQH{qK0I<$`&o6trYudDdAwDCHUVtP^rlCU9e5}(;(tts zI4KJYzYm)faNh(}PM>TNpER)5@NM6%22_VS$*g?UMH)F^)H>-=eS8~G4VVd&ZzZY$ zlrv%T4aNxv3hcZ0g^(Bs=sT1HHS$EO6!AtDd3b8gS7FC!E_BicWE+m+nt3!t((~_o zZ4ZgoK#^U3lPO)7y5#VVfiCur-7Sm*RqqNO@^CF10%a7I#%##lLTb+3?>&@I8(&jir8-qga zXzyp)XUQd1{;k4|BDBYRJ-ifqk)8uwVA5w@Phy_TTyh_J7>2hSl7EF`~!BR8)zqwBd$fB!@V z;X4@_j%;rq_pXY|IC8dRncO)aHfnfh&gg=$eR`XIDpVGS^gV^ej0=QVsATkU(KSCt zZkit}UGv<;h21po9(5q6&xefuPUum%E92s9{+y2Nz|eyCAmuD*ceu75Sg<#c*|^QR zv_bZA?H1hK-)XwD28NivtVvw9P7Jx+7iB}xIpk4hLu|Kf19fLD#){0yTDWBsvJUaG zN3sj_x9jsWTf-h$u0e0sHSLkJvOTgeuAK{OH;KXx$!&$%eS-DTUf+xT-nX%Ddcc9(6I^VxPepLXR! z=H0%_kX>G9k&U!$0C`;ih!T&T#}+Af5M?{y;Q55eDgop18m z+z1n6n>0d)9YN`;)du6d<;&vBwzAu*yt(+fGI<^R_sD&YXU}!anbad^QO8^ZX|HV` zAq26ecwO7)Y@NfbkLSXT>CMK6F^(UHR_$bFhgmsh_RNhxyYyCLhA)GsQDcPnYrnl# zGqD)YWrT=)1Z@*0`EcoJ+22@>Kqo`4<@eiswfz%#zIGxcWFb7=ybW@_$Q#B>J#`5+ z+U7N%5UdS74~L80vrBRVm~i>r-9(*`Yu?*uE7v3;sQUzq{fX~#516dmXX9WdO>lmKPYerDx-idma zU9Y;ioi}# /// Draws text. /// - /// The foreground brush. /// The upper-left corner of the text. /// The text. - 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); } /// diff --git a/src/Avalonia.Visuals/Media/FormattedText.cs b/src/Avalonia.Visuals/Media/FormattedText.cs index f6129eaf6a..12c40e4d59 100644 --- a/src/Avalonia.Visuals/Media/FormattedText.cs +++ b/src/Avalonia.Visuals/Media/FormattedText.cs @@ -1,214 +1,1415 @@ using System; -using System.Collections.Generic; -using Avalonia.Platform; +using System.Collections; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using Avalonia.Media.TextFormatting; +using Avalonia.Utilities; namespace Avalonia.Media { /// - /// Represents a piece of text with formatting. + /// The FormattedText class is targeted at programmers needing to add some simple text to a MIL visual. /// public class FormattedText { - private readonly IPlatformRenderInterface _platform; - private Size _constraint = Size.Infinity; - private IFormattedTextImpl? _platformImpl; - private IReadOnlyList? _spans; - private Typeface _typeface; - private double _fontSize; - private string? _text; - private TextAlignment _textAlignment; - private TextWrapping _textWrapping; + public const double DefaultRealToIdeal = 28800.0 / 96; + public const double DefaultIdealToReal = 1 / DefaultRealToIdeal; + public const int IdealInfiniteWidth = 0x3FFFFFFE; + public const double RealInfiniteWidth = IdealInfiniteWidth * DefaultIdealToReal; + + public const double GreatestMultiplierOfEm = 100; + + private const double MaxFontEmSize = RealInfiniteWidth / GreatestMultiplierOfEm; + + // properties and format runs + private ReadOnlySlice _text; + private readonly SpanVector _formatRuns = new SpanVector(null); + private SpanPosition _latestPosition; + + private GenericTextParagraphProperties _defaultParaProps; + + private double _maxTextWidth = double.PositiveInfinity; + private double[]? _maxTextWidths; + private double _maxTextHeight = double.PositiveInfinity; + private int _maxLineCount = int.MaxValue; + private TextTrimming _trimming = TextTrimming.WordEllipsis; + + // text source callbacks + private TextSourceImplementation? _textSourceImpl; + + // cached metrics + private CachedMetrics? _metrics; /// - /// Initializes a new instance of the class. + /// Construct a FormattedText object. /// - public FormattedText() + /// String of text to be displayed. + /// Culture of text. + /// Flow direction of text. + /// Type face used to display text. + /// Font em size in visual units (1/96 of an inch). + /// Foreground brush used to render text. + public FormattedText( + string textToFormat, + CultureInfo culture, + FlowDirection flowDirection, + Typeface typeface, + double emSize, + IBrush foreground) + { + if (culture is null) + { + throw new ArgumentNullException(nameof(culture)); + } + + ValidateFlowDirection(flowDirection, nameof(flowDirection)); + + ValidateFontSize(emSize); + + _text = textToFormat != null ? + new ReadOnlySlice(textToFormat.AsMemory()) : + throw new ArgumentNullException(nameof(textToFormat)); + + var runProps = new GenericTextRunProperties( + typeface, + emSize, + null, // decorations + foreground, + null, // highlight background + BaselineAlignment.Baseline, + culture + ); + + _latestPosition = _formatRuns.SetValue(0, _text.Length, runProps, _latestPosition); + + _defaultParaProps = new GenericTextParagraphProperties( + flowDirection, + TextAlignment.Left, + false, + false, + runProps, + TextWrapping.WrapWithOverflow, + 0, // line height not specified + 0 // indentation not specified + ); + + InvalidateMetrics(); + } + + private static void ValidateFontSize(double emSize) + { + if (emSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(emSize), "The parameter value must be greater than zero."); + } + + if (emSize > MaxFontEmSize) + { + throw new ArgumentOutOfRangeException(nameof(emSize), $"The parameter value cannot be greater than '{MaxFontEmSize}'"); + } + + if (double.IsNaN(emSize)) + { + throw new ArgumentOutOfRangeException(nameof(emSize), "The parameter value must be a number."); + } + } + + private static void ValidateFlowDirection(FlowDirection flowDirection, string parameterName) + { + if ((int)flowDirection < 0 || (int)flowDirection > (int)FlowDirection.RightToLeft) + { + throw new InvalidEnumArgumentException(parameterName, (int)flowDirection, typeof(FlowDirection)); + } + } + + private int ValidateRange(int startIndex, int count) + { + if (startIndex < 0 || startIndex > _text.Length) + { + throw new ArgumentOutOfRangeException(nameof(startIndex)); + } + + var limit = startIndex + count; + + if (count < 0 || limit < startIndex || limit > _text.Length) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + return limit; + } + + private void InvalidateMetrics() { - _platform = AvaloniaLocator.Current.GetRequiredService(); + _metrics = null; } /// - /// Initializes a new instance of the class. + /// Sets foreground brush used for drawing text /// - /// The platform render interface. - public FormattedText(IPlatformRenderInterface platform) + /// Foreground brush + public void SetForegroundBrush(IBrush foregroundBrush) { - _platform = platform; + SetForegroundBrush(foregroundBrush, 0, _text.Length); } /// - /// Initializes a new instance of the class. + /// Sets foreground brush used for drawing text /// - /// - /// - /// - /// - /// - /// - public FormattedText(string text, Typeface typeface, double fontSize, TextAlignment textAlignment, - TextWrapping textWrapping, Size constraint) : this() + /// Foreground brush + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetForegroundBrush(IBrush foregroundBrush, int startIndex, int count) { - _text = text; + var limit = ValidateRange(startIndex, count); + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + i = Math.Min(limit, i + formatRider.Length); - _typeface = typeface; +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range - _fontSize = fontSize; + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } - _textAlignment = textAlignment; + if (runProps.ForegroundBrush == foregroundBrush) + { + continue; + } - _textWrapping = textWrapping; + var newProps = new GenericTextRunProperties( + runProps.Typeface, + runProps.FontRenderingEmSize, + runProps.TextDecorations, + foregroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + runProps.CultureInfo + ); - _constraint = constraint; +#pragma warning restore 6506 + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, + newProps, formatRider.SpanPosition); + } } /// - /// Gets the bounds of the text within the . + /// Sets or changes the font family for the text object /// - /// The bounds of the text. - public Rect Bounds => PlatformImpl.Bounds; + /// Font family name + public void SetFontFamily(string fontFamily) + { + SetFontFamily(fontFamily, 0, _text.Length); + } /// - /// Gets or sets the constraint of the text. + /// Sets or changes the font family for the text object /// - public Size Constraint + /// Font family name + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetFontFamily(string fontFamily, int startIndex, int count) { - get => _constraint; - set => Set(ref _constraint, value); + if (fontFamily == null) + { + throw new ArgumentNullException(nameof(fontFamily)); + } + + SetFontFamily(new FontFamily(fontFamily), startIndex, count); } /// - /// Gets or sets the base typeface. + /// Sets or changes the font family for the text object /// - public Typeface Typeface + /// Font family + public void SetFontFamily(FontFamily fontFamily) { - get => _typeface; - set => Set(ref _typeface, value); + SetFontFamily(fontFamily, 0, _text.Length); + } + + /// + /// Sets or changes the font family for the text object + /// + /// Font family + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetFontFamily(FontFamily fontFamily, int startIndex, int count) + { + if (fontFamily == null) + { + throw new ArgumentNullException(nameof(fontFamily)); + } + + var limit = ValidateRange(startIndex, count); + + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + + i = Math.Min(limit, i + formatRider.Length); + +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range + + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } + + var oldTypeface = runProps.Typeface; + + if (fontFamily.Equals(oldTypeface.FontFamily)) + { + continue; + } + + var newProps = new GenericTextRunProperties( + new Typeface(fontFamily, oldTypeface.Style, oldTypeface.Weight), + runProps.FontRenderingEmSize, + runProps.TextDecorations, + runProps.ForegroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + runProps.CultureInfo + ); + +#pragma warning restore 6506 + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, + newProps, formatRider.SpanPosition); + + InvalidateMetrics(); + } } /// - /// Gets or sets the font size. + /// Sets or changes the font em size measured in MIL units /// - public double FontSize + /// Font em size + public void SetFontSize(double emSize) { - get => _fontSize; - set => Set(ref _fontSize, value); + SetFontSize(emSize, 0, _text.Length); } /// - /// Gets or sets a collection of spans that describe the formatting of subsections of the - /// text. + /// Sets or changes the font em size measured in MIL units /// - public IReadOnlyList? Spans + /// Font em size + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetFontSize(double emSize, int startIndex, int count) { - get => _spans; - set => Set(ref _spans, value); + ValidateFontSize(emSize); + + var limit = ValidateRange(startIndex, count); + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + + i = Math.Min(limit, i + formatRider.Length); + +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range + + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } + + if (runProps.FontRenderingEmSize == emSize) + { + continue; + } + + var newProps = new GenericTextRunProperties( + runProps.Typeface, + emSize, + runProps.TextDecorations, + runProps.ForegroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + runProps.CultureInfo + ); + + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, + newProps, formatRider.SpanPosition); + +#pragma warning restore 6506 + InvalidateMetrics(); + } } /// - /// Gets or sets the text. + /// Sets or changes the culture for the text object. /// - public string? Text + /// The new culture for the text object. + public void SetCulture(CultureInfo culture) { - get => _text; - set => Set(ref _text, value); + SetCulture(culture, 0, _text.Length); } /// - /// Gets or sets the alignment of the text. + /// Sets or changes the culture for the text object. + /// + /// The new culture for the text object. + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetCulture(CultureInfo culture, int startIndex, int count) + { + if (culture is null) + { + throw new ArgumentNullException(nameof(culture)); + } + + var limit = ValidateRange(startIndex, count); + + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + + i = Math.Min(limit, i + formatRider.Length); + +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range + + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } + + if (runProps.CultureInfo == culture) + { + continue; + } + + var newProps = new GenericTextRunProperties( + runProps.Typeface, + runProps.FontRenderingEmSize, + runProps.TextDecorations, + runProps.ForegroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + culture + ); + +#pragma warning restore 6506 + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, + newProps, formatRider.SpanPosition); + + InvalidateMetrics(); + } + } + + /// + /// Sets or changes the font weight + /// + /// Font weight + public void SetFontWeight(FontWeight weight) + { + SetFontWeight(weight, 0, _text.Length); + } + + /// + /// Sets or changes the font weight + /// + /// Font weight + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetFontWeight(FontWeight weight, int startIndex, int count) + { + var limit = ValidateRange(startIndex, count); + + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + + i = Math.Min(limit, i + formatRider.Length); + +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range + + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } + + var oldTypeface = runProps.Typeface; + + if (oldTypeface.Weight == weight) + { + continue; + } + + var newProps = new GenericTextRunProperties( + new Typeface(oldTypeface.FontFamily, oldTypeface.Style, weight), + runProps.FontRenderingEmSize, + runProps.TextDecorations, + runProps.ForegroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + runProps.CultureInfo + ); +#pragma warning restore 6506 + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, newProps, formatRider.SpanPosition); + + InvalidateMetrics(); + } + } + + /// + /// Sets or changes the font style + /// + /// Font style + public void SetFontStyle(FontStyle style) + { + SetFontStyle(style, 0, _text.Length); + } + + /// + /// Sets or changes the font style + /// + /// Font style + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetFontStyle(FontStyle style, int startIndex, int count) + { + var limit = ValidateRange(startIndex, count); + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + + i = Math.Min(limit, i + formatRider.Length); + +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range + + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } + + var oldTypeface = runProps.Typeface; + + if (oldTypeface.Style == style) + { + continue; + } + + var newProps = new GenericTextRunProperties( + new Typeface(oldTypeface.FontFamily, style, oldTypeface.Weight), + runProps.FontRenderingEmSize, + runProps.TextDecorations, + runProps.ForegroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + runProps.CultureInfo + ); +#pragma warning restore 6506 + + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, newProps, formatRider.SpanPosition); + + InvalidateMetrics(); // invalidate cached metrics + } + } + + /// + /// Sets or changes the type face + /// + /// Typeface + public void SetFontTypeface(Typeface typeface) + { + SetFontTypeface(typeface, 0, _text.Length); + } + + /// + /// Sets or changes the type face + /// + /// Typeface + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetFontTypeface(Typeface typeface, int startIndex, int count) + { + var limit = ValidateRange(startIndex, count); + + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + + i = Math.Min(limit, i + formatRider.Length); + +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range + + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } + + if (runProps.Typeface == typeface) + { + continue; + } + + var newProps = new GenericTextRunProperties( + typeface, + runProps.FontRenderingEmSize, + runProps.TextDecorations, + runProps.ForegroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + runProps.CultureInfo + ); +#pragma warning restore 6506 + + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, + newProps, formatRider.SpanPosition); + + InvalidateMetrics(); + } + } + + /// + /// Sets or changes the text decorations + /// + /// Text decorations + public void SetTextDecorations(TextDecorationCollection textDecorations) + { + SetTextDecorations(textDecorations, 0, _text.Length); + } + + /// + /// Sets or changes the text decorations + /// + /// Text decorations + /// The start index of initial character to apply the change to. + /// The number of characters the change should be applied to. + public void SetTextDecorations(TextDecorationCollection textDecorations, int startIndex, int count) + { + var limit = ValidateRange(startIndex, count); + + for (var i = startIndex; i < limit;) + { + var formatRider = new SpanRider(_formatRuns, _latestPosition, i); + + i = Math.Min(limit, i + formatRider.Length); + +#pragma warning disable 6506 + // Presharp warns that runProps is not validated, but it can never be null + // because the rider is already checked to be in range + + if (!(formatRider.CurrentElement is GenericTextRunProperties runProps)) + { + throw new NotSupportedException($"{nameof(runProps)} can not be null."); + } + + if (runProps.TextDecorations == textDecorations) + { + continue; + } + + var newProps = new GenericTextRunProperties( + runProps.Typeface, + runProps.FontRenderingEmSize, + textDecorations, + runProps.ForegroundBrush, + runProps.BackgroundBrush, + runProps.BaselineAlignment, + runProps.CultureInfo + ); +#pragma warning restore 6506 + + _latestPosition = _formatRuns.SetValue(formatRider.CurrentPosition, i - formatRider.CurrentPosition, + newProps, formatRider.SpanPosition); + } + } + + /// Note: enumeration is temporarily made private + /// because of PS #828532 + /// + /// + /// Strongly typed enumerator used for enumerating text lines + /// + private struct LineEnumerator : IEnumerator, IDisposable + { + private int _lineCount; + private double _totalHeight; + private TextLine? _nextLine; + private readonly TextFormatter _formatter; + private readonly FormattedText _that; + private readonly ITextSource _textSource; + + // these are needed because _currentLine can be disposed before the next MoveNext() call + private double _previousHeight; + + // line break before _currentLine, needed in case we have to reformat it with collapsing symbol + private TextLineBreak? _previousLineBreak; + + internal LineEnumerator(FormattedText text) + { + _previousHeight = 0; + Length = 0; + _previousLineBreak = null; + + Position = 0; + _lineCount = 0; + _totalHeight = 0; + Current = null; + _nextLine = null; + _formatter = TextFormatter.Current; + _that = text; + _textSource = _that._textSourceImpl ??= new TextSourceImplementation(_that); + } + + public void Dispose() + { + Current = null; + + _nextLine = null; + } + + private int Position { get; set; } + + private int Length { get; set; } + + /// + /// Gets the current text line in the collection + /// + public TextLine? Current { get; private set; } + + /// + /// Gets the current text line in the collection + /// + object? IEnumerator.Current => Current; + + /// + /// Gets the paragraph width used to format the current text line + /// + internal double CurrentParagraphWidth + { + get + { + return MaxLineLength(_lineCount); + } + } + + private double MaxLineLength(int line) + { + if (_that._maxTextWidths == null) + return _that._maxTextWidth; + return _that._maxTextWidths[Math.Min(line, _that._maxTextWidths.Length - 1)]; + } + + /// + /// Advances the enumerator to the next text line of the collection + /// + /// true if the enumerator was successfully advanced to the next element; + /// false if the enumerator has passed the end of the collection + public bool MoveNext() + { + if (Current == null) + { // this is the first line + if (_that._text.Length == 0) + { + return false; + } + + Current = FormatLine( + _textSource, + Position, + MaxLineLength(_lineCount), + _that._defaultParaProps!, + null // no previous line break + ); + + // check if this line fits the text height + if (_totalHeight + Current.Height > _that._maxTextHeight) + { + Current = null; + + return false; + } + Debug.Assert(_nextLine == null); + } + else + { + // there is no next line or it didn't fit + // either way we're finished + if (_nextLine == null) + { + return false; + } + + _totalHeight += _previousHeight; + Position += Length; + ++_lineCount; + + Current = _nextLine; + _nextLine = null; + } + + var currentLineBreak = Current.TextLineBreak; + + // this line is guaranteed to fit the text height + Debug.Assert(_totalHeight + Current.Height <= _that._maxTextHeight); + + // now, check if the next line fits, we need to do this on this iteration + // because we might need to add ellipsis to the current line + // as a result of the next line measurement + + // maybe there is no next line at all + if (Position + Current.TextRange.Length < _that._text.Length) + { + bool nextLineFits; + + if (_lineCount + 1 >= _that._maxLineCount) + { + nextLineFits = false; + } + else + { + _nextLine = FormatLine( + _textSource, + Position + Current.TextRange.Length, + MaxLineLength(_lineCount + 1), + _that._defaultParaProps, + currentLineBreak + ); + + nextLineFits = (_totalHeight + Current.Height + _nextLine.Height <= _that._maxTextHeight); + } + + if (!nextLineFits) + { + _nextLine = null; + + if (_that._trimming != TextTrimming.None && !Current.HasCollapsed) + { + // recreate the current line with ellipsis added + // Note: Paragraph ellipsis is not supported today. We'll workaround + // it here by faking a non-wrap text on finite column width. + var currentWrap = _that._defaultParaProps!.TextWrapping; + + _that._defaultParaProps.SetTextWrapping(TextWrapping.NoWrap); + + Current = FormatLine( + _that._textSourceImpl!, + Position, + MaxLineLength(_lineCount), + _that._defaultParaProps, + _previousLineBreak + ); + + currentLineBreak = Current.TextLineBreak; + + _that._defaultParaProps.SetTextWrapping(currentWrap); + } + } + } + + _previousHeight = Current.Height; + + Length = Current.TextRange.Length; + + _previousLineBreak = currentLineBreak; + + return true; + } + + /// + /// Wrapper of TextFormatter.FormatLine that auto-collapses the line if needed. + /// + private TextLine FormatLine(ITextSource textSource, int textSourcePosition, double maxLineLength, TextParagraphProperties paraProps, TextLineBreak? lineBreak) + { + var line = _formatter.FormatLine( + textSource, + textSourcePosition, + maxLineLength, + paraProps, + lineBreak + ); + + if (_that._trimming != TextTrimming.None && line.HasOverflowed && line.TextRange.Length > 0) + { + // what I really need here is the last displayed text run of the line + // textSourcePosition + line.Length - 1 works except the end of paragraph case, + // where line length includes the fake paragraph break run + Debug.Assert(_that._text.Length > 0 && textSourcePosition + line.TextRange.Length <= _that._text.Length + 1); + + var thatFormatRider = new SpanRider( + _that._formatRuns, + _that._latestPosition, + Math.Min(textSourcePosition + line.TextRange.Length - 1, _that._text.Length - 1) + ); + + var lastRunProps = (GenericTextRunProperties)thatFormatRider.CurrentElement!; + + TextCollapsingProperties trailingEllipsis; + + if (_that._trimming == TextTrimming.CharacterEllipsis) + { + trailingEllipsis = new TextTrailingCharacterEllipsis(maxLineLength, lastRunProps); + } + else + { + Debug.Assert(_that._trimming == TextTrimming.WordEllipsis); + trailingEllipsis = new TextTrailingWordEllipsis(maxLineLength, lastRunProps); + } + + var collapsedLine = line.Collapse(trailingEllipsis); + + line = collapsedLine; + } + return line; + } + + + /// + /// Sets the enumerator to its initial position, + /// which is before the first element in the collection + /// + public void Reset() + { + Position = 0; + _lineCount = 0; + _totalHeight = 0; + Current = null; + _nextLine = null; + } + } + + /// + /// Returns an enumerator that can iterate through the text line collection + /// + private LineEnumerator GetEnumerator() + { + return new LineEnumerator(this); + } +#if NEVER + /// + /// Returns an enumerator that can iterate through the text line collection + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +#endif + + private void AdvanceLineOrigin(ref Point lineOrigin, TextLine currentLine) + { + var height = currentLine.Height; + + // advance line origin according to the flow direction + switch (_defaultParaProps.FlowDirection) + { + case FlowDirection.LeftToRight: + case FlowDirection.RightToLeft: + lineOrigin = lineOrigin.WithY(lineOrigin.Y + height); + break; + } + } + + private class CachedMetrics + { + // vertical + public double Height; + public double Baseline; + + // horizontal + public double Width; + public double WidthIncludingTrailingWhitespace; + + // vertical bounding box metrics + public double Extent; + public double OverhangAfter; + + // horizontal bounding box metrics + public double OverhangLeading; + public double OverhangTrailing; + } + + /// + /// Defines the flow direction + /// + public FlowDirection FlowDirection + { + set + { + ValidateFlowDirection(value, "value"); + _defaultParaProps.SetFlowDirection(value); + InvalidateMetrics(); + } + get + { + return _defaultParaProps.FlowDirection; + } + } + + /// + /// Defines the alignment of text within the column /// public TextAlignment TextAlignment { - get => _textAlignment; - set => Set(ref _textAlignment, value); + set + { + _defaultParaProps.SetTextAlignment(value); + InvalidateMetrics(); + } + get + { + return _defaultParaProps.TextAlignment; + } + } + + /// + /// Gets or sets the height of, or the spacing between, each line where + /// zero represents the default line height. + /// + public double LineHeight + { + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Parameter must be greater than or equal to zero."); + } + + _defaultParaProps.SetLineHeight(value); + + InvalidateMetrics(); + } + get + { + return _defaultParaProps.LineHeight; + } + } + + /// + /// The MaxTextWidth property defines the alignment edges for the FormattedText. + /// For example, left aligned text is wrapped such that the leftmost glyph alignment point + /// on each line falls exactly on the left edge of the rectangle. + /// Note that for many fonts, especially in italic style, some glyph strokes may extend beyond the edges of the alignment rectangle. + /// For this reason, it is recommended that clients draw text with at least 1/6 em (i.e of the font size) unused margin space either side. + /// Zero value of MaxTextWidth is equivalent to the maximum possible paragraph width. + /// + public double MaxTextWidth + { + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Parameter must be greater than or equal to zero."); + } + + _maxTextWidth = value; + + InvalidateMetrics(); + } + get + { + return _maxTextWidth; + } + } + + /// + /// Sets the array of lengths, + /// which will be applied to each line of text in turn. + /// If the text covers more lines than there are entries in the length array, + /// the last entry is reused as many times as required. + /// The maxTextWidths array overrides the MaxTextWidth property. + /// + /// The max text width array + public void SetMaxTextWidths(double[] maxTextWidths) + { + if (maxTextWidths == null || maxTextWidths.Length <= 0) + { + throw new ArgumentNullException(nameof(maxTextWidths)); + } + + _maxTextWidths = maxTextWidths; + + InvalidateMetrics(); + } + + /// + /// Obtains a copy of the array of lengths, + /// which will be applied to each line of text in turn. + /// If the text covers more lines than there are entries in the length array, + /// the last entry is reused as many times as required. + /// The maxTextWidths array overrides the MaxTextWidth property. + /// + /// The copy of max text width array + public double[] GetMaxTextWidths() + { + return _maxTextWidths != null ? (double[])_maxTextWidths.Clone() : Array.Empty(); + } + + /// + /// Sets the maximum length of a column of text. + /// The last line of text displayed is the last whole line that will fit within this limit, + /// or the nth line as specified by MaxLineCount, whichever occurs first. + /// Use the Trimming property to control how the omission of text is indicated. + /// + public double MaxTextHeight + { + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), $"'{nameof(MaxTextHeight)}' property value must be greater than zero."); + } + + if (double.IsNaN(value)) + { + throw new ArgumentOutOfRangeException(nameof(value), $"'{nameof(MaxTextHeight)}' property value cannot be NaN."); + } + + _maxTextHeight = value; + + InvalidateMetrics(); + } + get + { + return _maxTextHeight; + } + } + + /// + /// Defines the maximum number of lines to display. + /// The last line of text displayed is the lineCount-1'th line, + /// or the last whole line that will fit within the count set by MaxTextHeight, + /// whichever occurs first. + /// Use the Trimming property to control how the omission of text is indicated + /// + public int MaxLineCount + { + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "The parameter value must be greater than zero."); + } + + _maxLineCount = value; + + InvalidateMetrics(); + } + get + { + return _maxLineCount; + } + } + + /// + /// Defines how omission of text is indicated. + /// CharacterEllipsis trimming allows partial words to be displayed, + /// while WordEllipsis removes whole words to fit. + /// Both guarantee to include an ellipsis ('...') at the end of the lines + /// where text has been trimmed as a result of line and column limits. + /// + public TextTrimming Trimming + { + set + { + if ((int)value < 0 || (int)value > (int)TextTrimming.WordEllipsis) + { + throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(TextTrimming)); + } + + _trimming = value; + + _defaultParaProps.SetTextWrapping(_trimming == TextTrimming.None ? + TextWrapping.Wrap : + TextWrapping.WrapWithOverflow); + + InvalidateMetrics(); + } + get + { + return _trimming; + } } /// - /// Gets or sets the text wrapping. + /// Lazily initializes the cached metrics EXCEPT for black box metrics and + /// returns the CachedMetrics structure. /// - public TextWrapping TextWrapping + private CachedMetrics Metrics { - get => _textWrapping; - set => Set(ref _textWrapping, value); + get + { + return _metrics ??= DrawAndCalculateMetrics( + null, // drawing context + new Point(), // drawing offset + false); + } } /// - /// Gets platform-specific platform implementation. + /// Lazily initializes the cached metrics INCLUDING black box metrics and + /// returns the CachedMetrics structure. /// - public IFormattedTextImpl PlatformImpl + private CachedMetrics BlackBoxMetrics { get { - if (_platformImpl == null) + if (_metrics == null || double.IsNaN(_metrics.Extent)) { - _platformImpl = _platform.CreateFormattedText( - _text ?? string.Empty, - _typeface, - _fontSize, - _textAlignment, - _textWrapping, - _constraint, - _spans); + // We need to obtain the metrics, including black box metrics. + + _metrics = DrawAndCalculateMetrics( + null, // drawing context + new Point(), // drawing offset + true); // calculate black box metrics } + return _metrics; + } + } + + /// + /// The distance from the top of the first line to the bottom of the last line. + /// + public double Height + { + get + { + return Metrics.Height; + } + } + + /// + /// The distance from the topmost black pixel of the first line + /// to the bottommost black pixel of the last line. + /// + public double Extent + { + get + { + return BlackBoxMetrics.Extent; + } + } + + /// + /// The distance from the top of the first line to the baseline of the first line. + /// + public double Baseline + { + get + { + return Metrics.Baseline; + } + } + + /// + /// The distance from the bottom of the last line to the extent bottom. + /// + public double OverhangAfter + { + get + { + return BlackBoxMetrics.OverhangAfter; + } + } - return _platformImpl; + /// + /// The maximum distance from the leading black pixel to the leading alignment point of a line. + /// + public double OverhangLeading + { + get + { + return BlackBoxMetrics.OverhangLeading; } } /// - /// Gets the lines in the text. + /// The maximum distance from the trailing black pixel to the trailing alignment point of a line. /// - /// - /// A collection of objects. - /// - public IEnumerable GetLines() + public double OverhangTrailing + { + get { - return PlatformImpl.GetLines(); + return BlackBoxMetrics.OverhangTrailing; + } } /// - /// Hit tests a point in the text. + /// The maximum advance width between the leading and trailing alignment points of a line, + /// excluding the width of whitespace characters at the end of the line. /// - /// The point. - /// - /// A describing the result of the hit test. - /// - public TextHitTestResult HitTestPoint(Point point) + public double Width { - return PlatformImpl.HitTestPoint(point); + get + { + return Metrics.Width; + } } /// - /// Gets the bounds rectangle that the specified character occupies. + /// The maximum advance width between the leading and trailing alignment points of a line, + /// including the width of whitespace characters at the end of the line. /// - /// The index of the character. - /// The character bounds. - public Rect HitTestTextPosition(int index) + public double WidthIncludingTrailingWhitespace { - return PlatformImpl.HitTestTextPosition(index); + get + { + return Metrics.WidthIncludingTrailingWhitespace; + } } /// - /// Gets the bounds rectangles that the specified text range occupies. + /// Draws the text object /// - /// The index of the first character. - /// The number of characters in the text range. - /// The character bounds. - public IEnumerable HitTestTextRange(int index, int length) + internal void Draw(DrawingContext drawingContext, Point origin) { - return PlatformImpl.HitTestTextRange(index, length); + var lineOrigin = origin; + + if (_metrics != null && !double.IsNaN(_metrics.Extent)) + { + // we can't use foreach because it requires GetEnumerator and associated classes to be public + // foreach (TextLine currentLine in this) + using (var enumerator = GetEnumerator()) + { + while (enumerator.MoveNext()) + { + var currentLine = enumerator.Current!; + + currentLine.Draw(drawingContext, lineOrigin); + + AdvanceLineOrigin(ref lineOrigin, currentLine); + } + } + } + else + { + // Calculate metrics as we draw to avoid formatting again if we need metrics later; we compute + // black box metrics too because these are already known as a side-effect of drawing + + _metrics = DrawAndCalculateMetrics(drawingContext, origin, true); + } } - private void Set(ref T field, T value) + private CachedMetrics DrawAndCalculateMetrics(DrawingContext? drawingContext, Point drawingOffset, bool getBlackBoxMetrics) { - if (EqualityComparer.Default.Equals(field, value)) + var metrics = new CachedMetrics(); + + if (_text.Length == 0) { - return; + return metrics; } - field = value; + // we can't use foreach because it requires GetEnumerator and associated classes to be public + // foreach (TextLine currentLine in this) + + using (var enumerator = GetEnumerator()) + { + var first = true; + + double accBlackBoxLeft, accBlackBoxTop, accBlackBoxRight, accBlackBoxBottom; + accBlackBoxLeft = accBlackBoxTop = double.MaxValue; + accBlackBoxRight = accBlackBoxBottom = double.MinValue; + + var origin = new Point(0, 0); + + // Holds the TextLine.Start of the longest line. Thus it will hold the minimum value + // of TextLine.Start among all the lines that forms the text. The overhangs (leading and trailing) + // are calculated with an offset as a result of the same issue with TextLine.Start. + // So, we compute this offset and remove it later from the values of the overhangs. + var lineStartOfLongestLine = double.MaxValue; - _platformImpl = null; + while (enumerator.MoveNext()) + { + // enumerator will dispose the currentLine + var currentLine = enumerator.Current!; + + // if we're drawing, do it first as this will compute black box metrics as a side-effect + if (drawingContext != null) + { + currentLine.Draw(drawingContext, + new Point(origin.X + drawingOffset.X, origin.Y + drawingOffset.Y)); + } + + if (getBlackBoxMetrics) + { + var blackBoxLeft = origin.X + currentLine.Start + currentLine.OverhangLeading; + var blackBoxRight = origin.X + currentLine.Start + currentLine.Width - currentLine.OverhangTrailing; + var blackBoxBottom = origin.Y + currentLine.Height + currentLine.OverhangAfter; + var blackBoxTop = blackBoxBottom - currentLine.Extent; + + accBlackBoxLeft = Math.Min(accBlackBoxLeft, blackBoxLeft); + accBlackBoxRight = Math.Max(accBlackBoxRight, blackBoxRight); + accBlackBoxBottom = Math.Max(accBlackBoxBottom, blackBoxBottom); + accBlackBoxTop = Math.Min(accBlackBoxTop, blackBoxTop); + + metrics.OverhangAfter = currentLine.OverhangAfter; + } + + metrics.Height += currentLine.Height; + metrics.Width = Math.Max(metrics.Width, currentLine.Width); + metrics.WidthIncludingTrailingWhitespace = Math.Max(metrics.WidthIncludingTrailingWhitespace, currentLine.WidthIncludingTrailingWhitespace); + lineStartOfLongestLine = Math.Min(lineStartOfLongestLine, currentLine.Start); + + if (first) + { + metrics.Baseline = currentLine.Baseline; + first = false; + } + + AdvanceLineOrigin(ref origin, currentLine); + } + + if (getBlackBoxMetrics) + { + metrics.Extent = accBlackBoxBottom - accBlackBoxTop; + metrics.OverhangLeading = accBlackBoxLeft - lineStartOfLongestLine; + metrics.OverhangTrailing = metrics.Width - (accBlackBoxRight - lineStartOfLongestLine); + } + else + { + // indicate that black box metrics are not known + metrics.Extent = double.NaN; + } + } + + return metrics; + } + + private class TextSourceImplementation : ITextSource + { + private readonly FormattedText _that; + + public TextSourceImplementation(FormattedText text) + { + _that = text; + } + + /// + public TextRun? GetTextRun(int textSourceCharacterIndex) + { + if (textSourceCharacterIndex >= _that._text.Length) + { + return null; + } + + var thatFormatRider = new SpanRider(_that._formatRuns, _that._latestPosition, textSourceCharacterIndex); + + TextRunProperties properties = (GenericTextRunProperties)thatFormatRider.CurrentElement!; + + var textCharacters = new TextCharacters(_that._text, textSourceCharacterIndex, thatFormatRider.Length, + properties); + + return textCharacters; + } } } } diff --git a/src/Avalonia.Visuals/Media/FormattedTextLine.cs b/src/Avalonia.Visuals/Media/FormattedTextLine.cs deleted file mode 100644 index 42859f698a..0000000000 --- a/src/Avalonia.Visuals/Media/FormattedTextLine.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Avalonia.Media -{ - /// - /// Stores information about a line of . - /// - public class FormattedTextLine - { - /// - /// Initializes a new instance of the class. - /// - /// The length of the line, in characters. - /// The height of the line, in pixels. - public FormattedTextLine(int length, double height) - { - Length = length; - Height = height; - } - - /// - /// Gets the length of the line, in characters. - /// - public int Length { get; } - - /// - /// Gets the height of the line, in pixels. - /// - public double Height { get; } - } -} diff --git a/src/Avalonia.Visuals/Media/FormattedTextStyleSpan.cs b/src/Avalonia.Visuals/Media/FormattedTextStyleSpan.cs deleted file mode 100644 index fcb631d1eb..0000000000 --- a/src/Avalonia.Visuals/Media/FormattedTextStyleSpan.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Avalonia.Media -{ - /// - /// Describes the formatting for a span of text in a object. - /// - public class FormattedTextStyleSpan - { - /// - /// Initializes a new instance of the class. - /// - /// The index of the first character in the span. - /// The length of the span. - /// The span's foreground brush. - public FormattedTextStyleSpan( - int startIndex, - int length, - IBrush? foregroundBrush = null) - { - StartIndex = startIndex; - Length = length; - ForegroundBrush = foregroundBrush; - } - - /// - /// Gets the index of the first character in the span. - /// - public int StartIndex { get; } - - /// - /// Gets the length of the span. - /// - public int Length { get; } - - /// - /// Gets the span's foreground brush. - /// - public IBrush? ForegroundBrush { get; } - } -} diff --git a/src/Avalonia.Visuals/Media/GlyphRun.cs b/src/Avalonia.Visuals/Media/GlyphRun.cs index 53b35fb31b..dfefa98f50 100644 --- a/src/Avalonia.Visuals/Media/GlyphRun.cs +++ b/src/Avalonia.Visuals/Media/GlyphRun.cs @@ -11,8 +11,8 @@ namespace Avalonia.Media /// public sealed class GlyphRun : IDisposable { - private static readonly IComparer s_ascendingComparer = Comparer.Default; - private static readonly IComparer s_descendingComparer = new ReverseComparer(); + private static readonly IComparer s_ascendingComparer = Comparer.Default; + private static readonly IComparer s_descendingComparer = new ReverseComparer(); private IGlyphRunImpl? _glyphRunImpl; private GlyphTypeface _glyphTypeface; @@ -21,12 +21,13 @@ namespace Avalonia.Media private Point? _baselineOrigin; private GlyphRunMetrics? _glyphRunMetrics; - private ReadOnlySlice _glyphIndices; - private ReadOnlySlice _glyphAdvances; - private ReadOnlySlice _glyphOffsets; - private ReadOnlySlice _glyphClusters; private ReadOnlySlice _characters; + private IReadOnlyList _glyphIndices; + private IReadOnlyList? _glyphAdvances; + private IReadOnlyList? _glyphOffsets; + private IReadOnlyList? _glyphClusters; + /// /// Initializes a new instance of the class by specifying properties of the class. /// @@ -41,25 +42,25 @@ namespace Avalonia.Media public GlyphRun( GlyphTypeface glyphTypeface, double fontRenderingEmSize, - ReadOnlySlice glyphIndices, - ReadOnlySlice glyphAdvances = default, - ReadOnlySlice glyphOffsets = default, - ReadOnlySlice characters = default, - ReadOnlySlice glyphClusters = default, + ReadOnlySlice characters, + IReadOnlyList glyphIndices, + IReadOnlyList? glyphAdvances = null, + IReadOnlyList? glyphOffsets = null, + IReadOnlyList? 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 /// /// Gets or sets an array of values that represent the glyph indices in the rendering physical font. /// - public ReadOnlySlice GlyphIndices + public IReadOnlyList GlyphIndices { get => _glyphIndices; set => Set(ref _glyphIndices, value); @@ -123,7 +124,7 @@ namespace Avalonia.Media /// /// Gets or sets an array of values that represent the advances corresponding to the glyph indices. /// - public ReadOnlySlice GlyphAdvances + public IReadOnlyList? GlyphAdvances { get => _glyphAdvances; set => Set(ref _glyphAdvances, value); @@ -132,7 +133,7 @@ namespace Avalonia.Media /// /// Gets or sets an array of values representing the offsets of the glyphs in the . /// - public ReadOnlySlice GlyphOffsets + public IReadOnlyList? GlyphOffsets { get => _glyphOffsets; set => Set(ref _glyphOffsets, value); @@ -150,7 +151,7 @@ namespace Avalonia.Media /// /// Gets or sets a list of values representing a mapping from character index to glyph index. /// - public ReadOnlySlice GlyphClusters + public IReadOnlyList? GlyphClusters { get => _glyphClusters; set => Set(ref _glyphClusters, value); @@ -202,34 +203,73 @@ namespace Avalonia.Media /// 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; + } } /// @@ -243,50 +283,86 @@ namespace Avalonia.Media /// 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); } /// @@ -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); } /// @@ -341,7 +425,7 @@ namespace Avalonia.Media /// 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. /// /// The glyph index. + /// The current cluster. /// The glyph's width. - 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 = 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 /// 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(); } diff --git a/src/Avalonia.Visuals/Media/TextDecoration.cs b/src/Avalonia.Visuals/Media/TextDecoration.cs index 57936426f3..8eeb86c555 100644 --- a/src/Avalonia.Visuals/Media/TextDecoration.cs +++ b/src/Avalonia.Visuals/Media/TextDecoration.cs @@ -154,11 +154,12 @@ namespace Avalonia.Media /// Draws the at given origin. /// /// The drawing context. - /// The shaped characters that are decorated. - internal void Draw(DrawingContext drawingContext, ShapedTextCharacters shapedTextCharacters) + /// The decorated run. + /// The font metrics of the decorated run. + /// The default brush that is used to draw the decoration. + 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)); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs b/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs index dd91dc04bd..c3e63739b7 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs @@ -5,11 +5,13 @@ /// 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; } + /// /// Gets the recommended distance above the baseline. /// diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattedTextSource.cs b/src/Avalonia.Visuals/Media/TextFormatting/FormattedTextSource.cs similarity index 83% rename from tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattedTextSource.cs rename to src/Avalonia.Visuals/Media/TextFormatting/FormattedTextSource.cs index e3a2f6e766..98344141f1 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattedTextSource.cs +++ b/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 _text; private readonly TextRunProperties _defaultProperties; - private readonly IReadOnlyList> _textModifier; + private readonly IReadOnlyList>? _textModifier; public FormattedTextSource(ReadOnlySlice text, TextRunProperties defaultProperties, - IReadOnlyList> textModifier) + IReadOnlyList>? 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. /// private static ValueSpan CreateTextStyleRun(ReadOnlySlice text, - TextRunProperties defaultProperties, IReadOnlyList> textModifier) + TextRunProperties defaultProperties, IReadOnlyList>? 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); diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs index 0c6c722941..9d648af8fb 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs @@ -7,11 +7,12 @@ namespace Avalonia.Media.TextFormatting /// public sealed class ShapeableTextCharacters : TextRun { - public ShapeableTextCharacters(ReadOnlySlice text, TextRunProperties properties) + public ShapeableTextCharacters(ReadOnlySlice 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 Text { get; } public override TextRunProperties Properties { get; } + + public sbyte BidiLevel { get; } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedBuffer.cs new file mode 100644 index 0000000000..ee38cf39e0 --- /dev/null +++ b/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 + { + private static readonly IComparer s_clusterComparer = new CompareClusters(); + + public ShapedBuffer(ReadOnlySlice text, int length, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) + : this(text, new GlyphInfo[length], glyphTypeface, fontRenderingEmSize, bidiLevel) + { + + } + + internal ShapedBuffer(ReadOnlySlice text, ArraySlice glyphInfos, GlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) + { + Text = text; + GlyphInfos = glyphInfos; + GlyphTypeface = glyphTypeface; + FontRenderingEmSize = fontRenderingEmSize; + BidiLevel = bidiLevel; + } + + internal ArraySlice GlyphInfos { get; } + + public ReadOnlySlice 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 GlyphIndices => new GlyphIndexList(GlyphInfos); + + public IReadOnlyList GlyphClusters => new GlyphClusterList(GlyphInfos); + + public IReadOnlyList GlyphAdvances => new GlyphAdvanceList(GlyphInfos); + + public IReadOnlyList GlyphOffsets => new GlyphOffsetList(GlyphInfos); + + /// + /// Finds a glyph index for given character index. + /// + /// The character index. + /// + /// The glyph index. + /// + 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; + } + + /// + /// Splits the at specified length. + /// + /// The length. + /// The split result. + internal SplitResult Split(int length) + { + var glyphCount = FindGlyphIndex(Text.Start + length); + + if (Text.Length == length) + { + return new SplitResult(this, null); + } + + if (Text.Length == glyphCount) + { + return new SplitResult(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(first, second); + } + + int ICollection.Count => throw new NotImplementedException(); + + bool ICollection.IsReadOnly => true; + + public GlyphInfo this[int index] + { + get => GlyphInfos[index]; + set => GlyphInfos[index] = value; + } + + int IList.IndexOf(GlyphInfo item) + { + throw new NotImplementedException(); + } + + void IList.Insert(int index, GlyphInfo item) + { + throw new NotImplementedException(); + } + + void IList.RemoveAt(int index) + { + throw new NotImplementedException(); + } + + void ICollection.Add(GlyphInfo item) + { + throw new NotImplementedException(); + } + + void ICollection.Clear() + { + throw new NotImplementedException(); + } + + bool ICollection.Contains(GlyphInfo item) + { + throw new NotImplementedException(); + } + + void ICollection.CopyTo(GlyphInfo[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + bool ICollection.Remove(GlyphInfo item) + { + throw new NotImplementedException(); + } + public IEnumerator GetEnumerator() => GlyphInfos.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + private class CompareClusters : IComparer + { + private static readonly Comparer s_intClusterComparer = Comparer.Default; + + public int Compare(GlyphInfo x, GlyphInfo y) + { + return s_intClusterComparer.Compare(x.GlyphCluster, y.GlyphCluster); + } + } + + private readonly struct GlyphAdvanceList : IReadOnlyList + { + private readonly ArraySlice _glyphInfos; + + public GlyphAdvanceList(ArraySlice glyphInfos) + { + _glyphInfos = glyphInfos; + } + + public double this[int index] => _glyphInfos[index].GlyphAdvance; + + public int Count => _glyphInfos.Length; + + public IEnumerator GetEnumerator() => new ImmutableReadOnlyListStructEnumerator(this); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private readonly struct GlyphIndexList : IReadOnlyList + { + private readonly ArraySlice _glyphInfos; + + public GlyphIndexList(ArraySlice glyphInfos) + { + _glyphInfos = glyphInfos; + } + + public ushort this[int index] => _glyphInfos[index].GlyphIndex; + + public int Count => _glyphInfos.Length; + + public IEnumerator GetEnumerator() => new ImmutableReadOnlyListStructEnumerator(this); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private readonly struct GlyphClusterList : IReadOnlyList + { + private readonly ArraySlice _glyphInfos; + + public GlyphClusterList(ArraySlice glyphInfos) + { + _glyphInfos = glyphInfos; + } + + public int this[int index] => _glyphInfos[index].GlyphCluster; + + public int Count => _glyphInfos.Length; + + public IEnumerator GetEnumerator() => new ImmutableReadOnlyListStructEnumerator(this); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private readonly struct GlyphOffsetList : IReadOnlyList + { + private readonly ArraySlice _glyphInfos; + + public GlyphOffsetList(ArraySlice glyphInfos) + { + _glyphInfos = glyphInfos; + } + + public Vector this[int index] => _glyphInfos[index].GlyphOffset; + + public int Count => _glyphInfos.Length; + + public IEnumerator GetEnumerator() => new ImmutableReadOnlyListStructEnumerator(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; + } + + /// + /// Get the glyph index. + /// + public ushort GlyphIndex { get; } + + /// + /// Get the glyph cluster. + /// + public int GlyphCluster { get; } + + /// + /// Get the glyph advance. + /// + public double GlyphAdvance { get; } + + /// + /// Get the glyph offset. + /// + public Vector GlyphOffset { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs index 72bb7431d7..d5af819c39 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs +++ b/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 /// 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; } + /// public override ReadOnlySlice Text { get; } @@ -25,31 +35,29 @@ namespace Avalonia.Media.TextFormatting /// public override int TextSourceLength { get; } - /// + public FontMetrics FontMetrics { get; } + public override Size Size => GlyphRun.Size; - /// - /// Gets the font metrics. - /// - /// - /// The font metrics. - /// - public FontMetrics FontMetrics { get; } + public GlyphRun GlyphRun + { + get + { + if(_glyphRun is null) + { + _glyphRun = CreateGlyphRun(); + } - /// - /// Gets the glyph run. - /// - /// - /// The glyphs. - /// - public GlyphRun GlyphRun { get; } + return _glyphRun; + } + } /// 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; + } + /// - /// Splits the at specified length. + /// Measures the number of characters that fit into available width. /// - /// The length. - /// The split result. - public SplitTextCharactersResult Split(int length) + /// The available width. + /// The count of fitting characters. + /// + /// true if characters fit into the available width; otherwise, false. + /// + 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 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(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); - /// - /// Gets the first text run. - /// - /// - /// The first text run. - /// - public ShapedTextCharacters First { get; } - - /// - /// Gets the second text run. - /// - /// - /// The second text run. - /// - public ShapedTextCharacters? Second { get; } + return new SplitResult(first, second); + } + + internal GlyphRun CreateGlyphRun() + { + return new GlyphRun( + ShapedBuffer.GlyphTypeface, + ShapedBuffer.FontRenderingEmSize, + Text, + ShapedBuffer.GlyphIndices, + ShapedBuffer.GlyphAdvances, + ShapedBuffer.GlyphOffsets, + ShapedBuffer.GlyphClusters, + BidiLevel); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SplitResult.cs b/src/Avalonia.Visuals/Media/TextFormatting/SplitResult.cs new file mode 100644 index 0000000000..02c7174499 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/SplitResult.cs @@ -0,0 +1,28 @@ +namespace Avalonia.Media.TextFormatting +{ + internal readonly struct SplitResult + { + public SplitResult(T first, T? second) + { + First = first; + + Second = second; + } + + /// + /// Gets the first part. + /// + /// + /// The first part. + /// + public T First { get; } + + /// + /// Gets the second part. + /// + /// + /// The second part. + /// + public T? Second { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs index cfca8f9ab2..9116b58695 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs @@ -37,15 +37,13 @@ namespace Avalonia.Media.TextFormatting /// Gets a list of . /// /// The shapeable text characters. - internal IList GetShapeableCharacters() + internal IList GetShapeableCharacters(ReadOnlySlice runText, sbyte biDiLevel) { var shapeableCharacters = new List(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 /// /// The text to create text runs from. /// The default text run properties. + /// The bidi level of the run. /// A list of shapeable text runs. - private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text, TextRunProperties defaultProperties) + private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice 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); } /// @@ -120,22 +132,21 @@ namespace Avalonia.Media.TextFormatting /// /// /// The typeface that is used to find matching characters. - /// + /// /// - protected bool TryGetRunProperties(ReadOnlySlice text, Typeface typeface, Typeface defaultTypeface, - out int count) + protected static bool TryGetShapeableLength(ReadOnlySlice 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; } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs index 682fd930f6..4d342d4d58 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs @@ -5,5 +5,14 @@ /// public class TextEndOfParagraph : TextEndOfLine { + public TextEndOfParagraph() + { + + } + + public TextEndOfParagraph(int textSourceLength) : base(textSourceLength) + { + + } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs index c97e36d5ff..9a41d01b56 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs +++ b/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 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; - } - - /// - /// Measures the number of characters that fit into available width. - /// - /// The text run. - /// The available width. - /// The count of fitting characters. - /// - /// true if characters fit into the available width; otherwise, false. - /// - 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; } /// @@ -185,7 +71,7 @@ namespace Avalonia.Media.TextFormatting /// The text run's. /// The length to split at. /// The split text runs. - internal static SplitTextRunsResult SplitTextRuns(List textRuns, int length) + internal static SplitResult> SplitShapedRuns(List 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(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(secondCount); - - var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0; + var second = secondCount > 0 ? new List(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>(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>(first, second); } } - return new SplitTextRunsResult(textRuns, null); + return new SplitResult>(textRuns, null); } /// - /// Fetches text runs. + /// Shape specified text runs with specified paragraph embedding. /// - /// The text source. - /// The first text source index. - /// Previous line break. Can be null. - /// Next line break. Can be null. + /// The text runs to shape. + /// The paragraph embedding level. + /// The resolved flow direction. /// - /// The formatted text runs. + /// A list of shaped text characters. /// - private static List FetchTextRuns(ITextSource textSource, - int firstTextSourceIndex, TextLineBreak? previousLineBreak, out TextLineBreak? nextLineBreak) + private static List ShapeTextRuns(List textRuns, + FlowDirection flowDirection, out FlowDirection resolvedFlowDirection) { - nextLineBreak = default; + var shapedTextCharacters = new List(); - var currentLength = 0; + var biDiData = new BidiData((sbyte)flowDirection); - var textRuns = new List(); + 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; + } + + /// + /// Coalesces ranges of the same bidi level to form + /// + /// The text characters to form from. + /// The bidi levels. + /// + private static IEnumerable> CoalesceLevels( + IReadOnlyList textCharacters, + ReadOnlySlice levels) + { + if (levels.Length == 0) + { + yield break; + } + + var levelIndex = 0; + var runLevel = levels[0]; - textRuns.Add(shapedCharacters); + TextCharacters? currentRun = null; + var runText = ReadOnlySlice.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); + } + + /// + /// Fetches text runs. + /// + /// The text source. + /// The first text source index. + /// + /// + /// + /// The formatted text runs. + /// + private static List FetchTextRuns(ITextSource textSource, int firstTextSourceIndex, + out TextEndOfLine? endOfLine, out TextRange textRange) + { + var length = 0; + + endOfLine = null; + + var textRuns = new List(); 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; } - /// - /// Performs text wrapping returns a list of text lines. - /// - /// The text run's. - /// The text range that is covered by the text runs. - /// The paragraph width. - /// The text paragraph properties. - /// The current line break if the line was explicitly broken. - /// The wrapped text line. - private static TextLine PerformTextWrapping(List textRuns, TextRange textRange, - double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak? currentLineBreak) + private static int MeasureLength(IReadOnlyList 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; - } + /// + /// Performs text wrapping returns a list of text lines. + /// + /// + /// The text range that is covered by the text runs. + /// The paragraph width. + /// The text paragraph properties. + /// + /// The current line break if the line was explicitly broken. + /// The wrapped text line. + private static TextLineImpl PerformTextWrapping(List 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); - } - - /// - /// Gets the text range that is covered by the text runs. - /// - /// The text runs. - /// The text range that is covered by the text runs. - private static TextRange GetTextRange(IReadOnlyList textRuns) - { - if (textRuns.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 first, List? second) + if (lineBreak is null && currentLineBreak?.TextEndOfLine != null) { - First = first; - - Second = second; + lineBreak = new TextLineBreak(currentLineBreak.TextEndOfLine, flowDirection); } - /// - /// Gets the first text runs. - /// - /// - /// The first text runs. - /// - public List First { get; } - - /// - /// Gets the second text runs. - /// - /// - /// The second text runs. - /// - public List? 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; } } + + /// + /// Creates a shaped symbol. + /// + /// The symbol run to shape. + /// The flow direction. + /// + /// The shaped symbol. + /// + 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); + } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 0ed06e4e57..646204021b 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/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 /// The text wrapping. /// The text trimming. /// The text decorations. + /// The text flow direction. /// The maximum width. /// The maximum height. /// The height of each line of text. @@ -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(); } /// @@ -190,29 +191,149 @@ namespace Avalonia.Media.TextFormatting } var result = new List(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); } /// @@ -286,15 +443,16 @@ namespace Avalonia.Media.TextFormatting /// The text alignment. /// The text wrapping. /// The text decorations. + /// The text flow direction. /// The height of each line of text. /// 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 /// The current height. 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 /// The empty text line. private TextLine CreateEmptyTextLine(int startingIndex) { + var flowDirection = _paragraphProperties.FlowDirection; var properties = _paragraphProperties.DefaultTextRunProperties; + var glyphTypeface = properties.Typeface.GlyphTypeface; + var text = new ReadOnlySlice(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(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 - { - new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) - }; + var textRuns = new List { 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(); } - /// - /// Updates the layout and applies specified text style overrides. - /// - [MemberNotNull(nameof(TextLines))] - private void UpdateLayout() + private IReadOnlyList CreateTextLines() { if (_text.IsEmpty || MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight)) { var textLine = CreateEmptyTextLine(0); - TextLines = new List { textLine }; - Size = new Size(0, textLine.Height); + + return new List { textLine }; } - else - { - var textLines = new List(); - double width = 0.0, height = 0.0; + var textLines = new List(); - 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; } /// @@ -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 _text; - private readonly TextRunProperties _defaultProperties; - private readonly IReadOnlyList>? _textModifier; - - public FormattedTextSource(ReadOnlySlice text, TextRunProperties defaultProperties, - IReadOnlyList>? 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); - } - - /// - /// Creates a span of text run properties that has modifier applied. - /// - /// The text to create the properties for. - /// The default text properties. - /// The text properties modifier. - /// - /// The created text style run. - /// - private static ValueSpan CreateTextStyleRun(ReadOnlySlice text, - TextRunProperties defaultProperties, IReadOnlyList>? textModifier) - { - if (textModifier == null || textModifier.Count == 0) - { - return new ValueSpan(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(text.Start, length, currentProperties); - } - } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs index aea4227002..9bbc4a8a9d 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs @@ -191,27 +191,45 @@ namespace Avalonia.Media.TextFormatting /// /// Gets the text line offset x. /// - /// The line width. + /// The line width. + /// The paragraph width including whitespace. /// The paragraph width. /// The text alignment. + /// The flow direction of the line. /// The paragraph offset. - 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; } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs index d2bd58682a..be9661c2bf 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs +++ b/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? remainingCharacters = null) { TextEndOfLine = textEndOfLine; - } - - public TextLineBreak(IReadOnlyList remainingCharacters) - { + FlowDirection = flowDirection; RemainingCharacters = remainingCharacters; } - + /// - /// Get the + /// Get the end of line run. /// public TextEndOfLine? TextEndOfLine { get; } + /// + /// Get the flow direction for remaining characters. + /// + public FlowDirection FlowDirection { get; } + /// /// Get the remaining shaped characters that were split up by the during the formatting process. /// diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs index b1397518e4..66666457f7 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs +++ b/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 _shapedTextRuns; + private static readonly Comparer s_compareStart = Comparer.Default; + + private static readonly Comparison s_compareLogicalOrder = + (a, b) => s_compareStart.Compare(a.Text.Start, b.Text.Start); + + private readonly List _textRuns; private readonly double _paragraphWidth; private readonly TextParagraphProperties _paragraphProperties; - private readonly TextLineMetrics _textLineMetrics; + private TextLineMetrics _textLineMetrics; + private readonly FlowDirection _flowDirection; public TextLineImpl(List 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; } /// - public override IReadOnlyList TextRuns => _shapedTextRuns; + public override IReadOnlyList TextRuns => _textRuns; /// 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 /// 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(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); - } - /// 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; } /// @@ -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 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; + } + } + + /// + /// Reorders a series of runs from logical to visual order, returning the left most run. + /// + /// + /// The ordered bidi run. + /// The . + 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!; + } + /// /// Tries to find the next character hit. /// @@ -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 /// 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. /// /// The codepoint index. + /// The logical direction. /// The text run index. - 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); } - /// - /// Creates a shaped symbol. - /// - /// The symbol run to shape. - /// - /// The shaped symbol. - /// - 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; + } } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs index bbff09ad79..b799567a60 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs @@ -7,7 +7,7 @@ { /// /// 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. /// public abstract FlowDirection FlowDirection { get; } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs index 4bfbb89006..26c3f8947a 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs +++ b/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); } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs index 2892e608ab..c982a435c3 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs @@ -45,10 +45,10 @@ namespace Avalonia.Media.TextFormatting } /// - public GlyphRun ShapeText(ReadOnlySlice text, Typeface typeface, double fontRenderingEmSize, - CultureInfo? culture) + public ShapedBuffer ShapeText(ReadOnlySlice text, GlyphTypeface typeface, double fontRenderingEmSize, + CultureInfo? culture, sbyte bidiLevel) { - return _platformImpl.ShapeText(text, typeface, fontRenderingEmSize, culture); + return _platformImpl.ShapeText(text, typeface, fontRenderingEmSize, culture, bidiLevel); } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiAlgorithm.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiAlgorithm.cs new file mode 100644 index 0000000000..404956d1e1 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiAlgorithm.cs @@ -0,0 +1,1717 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. +// Ported from: https://github.com/SixLabors/Fonts/ + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + /// + /// Implementation of Unicode bidirectional algorithm (UAX #9) + /// https://unicode.org/reports/tr9/ + /// + /// + /// + /// The Bidi algorithm uses a number of memory arrays for resolved + /// types, level information, bracket types, x9 removal maps and + /// more... + /// + /// + /// This implementation of the BiDi algorithm has been designed + /// to reduce memory pressure on the GC by re-using the same + /// work buffers, so instances of this class should be re-used + /// as much as possible. + /// + /// + internal sealed class BidiAlgorithm + { + /// + /// The original BiDiClass classes as provided by the caller + /// + private ArraySlice _originalClasses; + + /// + /// Paired bracket types as provided by caller + /// + private ArraySlice _pairedBracketTypes; + + /// + /// Paired bracket values as provided by caller + /// + private ArraySlice _pairedBracketValues; + + /// + /// Try if the incoming data is known to contain brackets + /// + private bool _hasBrackets; + + /// + /// True if the incoming data is known to contain embedding runs + /// + private bool _hasEmbeddings; + + /// + /// True if the incoming data is known to contain isolating runs + /// + private bool _hasIsolates; + + /// + /// Two directional mapping of isolate start/end pairs + /// + /// + /// The forward mapping maps the start index to the end index. + /// The reverse mapping maps the end index to the start index. + /// + private readonly Dictionary _isolatePairs = new Dictionary(); + + /// + /// The working BiDi classes + /// + private ArraySlice _workingClasses; + + /// + /// The working classes buffer + /// + private ArrayBuilder _workingClassesBuffer; + + /// + /// A slice of the resolved levels + /// + private ArraySlice _resolvedLevels; + + /// + /// The buffer underlying resolvedLevels + /// + private ArrayBuilder _resolvedLevelsBuffer; + + /// + /// The resolve paragraph embedding level + /// + private sbyte _paragraphEmbeddingLevel; + + /// + /// The status stack used during resolution of explicit + /// embedding and isolating runs + /// + private readonly Stack _statusStack = new Stack(); + + /// + /// Mapping used to virtually remove characters for rule X9 + /// + private ArrayBuilder _x9Map; + + /// + /// Re-usable list of level runs + /// + private readonly List _levelRuns = new List(); + + /// + /// Mapping for the current isolating sequence, built + /// by joining level runs from the x9 map. + /// + private ArrayBuilder _isolatedRunMapping; + + /// + /// A stack of pending isolate openings used by FindIsolatePairs() + /// + private readonly Stack _pendingIsolateOpenings = new Stack(); + + /// + /// The level of the isolating run currently being processed + /// + private int _runLevel; + + /// + /// The direction of the isolating run currently being processed + /// + private BidiClass _runDirection; + + /// + /// The length of the isolating run currently being processed + /// + private int _runLength; + + /// + /// A mapped slice of the resolved types for the isolating run currently + /// being processed + /// + private MappedArraySlice _runResolvedClasses; + + /// + /// A mapped slice of the original types for the isolating run currently + /// being processed + /// + private MappedArraySlice _runOriginalClasses; + + /// + /// A mapped slice of the run levels for the isolating run currently + /// being processed + /// + private MappedArraySlice _runLevels; + + /// + /// A mapped slice of the paired bracket types of the isolating + /// run currently being processed + /// + private MappedArraySlice _runBiDiPairedBracketTypes; + + /// + /// A mapped slice of the paired bracket values of the isolating + /// run currently being processed + /// + private MappedArraySlice _runPairedBracketValues; + + /// + /// Maximum pairing depth for paired brackets + /// + private const int MaxPairedBracketDepth = 63; + + /// + /// Reusable list of pending opening brackets used by the + /// LocatePairedBrackets method + /// + private readonly List _pendingOpeningBrackets = new List(); + + /// + /// Resolved list of paired brackets + /// + private readonly List _pairedBrackets = new List(); + + /// + /// Initializes a new instance of the class. + /// + internal BidiAlgorithm() + { + } + + /// + /// Gets a per-thread instance that can be re-used as often + /// as necessary. + /// + public static ThreadLocal Instance { get; } = new ThreadLocal(() => new BidiAlgorithm()); + + /// + /// Gets the resolved levels. + /// + public ArraySlice ResolvedLevels => _resolvedLevels; + + /// + /// Gets the resolved paragraph embedding level + /// + public int ResolvedParagraphEmbeddingLevel => _paragraphEmbeddingLevel; + + /// + /// Process data from a BiDiData instance + /// + /// The BiDi Unicode data. + public void Process(BidiData data) + => Process( + data.Classes, + data.PairedBracketTypes, + data.PairedBracketValues, + data.ParagraphEmbeddingLevel, + data.HasBrackets, + data.HasEmbeddings, + data.HasIsolates, + null); + + /// + /// Processes Bidi Data + /// + public void Process( + ArraySlice types, + ArraySlice pairedBracketTypes, + ArraySlice pairedBracketValues, + sbyte paragraphEmbeddingLevel, + bool? hasBrackets, + bool? hasEmbeddings, + bool? hasIsolates, + ArraySlice? outLevels) + { + // Reset state + _isolatePairs.Clear(); + _workingClassesBuffer.Clear(); + _levelRuns.Clear(); + _resolvedLevelsBuffer.Clear(); + + // Setup original types and working types + _originalClasses = types; + _workingClasses = _workingClassesBuffer.Add(types); + + // Capture paired bracket values and types + _pairedBracketTypes = pairedBracketTypes; + _pairedBracketValues = pairedBracketValues; + + // Store things we know + _hasBrackets = hasBrackets ?? _pairedBracketTypes.Length == _originalClasses.Length; + _hasEmbeddings = hasEmbeddings ?? true; + _hasIsolates = hasIsolates ?? true; + + // Find all isolate pairs + FindIsolatePairs(); + + // Resolve the paragraph embedding level + if (paragraphEmbeddingLevel == 2) + { + _paragraphEmbeddingLevel = ResolveEmbeddingLevel(_originalClasses); + } + else + { + _paragraphEmbeddingLevel = paragraphEmbeddingLevel; + } + + // Create resolved levels buffer + if (outLevels.HasValue) + { + if (outLevels.Value.Length != _originalClasses.Length) + { + throw new ArgumentException("Out levels must be the same length as the input data"); + } + + _resolvedLevels = outLevels.Value; + } + else + { + _resolvedLevels = _resolvedLevelsBuffer.Add(_originalClasses.Length); + _resolvedLevels.Fill(_paragraphEmbeddingLevel); + } + + // Resolve explicit embedding levels (Rules X1-X8) + ResolveExplicitEmbeddingLevels(); + + // Build the rule X9 map + BuildX9RemovalMap(); + + // Process all isolated run sequences + ProcessIsolatedRunSequences(); + + // Reset whitespace levels + ResetWhitespaceLevels(); + + // Clean up + AssignLevelsToCodePointsRemovedByX9(); + } + + /// + /// Resolve the paragraph embedding level if not explicitly passed + /// by the caller. Also used by rule X5c for FSI isolating sequences. + /// + /// The data to be evaluated + /// The resolved embedding level + public sbyte ResolveEmbeddingLevel(ReadOnlySlice data) + { + // P2 + for (var i = 0; i < data.Length; ++i) + { + switch (data[i]) + { + case BidiClass.LeftToRight: + // P3 + return 0; + + case BidiClass.ArabicLetter: + case BidiClass.RightToLeft: + // P3 + return 1; + + case BidiClass.FirstStrongIsolate: + case BidiClass.LeftToRightIsolate: + case BidiClass.RightToLeftIsolate: + // Skip isolate pairs + // (Because we're working with a slice, we need to adjust the indices + // we're using for the isolatePairs map) + if (_isolatePairs.TryGetValue(data.Start + i, out i)) + { + i -= data.Start; + } + else + { + i = data.Length; + } + + break; + } + } + + // P3 + return 0; + } + + /// + /// Build a list of matching isolates for a directionality slice + /// Implements BD9 + /// + private void FindIsolatePairs() + { + // Redundant? + if (!_hasIsolates) + { + return; + } + + // Lets double check this as we go and clear the flag + // if there actually aren't any isolate pairs as this might + // mean we can skip some later steps + _hasIsolates = false; + + // BD9... + _pendingIsolateOpenings.Clear(); + + for (var i = 0; i < _originalClasses.Length; i++) + { + var t = _originalClasses[i]; + + switch (t) + { + case BidiClass.LeftToRightIsolate: + case BidiClass.RightToLeftIsolate: + case BidiClass.FirstStrongIsolate: + { + _pendingIsolateOpenings.Push(i); + _hasIsolates = true; + break; + } + case BidiClass.PopDirectionalIsolate: + { + if (_pendingIsolateOpenings.Count > 0) + { + _isolatePairs.Add(_pendingIsolateOpenings.Pop(), i); + } + + _hasIsolates = true; + + break; + } + } + } + } + + /// + /// Resolve the explicit embedding levels from the original + /// data. Implements rules X1 to X8. + /// + private void ResolveExplicitEmbeddingLevels() + { + // Redundant? + if (!_hasIsolates && !_hasEmbeddings) + { + return; + } + + // Work variables + _statusStack.Clear(); + var overflowIsolateCount = 0; + var overflowEmbeddingCount = 0; + var validIsolateCount = 0; + + // Constants + const int maxStackDepth = 125; + + // Rule X1 - setup initial state + _statusStack.Clear(); + + // Neutral + _statusStack.Push(new Status(_paragraphEmbeddingLevel, BidiClass.OtherNeutral, false)); + + // Process all characters + for (var i = 0; i < _originalClasses.Length; i++) + { + switch (_originalClasses[i]) + { + case BidiClass.RightToLeftEmbedding: + { + // Rule X2 + var newLevel = (sbyte)((_statusStack.Peek().EmbeddingLevel + 1) | 1); + if (newLevel <= maxStackDepth && overflowIsolateCount == 0 && overflowEmbeddingCount == 0) + { + _statusStack.Push(new Status(newLevel, BidiClass.OtherNeutral, false)); + _resolvedLevels[i] = newLevel; + } + else if (overflowIsolateCount == 0) + { + overflowEmbeddingCount++; + } + + break; + } + + case BidiClass.LeftToRightEmbedding: + { + // Rule X3 + var newLevel = (sbyte)((_statusStack.Peek().EmbeddingLevel + 2) & ~1); + if (newLevel < maxStackDepth && overflowIsolateCount == 0 && overflowEmbeddingCount == 0) + { + _statusStack.Push(new Status(newLevel, BidiClass.OtherNeutral, false)); + _resolvedLevels[i] = newLevel; + } + else if (overflowIsolateCount == 0) + { + overflowEmbeddingCount++; + } + + break; + } + + case BidiClass.RightToLeftOverride: + { + // Rule X4 + var newLevel = (sbyte)((_statusStack.Peek().EmbeddingLevel + 1) | 1); + if (newLevel <= maxStackDepth && overflowIsolateCount == 0 && overflowEmbeddingCount == 0) + { + _statusStack.Push(new Status(newLevel, BidiClass.RightToLeft, false)); + _resolvedLevels[i] = newLevel; + } + else if (overflowIsolateCount == 0) + { + overflowEmbeddingCount++; + } + + break; + } + + case BidiClass.LeftToRightOverride: + { + // Rule X5 + var newLevel = (sbyte)((_statusStack.Peek().EmbeddingLevel + 2) & ~1); + if (newLevel <= maxStackDepth && overflowIsolateCount == 0 && overflowEmbeddingCount == 0) + { + _statusStack.Push(new Status(newLevel, BidiClass.LeftToRight, false)); + _resolvedLevels[i] = newLevel; + } + else if (overflowIsolateCount == 0) + { + overflowEmbeddingCount++; + } + + break; + } + + case BidiClass.RightToLeftIsolate: + case BidiClass.LeftToRightIsolate: + case BidiClass.FirstStrongIsolate: + { + // Rule X5a, X5b and X5c + var resolvedIsolate = _originalClasses[i]; + + if (resolvedIsolate == BidiClass.FirstStrongIsolate) + { + if (!_isolatePairs.TryGetValue(i, out var endOfIsolate)) + { + endOfIsolate = _originalClasses.Length; + } + + // Rule X5c + if (ResolveEmbeddingLevel(_originalClasses.Slice(i + 1,endOfIsolate - (i + 1))) == 1) + { + resolvedIsolate = BidiClass.RightToLeftIsolate; + } + else + { + resolvedIsolate = BidiClass.LeftToRightIsolate; + } + } + + // Replace RLI's level with current embedding level + var tos = _statusStack.Peek(); + _resolvedLevels[i] = tos.EmbeddingLevel; + + // Apply override + if (tos.OverrideStatus != BidiClass.OtherNeutral) + { + _workingClasses[i] = tos.OverrideStatus; + } + + // Work out new level + sbyte newLevel; + if (resolvedIsolate == BidiClass.RightToLeftIsolate) + { + newLevel = (sbyte)((tos.EmbeddingLevel + 1) | 1); + } + else + { + newLevel = (sbyte)((tos.EmbeddingLevel + 2) & ~1); + } + + // Valid? + if (newLevel <= maxStackDepth && overflowIsolateCount == 0 && overflowEmbeddingCount == 0) + { + validIsolateCount++; + _statusStack.Push(new Status(newLevel, BidiClass.OtherNeutral, true)); + } + else + { + overflowIsolateCount++; + } + + break; + } + + case BidiClass.BoundaryNeutral: + { + // Mentioned in rule X6 - "for all types besides ..., BN, ..." + // no-op + break; + } + + default: + { + // Rule X6 + var tos = _statusStack.Peek(); + _resolvedLevels[i] = tos.EmbeddingLevel; + if (tos.OverrideStatus != BidiClass.OtherNeutral) + { + _workingClasses[i] = tos.OverrideStatus; + } + + break; + } + + case BidiClass.PopDirectionalIsolate: + { + // Rule X6a + if (overflowIsolateCount > 0) + { + overflowIsolateCount--; + } + else if (validIsolateCount != 0) + { + overflowEmbeddingCount = 0; + while (!_statusStack.Peek().IsolateStatus) + { + _statusStack.Pop(); + } + + _statusStack.Pop(); + validIsolateCount--; + } + + var tos = _statusStack.Peek(); + _resolvedLevels[i] = tos.EmbeddingLevel; + if (tos.OverrideStatus != BidiClass.OtherNeutral) + { + _workingClasses[i] = tos.OverrideStatus; + } + + break; + } + + case BidiClass.PopDirectionalFormat: + { + // Rule X7 + if (overflowIsolateCount == 0) + { + if (overflowEmbeddingCount > 0) + { + overflowEmbeddingCount--; + } + else if (!_statusStack.Peek().IsolateStatus && _statusStack.Count >= 2) + { + _statusStack.Pop(); + } + } + + break; + } + + case BidiClass.ParagraphSeparator: + { + // Rule X8 + _resolvedLevels[i] = _paragraphEmbeddingLevel; + break; + } + } + } + } + + /// + /// Build a map to the original data positions that excludes all + /// the types defined by rule X9 + /// + private void BuildX9RemovalMap() + { + // Reserve room for the x9 map + _x9Map.Length = _originalClasses.Length; + + if (_hasEmbeddings || _hasIsolates) + { + // Build a map the removes all x9 characters + var j = 0; + for (var i = 0; i < _originalClasses.Length; i++) + { + if (!IsRemovedByX9(_originalClasses[i])) + { + _x9Map[j++] = i; + } + } + + // Set the final length + _x9Map.Length = j; + } + else + { + for (int i = 0, count = _originalClasses.Length; i < count; i++) + { + _x9Map[i] = i; + } + } + } + + /// + /// Find the original character index for an entry in the X9 map + /// + /// Index in the x9 removal map + /// Index to the original data + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int MapX9(int index) => _x9Map[index]; + + /// + /// Add a new level run + /// + /// + /// This method resolves the sos and eos values for the run + /// and adds the run to the list + /// /// + /// The index of the start of the run (in x9 removed units) + /// The length of the run (in x9 removed units) + /// The level of the run + private void AddLevelRun(int start, int length, int level) + { + // Get original indices to first and last character in this run + var firstCharIndex = MapX9(start); + var lastCharIndex = MapX9(start + length - 1); + + // Work out sos + var i = firstCharIndex - 1; + + while (i >= 0 && IsRemovedByX9(_originalClasses[i])) + { + i--; + } + + var prevLevel = i < 0 ? _paragraphEmbeddingLevel : _resolvedLevels[i]; + var sos = DirectionFromLevel(Math.Max(prevLevel, level)); + + // Work out eos + var lastType = _workingClasses[lastCharIndex]; + int nextLevel; + + switch (lastType) + { + case BidiClass.LeftToRightIsolate: + case BidiClass.RightToLeftIsolate: + case BidiClass.FirstStrongIsolate: + { + nextLevel = _paragraphEmbeddingLevel; + + break; + } + default: + { + i = lastCharIndex + 1; + while (i < _originalClasses.Length && IsRemovedByX9(_originalClasses[i])) + { + i++; + } + + nextLevel = i >= _originalClasses.Length ? _paragraphEmbeddingLevel : _resolvedLevels[i]; + + break; + } + } + + var eos = DirectionFromLevel(Math.Max(nextLevel, level)); + + // Add the run + _levelRuns.Add(new LevelRun(start, length, level, sos, eos)); + } + + /// + /// Find all runs of the same level, populating the _levelRuns + /// collection + /// + private void FindLevelRuns() + { + var currentLevel = -1; + var runStart = 0; + + for (var i = 0; i < _x9Map.Length; ++i) + { + int level = _resolvedLevels[MapX9(i)]; + + if (level == currentLevel) + { + continue; + } + + if (currentLevel != -1) + { + AddLevelRun(runStart, i - runStart, currentLevel); + } + + currentLevel = level; + runStart = i; + } + + // Don't forget the final level run + if (currentLevel != -1) + { + AddLevelRun(runStart, _x9Map.Length - runStart, currentLevel); + } + } + + /// + /// Given a character index, find the level run that starts at that position + /// + /// The index into the original (unmapped) data + /// The index of the run that starts at that index + private int FindRunForIndex(int index) + { + for (var i = 0; i < _levelRuns.Count; i++) + { + // Passed index is for the original non-x9 filtered data, however + // the level run ranges are for the x9 filtered data. Convert before + // comparing + if (MapX9(_levelRuns[i].Start) == index) + { + return i; + } + } + + throw new InvalidOperationException("Internal error"); + } + + /// + /// Determine and the process all isolated run sequences + /// + private void ProcessIsolatedRunSequences() + { + // Find all runs with the same level + FindLevelRuns(); + + // Process them one at a time by first building + // a mapping using slices from the x9 map for each + // run section that needs to be joined together to + // form an complete run. That full run mapping + // will be placed in _isolatedRunMapping and then + // processed by ProcessIsolatedRunSequence(). + while (_levelRuns.Count > 0) + { + // Clear the mapping + _isolatedRunMapping.Clear(); + + // Combine mappings from this run and all runs that continue on from it + var runIndex = 0; + BidiClass eos; + var sos = _levelRuns[0].Sos; + var level = _levelRuns[0].Level; + + while (true) + { + // Get the run + var r = _levelRuns[runIndex]; + + // The eos of the isolating run is the eos of the + // last level run that comprises it. + eos = r.Eos; + + // Remove this run as we've now processed it + _levelRuns.RemoveAt(runIndex); + + // Add the x9 map indices for the run range to the mapping + // for this isolated run + _isolatedRunMapping.Add(_x9Map.AsSlice(r.Start, r.Length)); + + // Get the last character and see if it's an isolating run with a matching + // PDI and concatenate that run to this one + var lastCharacterIndex = _isolatedRunMapping[_isolatedRunMapping.Length - 1]; + var lastType = _originalClasses[lastCharacterIndex]; + if ((lastType == BidiClass.LeftToRightIsolate || lastType == BidiClass.RightToLeftIsolate || lastType == BidiClass.FirstStrongIsolate) && + _isolatePairs.TryGetValue(lastCharacterIndex, out var nextRunIndex)) + { + // Find the continuing run index + runIndex = FindRunForIndex(nextRunIndex); + } + else + { + break; + } + } + + // Process this isolated run + ProcessIsolatedRunSequence(sos, eos, level); + } + } + + /// + /// Process a single isolated run sequence, where the character sequence + /// mapping is currently held in _isolatedRunMapping. + /// + private void ProcessIsolatedRunSequence(BidiClass sos, BidiClass eos, int runLevel) + { + // Create mappings onto the underlying data + _runResolvedClasses = new MappedArraySlice(_workingClasses, _isolatedRunMapping.AsSlice()); + _runOriginalClasses = new MappedArraySlice(_originalClasses, _isolatedRunMapping.AsSlice()); + _runLevels = new MappedArraySlice(_resolvedLevels, _isolatedRunMapping.AsSlice()); + if (_hasBrackets) + { + _runBiDiPairedBracketTypes = new MappedArraySlice(_pairedBracketTypes, _isolatedRunMapping.AsSlice()); + _runPairedBracketValues = new MappedArraySlice(_pairedBracketValues, _isolatedRunMapping.AsSlice()); + } + + _runLevel = runLevel; + _runDirection = DirectionFromLevel(runLevel); + _runLength = _runResolvedClasses.Length; + + // By tracking the types of characters known to be in the current run, we can + // skip some of the rules that we know won't apply. The flags will be + // initialized while we're processing rule W1 below. + var hasEN = false; + var hasAL = false; + var hasES = false; + var hasCS = false; + var hasAN = false; + var hasET = false; + + // Rule W1 + // Also, set hasXX flags + int i; + var previousClass = sos; + + for (i = 0; i < _runLength; i++) + { + var resolvedClass = _runResolvedClasses[i]; + + switch (resolvedClass) + { + case BidiClass.NonspacingMark: + _runResolvedClasses[i] = previousClass; + break; + + case BidiClass.LeftToRightIsolate: + case BidiClass.RightToLeftIsolate: + case BidiClass.FirstStrongIsolate: + case BidiClass.PopDirectionalIsolate: + previousClass = BidiClass.OtherNeutral; + break; + + case BidiClass.EuropeanNumber: + hasEN = true; + previousClass = resolvedClass; + break; + + case BidiClass.ArabicLetter: + hasAL = true; + previousClass = resolvedClass; + break; + + case BidiClass.EuropeanSeparator: + hasES = true; + previousClass = resolvedClass; + break; + + case BidiClass.CommonSeparator: + hasCS = true; + previousClass = resolvedClass; + break; + + case BidiClass.ArabicNumber: + hasAN = true; + previousClass = resolvedClass; + break; + + case BidiClass.EuropeanTerminator: + hasET = true; + previousClass = resolvedClass; + break; + + default: + previousClass = resolvedClass; + break; + } + } + + // Rule W2 + if (hasEN) + { + for (i = 0; i < _runLength; i++) + { + if (_runResolvedClasses[i] != BidiClass.EuropeanNumber) + { + continue; + } + + for (var j = i - 1; j >= 0; j--) + { + var resolvedClass = _runResolvedClasses[j]; + + switch (resolvedClass) + { + case BidiClass.LeftToRight: + case BidiClass.RightToLeft: + case BidiClass.ArabicLetter: + { + if (resolvedClass == BidiClass.ArabicLetter) + { + _runResolvedClasses[i] = BidiClass.ArabicNumber; + hasAN = true; + } + + j = -1; + + break; + } + } + } + } + } + + // Rule W3 + if (hasAL) + { + for (i = 0; i < _runLength; i++) + { + if (_runResolvedClasses[i] == BidiClass.ArabicLetter) + { + _runResolvedClasses[i] = BidiClass.RightToLeft; + } + } + } + + // Rule W4 + if ((hasES || hasCS) && (hasEN || hasAN)) + { + for (i = 1; i < _runLength - 1; ++i) + { + ref var resolvedClass = ref _runResolvedClasses[i]; + + if (resolvedClass == BidiClass.EuropeanSeparator) + { + var previousSeparatorClass = _runResolvedClasses[i - 1]; + var nextSeparatorClass = _runResolvedClasses[i + 1]; + + if (previousSeparatorClass == BidiClass.EuropeanNumber && nextSeparatorClass == BidiClass.EuropeanNumber) + { + // ES between EN and EN + resolvedClass = BidiClass.EuropeanNumber; + } + } + else if (resolvedClass == BidiClass.CommonSeparator) + { + var previousSeparatorClass = _runResolvedClasses[i - 1]; + var nextSeparatorClass = _runResolvedClasses[i + 1]; + + if ((previousSeparatorClass == BidiClass.ArabicNumber && nextSeparatorClass == BidiClass.ArabicNumber) || + (previousSeparatorClass == BidiClass.EuropeanNumber && nextSeparatorClass == BidiClass.EuropeanNumber)) + { + // CS between (AN and AN) or (EN and EN) + resolvedClass = previousSeparatorClass; + } + } + } + } + + // Rule W5 + if (hasET && hasEN) + { + for (i = 0; i < _runLength; ++i) + { + if (_runResolvedClasses[i] != BidiClass.EuropeanTerminator) + { + continue; + } + + // Locate end of sequence + var sequenceStart = i; + var sequenceEnd = i; + + while (sequenceEnd < _runLength && _runResolvedClasses[sequenceEnd] == BidiClass.EuropeanTerminator) + { + sequenceEnd++; + } + + // Preceded by, or followed by EN? + if ((sequenceStart == 0 ? sos : _runResolvedClasses[sequenceStart - 1]) == BidiClass.EuropeanNumber + || (sequenceEnd == _runLength ? eos : _runResolvedClasses[sequenceEnd]) == BidiClass.EuropeanNumber) + { + // Change the entire range + for (var j = sequenceStart; i < sequenceEnd; ++i) + { + _runResolvedClasses[i] = BidiClass.EuropeanNumber; + } + } + + // continue at end of sequence + i = sequenceEnd; + } + } + + // Rule W6 + if (hasES || hasET || hasCS) + { + for (i = 0; i < _runLength; ++i) + { + ref var resolvedClass = ref _runResolvedClasses[i]; + + switch (resolvedClass) + { + case BidiClass.EuropeanSeparator: + case BidiClass.EuropeanTerminator: + case BidiClass.CommonSeparator: + { + resolvedClass = BidiClass.OtherNeutral; + + break; + } + } + } + } + + // Rule W7. + if (hasEN) + { + var previousStrongClass = sos; + + for (i = 0; i < _runLength; ++i) + { + ref var resolvedClass = ref _runResolvedClasses[i]; + + switch (resolvedClass) + { + case BidiClass.EuropeanNumber: + { + // If prev strong type was an L change this to L too + if (previousStrongClass == BidiClass.LeftToRight) + { + _runResolvedClasses[i] = BidiClass.LeftToRight; + } + + break; + } + + case BidiClass.LeftToRight: + case BidiClass.RightToLeft: + { + // Remember previous strong type (NB: AL should already be changed to R) + previousStrongClass = resolvedClass; + break; + } + } + } + } + + // Rule N0 - process bracket pairs + if (_hasBrackets) + { + int count; + var pairedBrackets = LocatePairedBrackets(); + + for (i = 0, count = pairedBrackets.Count; i < count; i++) + { + var pairedBracket = pairedBrackets[i]; + + var strongDirection = InspectPairedBracket(pairedBracket); + + // Case "d" - no strong types in the brackets, ignore + if (strongDirection == BidiClass.OtherNeutral) + { + continue; + } + + // Case "b" - strong type found that matches the embedding direction + if ((strongDirection == BidiClass.LeftToRight || strongDirection == BidiClass.RightToLeft) && strongDirection == _runDirection) + { + SetPairedBracketDirection(pairedBracket, strongDirection); + continue; + } + + // Case "c" - found opposite strong type found, look before to establish context + strongDirection = InspectBeforePairedBracket(pairedBracket, sos); + + if (strongDirection == _runDirection || strongDirection == BidiClass.OtherNeutral) + { + strongDirection = _runDirection; + } + + SetPairedBracketDirection(pairedBracket, strongDirection); + } + } + + // Rules N1 and N2 - resolve neutral types + for (i = 0; i < _runLength; ++i) + { + var resolvedClass = _runResolvedClasses[i]; + + if (IsNeutralClass(resolvedClass)) + { + // Locate end of sequence + var seqStart = i; + var seqEnd = i; + + while (seqEnd < _runLength && IsNeutralClass(_runResolvedClasses[seqEnd])) + { + seqEnd++; + } + + // Work out the preceding class + BidiClass classBefore; + + if (seqStart == 0) + { + classBefore = sos; + } + else + { + classBefore = _runResolvedClasses[seqStart - 1]; + + switch (classBefore) + { + case BidiClass.ArabicNumber: + case BidiClass.EuropeanNumber: + { + classBefore = BidiClass.RightToLeft; + + break; + } + } + } + + // Work out the following class + BidiClass classAfter; + + if (seqEnd == _runLength) + { + classAfter = eos; + } + else + { + classAfter = _runResolvedClasses[seqEnd]; + + switch (classAfter) + { + case BidiClass.ArabicNumber: + case BidiClass.EuropeanNumber: + { + classAfter = BidiClass.RightToLeft; + + break; + } + } + } + + // Work out the final resolved type + BidiClass finalResolveClass; + + if (classBefore == classAfter) + { + // Rule N1 + finalResolveClass = classBefore; + } + else + { + // Rule N2 + finalResolveClass = _runDirection; + } + + // Apply changes + for (var j = seqStart; j < seqEnd; j++) + { + _runResolvedClasses[j] = finalResolveClass; + } + + // continue after this run + i = seqEnd; + } + } + + // Rules I1 and I2 - resolve implicit types + if ((_runLevel & 0x01) == 0) + { + // Rule I1 - even + for (i = 0; i < _runLength; i++) + { + var resolvedClass = _runResolvedClasses[i]; + ref var currentRunLevel = ref _runLevels[i]; + + switch (resolvedClass) + { + case BidiClass.RightToLeft: + { + currentRunLevel++; + break; + } + case BidiClass.ArabicNumber: + case BidiClass.EuropeanNumber: + { + currentRunLevel += 2; + + break; + } + } + } + } + else + { + // Rule I2 - odd + for (i = 0; i < _runLength; i++) + { + var resolvedClass = _runResolvedClasses[i]; + ref var currentRunLevel = ref _runLevels[i]; + + if (resolvedClass != BidiClass.RightToLeft) + { + currentRunLevel++; + } + } + } + } + + /// + /// Locate all pair brackets in the current isolating run + /// + /// A sorted list of BracketPairs + private List LocatePairedBrackets() + { + // Clear work collections + _pendingOpeningBrackets.Clear(); + _pairedBrackets.Clear(); + + // Since List.Sort is expensive on memory if called often (it internally + // allocates an ArraySorted object) and since we will rarely have many + // items in this list (most paragraphs will only have a handful of bracket + // pairs - if that), we use a simple linear lookup and insert most of the + // time. If there are more that `sortLimit` paired brackets we abort th + // linear searching/inserting and using List.Sort at the end. + const int sortLimit = 8; + + // Process all characters in the run, looking for paired brackets + for (int i = 0, length = _runLength; i < length; i++) + { + // Ignore non-neutral characters + if (_runResolvedClasses[i] != BidiClass.OtherNeutral) + { + continue; + } + + switch (_runBiDiPairedBracketTypes[i]) + { + case BidiPairedBracketType.Open: + if (_pendingOpeningBrackets.Count == MaxPairedBracketDepth) + { + goto exit; + } + + _pendingOpeningBrackets.Insert(0, i); + break; + + case BidiPairedBracketType.Close: + // see if there is a match + for (var j = 0; j < _pendingOpeningBrackets.Count; j++) + { + if (_runPairedBracketValues[i] != _runPairedBracketValues[_pendingOpeningBrackets[j]]) + { + continue; + } + + // Add this paired bracket set + var opener = _pendingOpeningBrackets[j]; + + if (_pairedBrackets.Count < sortLimit) + { + var ppi = 0; + while (ppi < _pairedBrackets.Count && _pairedBrackets[ppi].OpeningIndex < opener) + { + ppi++; + } + + _pairedBrackets.Insert(ppi, new BracketPair(opener, i)); + } + else + { + _pairedBrackets.Add(new BracketPair(opener, i)); + } + + // remove up to and including matched opener + _pendingOpeningBrackets.RemoveRange(0, j + 1); + break; + } + + break; + } + } + + exit: + + // Is a sort pending? + if (_pairedBrackets.Count > sortLimit) + { + _pairedBrackets.Sort(); + } + + return _pairedBrackets; + } + + /// + /// Inspect a paired bracket set and determine its strong direction + /// + /// The paired bracket to be inspected + /// The direction of the bracket set content + private BidiClass InspectPairedBracket(in BracketPair bracketPair) + { + var directionFromLevel = DirectionFromLevel(_runLevel); + var directionOpposite = BidiClass.OtherNeutral; + + for (var i = bracketPair.OpeningIndex + 1; i < bracketPair.ClosingIndex; i++) + { + var dir = GetStrongClassN0(_runResolvedClasses[i]); + + if (dir == BidiClass.OtherNeutral) + { + continue; + } + + if (dir == directionFromLevel) + { + return dir; + } + + directionOpposite = dir; + } + + return directionOpposite; + } + + /// + /// Look for a strong type before a paired bracket + /// + /// The paired bracket set to be inspected + /// The sos in case nothing found before the bracket + /// The strong direction before the brackets + private BidiClass InspectBeforePairedBracket(in BracketPair bracketPair, BidiClass sos) + { + for (var i = bracketPair.OpeningIndex - 1; i >= 0; --i) + { + var direction = GetStrongClassN0(_runResolvedClasses[i]); + + if (direction != BidiClass.OtherNeutral) + { + return direction; + } + } + + return sos; + } + + /// + /// Sets the direction of a bracket pair, including setting the direction of + /// NSM's inside the brackets and following. + /// + /// The paired brackets + /// The resolved direction for the bracket pair + private void SetPairedBracketDirection(in BracketPair bracketPair, BidiClass direction) + { + // Set the direction of the brackets + _runResolvedClasses[bracketPair.OpeningIndex] = direction; + _runResolvedClasses[bracketPair.ClosingIndex] = direction; + + // Set the directionality of NSM's inside the brackets + for (var i = bracketPair.OpeningIndex + 1; i < bracketPair.ClosingIndex; i++) + { + if (_runOriginalClasses[i] == BidiClass.NonspacingMark) + { + _runOriginalClasses[i] = direction; + } + else + { + break; + } + } + + // Set the directionality of NSM's following the brackets + for (var i = bracketPair.ClosingIndex + 1; i < _runLength; i++) + { + if (_runOriginalClasses[i] == BidiClass.NonspacingMark) + { + _runResolvedClasses[i] = direction; + } + else + { + break; + } + } + } + + /// + /// Resets whitespace levels. Implements rule L1 + /// + private void ResetWhitespaceLevels() + { + for (var i = 0; i < _resolvedLevels.Length; i++) + { + var originalClass = _originalClasses[i]; + + switch (originalClass) + { + case BidiClass.ParagraphSeparator: + case BidiClass.SegmentSeparator: + { + // Rule L1, clauses one and two. + _resolvedLevels[i] = _paragraphEmbeddingLevel; + + // Rule L1, clause three. + for (var j = i - 1; j >= 0; --j) + { + if (IsWhitespace(_originalClasses[j])) + { + // including format codes + _resolvedLevels[j] = _paragraphEmbeddingLevel; + } + else + { + break; + } + } + + break; + } + } + } + + // Rule L1, clause four. + for (var j = _resolvedLevels.Length - 1; j >= 0; j--) + { + if (IsWhitespace(_originalClasses[j])) + { // including format codes + _resolvedLevels[j] = _paragraphEmbeddingLevel; + } + else + { + break; + } + } + } + + /// + /// Assign levels to any characters that would be have been + /// removed by rule X9. The idea is to keep level runs together + /// that would otherwise be broken by an interfering isolate/embedding + /// control character. + /// + private void AssignLevelsToCodePointsRemovedByX9() + { + // Redundant? + if (!_hasIsolates && !_hasEmbeddings) + { + return; + } + + // No-op? + if (_workingClasses.Length == 0) + { + return; + } + + // Fix up first character + if (_resolvedLevels[0] < 0) + { + _resolvedLevels[0] = _paragraphEmbeddingLevel; + } + + if (IsRemovedByX9(_originalClasses[0])) + { + _workingClasses[0] = _originalClasses[0]; + } + + for (int i = 1, length = _workingClasses.Length; i < length; i++) + { + var originalClass = _originalClasses[i]; + + if (IsRemovedByX9(originalClass)) + { + _workingClasses[i] = originalClass; + _resolvedLevels[i] = _resolvedLevels[i - 1]; + } + } + } + + /// + /// Check if a directionality type represents whitespace + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsWhitespace(BidiClass biDiClass) + { + switch (biDiClass) + { + case BidiClass.LeftToRightEmbedding: + case BidiClass.RightToLeftEmbedding: + case BidiClass.LeftToRightOverride: + case BidiClass.RightToLeftOverride: + case BidiClass.PopDirectionalFormat: + case BidiClass.LeftToRightIsolate: + case BidiClass.RightToLeftIsolate: + case BidiClass.FirstStrongIsolate: + case BidiClass.PopDirectionalIsolate: + case BidiClass.BoundaryNeutral: + case BidiClass.WhiteSpace: + return true; + default: + return false; + } + } + + /// + /// Convert a level to a direction where odd is RTL and + /// even is LTR + /// + /// The level to convert + /// A directionality + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static BidiClass DirectionFromLevel(int level) + => ((level & 0x1) == 0) ? BidiClass.LeftToRight : BidiClass.RightToLeft; + + /// + /// Helper to check if a directionality is removed by rule X9 + /// + /// The bidi type to check + /// True if rule X9 would remove this character; otherwise false + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsRemovedByX9(BidiClass biDiClass) + { + switch (biDiClass) + { + case BidiClass.LeftToRightEmbedding: + case BidiClass.RightToLeftEmbedding: + case BidiClass.LeftToRightOverride: + case BidiClass.RightToLeftOverride: + case BidiClass.PopDirectionalFormat: + case BidiClass.BoundaryNeutral: + return true; + default: + return false; + } + } + + /// + /// Check if a a directionality is neutral for rules N1 and N2 + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsNeutralClass(BidiClass direction) + { + switch (direction) + { + case BidiClass.ParagraphSeparator: + case BidiClass.SegmentSeparator: + case BidiClass.WhiteSpace: + case BidiClass.OtherNeutral: + case BidiClass.RightToLeftIsolate: + case BidiClass.LeftToRightIsolate: + case BidiClass.FirstStrongIsolate: + case BidiClass.PopDirectionalIsolate: + return true; + default: + return false; + } + } + + /// + /// Maps a direction to a strong class for rule N0 + /// + /// The direction to map + /// A strong direction - R, L or ON + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static BidiClass GetStrongClassN0(BidiClass direction) + { + switch (direction) + { + case BidiClass.EuropeanNumber: + case BidiClass.ArabicNumber: + case BidiClass.ArabicLetter: + case BidiClass.RightToLeft: + return BidiClass.RightToLeft; + case BidiClass.LeftToRight: + return BidiClass.LeftToRight; + default: + return BidiClass.OtherNeutral; + } + } + + /// + /// Hold the start and end index of a pair of brackets + /// + private readonly struct BracketPair : IComparable + { + /// + /// Initializes a new instance of the struct. + /// + /// Index of the opening bracket + /// Index of the closing bracket + public BracketPair(int openingIndex, int closingIndex) + { + OpeningIndex = openingIndex; + ClosingIndex = closingIndex; + } + + /// + /// Gets the index of the opening bracket + /// + public int OpeningIndex { get; } + + /// + /// Gets the index of the closing bracket + /// + public int ClosingIndex { get; } + + public int CompareTo(BracketPair other) + => OpeningIndex.CompareTo(other.OpeningIndex); + } + + /// + /// Status stack entry used while resolving explicit + /// embedding levels + /// + private readonly struct Status + { + public Status(sbyte embeddingLevel, BidiClass overrideStatus, bool isolateStatus) + { + EmbeddingLevel = embeddingLevel; + OverrideStatus = overrideStatus; + IsolateStatus = isolateStatus; + } + + public sbyte EmbeddingLevel { get; } + + public BidiClass OverrideStatus { get; } + + public bool IsolateStatus { get; } + } + + /// + /// Provides information about a level run - a continuous + /// sequence of equal levels. + /// + private readonly struct LevelRun + { + public LevelRun(int start, int length, int level, BidiClass sos, BidiClass eos) + { + Start = start; + Length = length; + Level = level; + Sos = sos; + Eos = eos; + } + + public int Start { get; } + + public int Length { get; } + + public int Level { get; } + + public BidiClass Sos { get; } + + public BidiClass Eos { get; } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiClass.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiClass.cs index ad3cc9141b..28c6fee089 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiClass.cs +++ b/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 diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiData.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiData.cs new file mode 100644 index 0000000000..9d76d56376 --- /dev/null +++ b/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 +{ + /// + /// Represents a unicode string and all associated attributes + /// for each character required for the bidirectional Unicode algorithm + /// + internal class BidiData + { + private ArrayBuilder _classes; + private ArrayBuilder _pairedBracketTypes; + private ArrayBuilder _pairedBracketValues; + private ArrayBuilder _savedClasses; + private ArrayBuilder _savedPairedBracketTypes; + private ArrayBuilder _tempLevelBuffer; + + public BidiData(sbyte paragraphEmbeddingLevel = 0) + { + ParagraphEmbeddingLevel = paragraphEmbeddingLevel; + } + + public BidiData(ReadOnlySlice 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; } + + /// + /// Gets the length of the data held by the BidiData + /// + public int Length{get; private set; } + + /// + /// Gets the bidi character type of each code point + /// + public ArraySlice Classes { get; private set; } + + /// + /// Gets the paired bracket type for each code point + /// + public ArraySlice PairedBracketTypes { get; private set; } + + /// + /// Gets the paired bracket value for code point + /// + /// + /// 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 + /// + public ArraySlice PairedBracketValues { get; private set; } + + public void Append(ReadOnlySlice 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); + } + + /// + /// Save the Types and PairedBracketTypes of this BiDiData + /// + /// + /// 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. + /// + public void SaveTypes() + { + // Capture the types data + _savedClasses.Clear(); + _savedClasses.Add(_classes.AsSlice()); + _savedPairedBracketTypes.Clear(); + _savedPairedBracketTypes.Add(_pairedBracketTypes.AsSlice()); + } + + /// + /// Restore the data saved by SaveTypes + /// + public void RestoreTypes() + { + _classes.Clear(); + _classes.Add(_savedClasses.AsSlice()); + _pairedBracketTypes.Clear(); + _pairedBracketTypes.Add(_savedPairedBracketTypes.AsSlice()); + } + + /// + /// Gets a temporary level buffer. Used by the text layout process when + /// resolving style runs with different BiDiClass. + /// + /// Length of the required ExpandableBuffer + /// An uninitialized level ExpandableBuffer + public ArraySlice GetTempLevelBuffer(int length) + { + _tempLevelBuffer.Clear(); + + return _tempLevelBuffer.Add(length, false); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiPairedBracketType.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiPairedBracketType.cs new file mode 100644 index 0000000000..f204497eb1 --- /dev/null +++ b/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 + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs index 43a95310c6..39440f6fcf 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs +++ b/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); /// - /// Gets the . + /// Gets the . /// - public BiDiClass BiDiClass => UnicodeData.GetBiDiClass(Value); + public BidiClass BiDiClass => UnicodeData.GetBiDiClass(Value); + /// + /// Gets the . + /// + public BidiPairedBracketType PairedBracketType => UnicodeData.GetBiDiPairedBracketType(Value); + /// /// Gets the . /// @@ -93,6 +99,52 @@ namespace Avalonia.Media.TextFormatting.Unicode return false; } } + + /// + /// Gets the canonical representation of a given codepoint. + /// + /// + /// The code point to be mapped. + /// The mapped canonical code point, or the passed . + [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; + } + + /// + /// Gets the codepoint representing the bracket pairing for this instance. + /// + /// + /// When this method returns, contains the codepoint representing the bracket pairing for this instance; + /// otherwise, the default value for the type of the parameter. + /// This parameter is passed uninitialized. + /// . + /// if this instance has a bracket pairing; otherwise, + [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) { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/PropertyValueAliasHelper.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/PropertyValueAliasHelper.cs index 388a7d257d..af5d9ff752 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/PropertyValueAliasHelper.cs +++ b/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 s_tagToScript = + new Dictionary{ + { "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 s_tagToGeneralCategory = + new Dictionary{ + { "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 s_tagToLineBreakClass = + new Dictionary{ + { "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 s_tagToBiDiPairedBracketType = + new Dictionary{ + { "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 s_tagToBiDiClass = + new Dictionary{ + { "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]; + } + } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs index 4189b24af6..471cb52bea 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs +++ b/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 { /// /// 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")!); } /// @@ -33,11 +45,10 @@ /// /// The codepoint in question. /// The code point's general category. + [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); } /// @@ -45,23 +56,43 @@ /// /// The codepoint in question. /// The code point's script. + [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); } /// - /// Gets the for a Unicode codepoint. + /// Gets the for a Unicode codepoint. /// /// The codepoint in question. /// The code point's biDi class. - 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); + } + + /// + /// Gets the for a Unicode codepoint. + /// + /// The codepoint in question. + /// The code point's paired bracket type. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static BidiPairedBracketType GetBiDiPairedBracketType(int codepoint) + { + return (BidiPairedBracketType)((s_biDiTrie.Get(codepoint) >> BIDIPAIREDBRACKEDTYPE_SHIFT) & BIDIPAIREDBRACKEDTYPE_MASK); + } + + /// + /// Gets the paired bracket for a Unicode codepoint. + /// + /// The codepoint in question. + /// The code point's paired bracket. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Codepoint GetBiDiPairedBracket(int codepoint) + { + return new Codepoint((int)(s_biDiTrie.Get(codepoint) & BIDIPAIREDBRACKED_MASK)); } /// @@ -69,11 +100,10 @@ /// /// The codepoint in question. /// The code point's line break class. + [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); } /// @@ -81,6 +111,7 @@ /// /// The codepoint in question. /// The code point's grapheme break type. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static GraphemeBreakClass GetGraphemeClusterBreak(int codepoint) { return (GraphemeBreakClass)s_graphemeBreakTrie.Get(codepoint); diff --git a/src/Avalonia.Visuals/Media/TextHitTestResult.cs b/src/Avalonia.Visuals/Media/TextHitTestResult.cs index 537dfed49d..c8922f06c8 100644 --- a/src/Avalonia.Visuals/Media/TextHitTestResult.cs +++ b/src/Avalonia.Visuals/Media/TextHitTestResult.cs @@ -1,23 +1,38 @@ +using Avalonia.Media.TextFormatting; + namespace Avalonia.Media { /// - /// Holds a hit test result from a . + /// Holds a hit test result from a . /// - 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; + } + + /// + /// Gets the character hit of the hit test result. + /// + public CharacterHit CharacterHit { get; } + /// - /// 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. /// - public bool IsInside { get; set; } + public bool IsInside { get; } /// /// Gets the index of the hit character in the text. /// - public int TextPosition { get; set; } + public int TextPosition { get; } /// /// Gets a value indicating whether the hit is on the trailing edge of the character. /// - public bool IsTrailing { get; set; } + public bool IsTrailing { get; } } } diff --git a/src/Avalonia.Visuals/Media/Typeface.cs b/src/Avalonia.Visuals/Media/Typeface.cs index 8245b63440..45540a5812 100644 --- a/src/Avalonia.Visuals/Media/Typeface.cs +++ b/src/Avalonia.Visuals/Media/Typeface.cs @@ -48,7 +48,7 @@ namespace Avalonia.Media /// /// Gets the font family. /// - public FontFamily? FontFamily { get; } + public FontFamily FontFamily { get; } /// /// Gets the font style. diff --git a/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs b/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs index a1f42c171c..82af6ff0b6 100644 --- a/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs +++ b/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs @@ -83,13 +83,6 @@ namespace Avalonia.Platform /// void DrawEllipse(IBrush? brush, IPen? pen, Rect rect); - /// - /// Draws text. - /// - /// The foreground brush. - /// The upper-left corner of the text. - /// The text. - void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text); /// /// Draws a glyph run. diff --git a/src/Avalonia.Visuals/Platform/IFormattedTextImpl.cs b/src/Avalonia.Visuals/Platform/IFormattedTextImpl.cs deleted file mode 100644 index 330fcac50c..0000000000 --- a/src/Avalonia.Visuals/Platform/IFormattedTextImpl.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia.Media; - -namespace Avalonia.Platform -{ - /// - /// Defines the platform-specific interface for . - /// - public interface IFormattedTextImpl - { - /// - /// Gets the constraint of the text. - /// - Size Constraint { get; } - - /// - /// The measured bounds of the text. - /// - Rect Bounds{ get; } - - /// - /// Gets the text. - /// - string Text { get; } - - /// - /// Gets the lines in the text. - /// - /// - /// A collection of objects. - /// - IEnumerable GetLines(); - - /// - /// Hit tests a point in the text. - /// - /// The point. - /// - /// A describing the result of the hit test. - /// - TextHitTestResult HitTestPoint(Point point); - - /// - /// Gets the bounds rectangle that the specified character occupies. - /// - /// The index of the character. - /// The character bounds. - Rect HitTestTextPosition(int index); - - /// - /// Gets the bounds rectangles that the specified text range occupies. - /// - /// The index of the first character. - /// The number of characters in the text range. - /// The character bounds. - IEnumerable HitTestTextRange(int index, int length); - } -} diff --git a/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs b/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs index 60ae0b5ef8..a295a8cdc9 100644 --- a/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs +++ b/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs @@ -11,27 +11,6 @@ namespace Avalonia.Platform /// public interface IPlatformRenderInterface { - /// - /// Creates a formatted text implementation. - /// - /// The text. - /// The base typeface. - /// The font size. - /// The text alignment. - /// The text wrapping mode. - /// The text layout constraints. - /// The style spans. - /// An . - IFormattedTextImpl CreateFormattedText( - string text, - Typeface typeface, - double fontSize, - TextAlignment textAlignment, - TextWrapping wrapping, - Size constraint, - IReadOnlyList? spans); - - /// /// Creates an ellipse geometry implementation. /// /// The bounds of the ellipse. diff --git a/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs b/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs index 73d198d7ef..aced05c9d8 100644 --- a/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs +++ b/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 { /// - /// 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. /// /// The text. /// The typeface. /// The font rendering em size. /// The culture. + /// The bidi level. /// A shaped glyph run. - GlyphRun ShapeText(ReadOnlySlice text, Typeface typeface, double fontRenderingEmSize, CultureInfo? culture); + ShapedBuffer ShapeText(ReadOnlySlice text, GlyphTypeface typeface, double fontRenderingEmSize, CultureInfo? culture, sbyte bidiLevel); } } diff --git a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs index c453181f65..82be0a1a0f 100644 --- a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs +++ b/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); + } } } diff --git a/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs b/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs index f6642102f7..23016de148 100644 --- a/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs +++ b/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); } } } diff --git a/src/Avalonia.Visuals/Rendering/RendererBase.cs b/src/Avalonia.Visuals/Rendering/RendererBase.cs index 5c9cace4cd..90ba60c42a 100644 --- a/src/Avalonia.Visuals/Rendering/RendererBase.cs +++ b/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); } } } diff --git a/src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs b/src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs index 688cbd83c8..9710ca6c3c 100644 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs +++ b/src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs @@ -202,21 +202,6 @@ namespace Avalonia.Rendering.SceneGraph ++_drawOperationindex; } - /// - public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text) - { - var next = NextDrawAs(); - - if (next == null || !next.Item.Equals(Transform, foreground, origin, text)) - { - Add(new TextNode(Transform, foreground, origin, text, CreateChildScene(foreground))); - } - else - { - ++_drawOperationindex; - } - } - /// public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun) { diff --git a/src/Avalonia.Visuals/Rendering/SceneGraph/TextNode.cs b/src/Avalonia.Visuals/Rendering/SceneGraph/TextNode.cs deleted file mode 100644 index 4a1587fb90..0000000000 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/TextNode.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.Generic; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.VisualTree; - -namespace Avalonia.Rendering.SceneGraph -{ - /// - /// A node in the scene graph which represents a text draw. - /// - internal class TextNode : BrushDrawOperation - { - /// - /// Initializes a new instance of the class. - /// - /// The transform. - /// The foreground brush. - /// The draw origin. - /// The text to draw. - /// Child scenes for drawing visual brushes. - public TextNode( - Matrix transform, - IBrush foreground, - Point origin, - IFormattedTextImpl text, - IDictionary? childScenes = null) - : base(text.Bounds.Translate(origin), transform) - { - Transform = transform; - Foreground = foreground.ToImmutable(); - Origin = origin; - Text = text; - ChildScenes = childScenes; - } - - /// - /// Gets the transform with which the node will be drawn. - /// - public Matrix Transform { get; } - - /// - /// Gets the foreground brush. - /// - public IBrush Foreground { get; } - - /// - /// Gets the draw origin. - /// - public Point Origin { get; } - - /// - /// Gets the text to draw. - /// - public IFormattedTextImpl Text { get; } - - /// - public override IDictionary? ChildScenes { get; } - - /// - public override void Render(IDrawingContextImpl context) - { - context.Transform = Transform; - context.DrawText(Foreground, Origin, Text); - } - - /// - /// Determines if this draw operation equals another. - /// - /// The transform of the other draw operation. - /// The foreground of the other draw operation. - /// The draw origin of the other draw operation. - /// The text of the other draw operation. - /// True if the draw operations are the same, otherwise false. - /// - /// The properties of the other draw operation are passed in as arguments to prevent - /// allocation of a not-yet-constructed draw operation object. - /// - internal bool Equals(Matrix transform, IBrush foreground, Point origin, IFormattedTextImpl text) - { - return transform == Transform && - Equals(foreground, Foreground) && - origin == Origin && - Equals(text, Text); - } - - /// - public override bool HitTest(Point p) => Bounds.Contains(p); - } -} diff --git a/src/Avalonia.Visuals/Utilities/ArrayBuilder.cs b/src/Avalonia.Visuals/Utilities/ArrayBuilder.cs new file mode 100644 index 0000000000..7048659431 --- /dev/null +++ b/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 +{ + /// + /// A helper type for avoiding allocations while building arrays. + /// + /// The type of item contained in the array. + internal struct ArrayBuilder + 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; + + /// + /// Gets or sets the number of items in the array. + /// + public int Length + { + get => _size; + + set + { + if (value == _size) + { + return; + } + + if (value > 0) + { + EnsureCapacity(value); + + _size = value; + } + else + { + _size = 0; + } + } + } + + /// + /// Returns a reference to specified element of the array. + /// + /// The index of the element to return. + /// The . + /// + /// Thrown when index less than 0 or index greater than or equal to . + /// + 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]; + } + } + + /// + /// Appends a given number of empty items to the array returning + /// the items as a slice. + /// + /// The number of items in the slice. + /// Whether to clear the new slice, Defaults to . + /// The . + public ArraySlice 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; + } + + /// + /// Appends the slice to the array copying the data across. + /// + /// The array slice. + /// The . + public ArraySlice Add(in ReadOnlySlice value) + { + var position = _size; + + // Expand the array. + Length += value.Length; + + var slice = AsSlice(position, Length - position); + + value.Span.CopyTo(slice.Span); + + return slice; + } + + /// + /// Clears the array. + /// Allocated memory is left intact for future usage. + /// + 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. + 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; + } + + /// + /// Returns the current state of the array as a slice. + /// + /// The . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ArraySlice AsSlice() => AsSlice(Length); + + /// + /// Returns the current state of the array as a slice. + /// + /// The number of items in the slice. + /// The . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ArraySlice AsSlice(int length) => new ArraySlice(_data!, 0, length); + + /// + /// Returns the current state of the array as a slice. + /// + /// The index at which to begin the slice. + /// The number of items in the slice. + /// The . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ArraySlice AsSlice(int start, int length) => new ArraySlice(_data!, start, length); + } +} diff --git a/src/Avalonia.Visuals/Utilities/ArraySlice.cs b/src/Avalonia.Visuals/Utilities/ArraySlice.cs new file mode 100644 index 0000000000..f5a9d3a98d --- /dev/null +++ b/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 +{ + /// + /// ArraySlice represents a contiguous region of arbitrary memory similar + /// to and though constrained + /// to arrays. + /// Unlike , it is not a byref-like type. + /// + /// The type of item contained in the slice. + internal readonly struct ArraySlice : IReadOnlyList + where T : struct + { + /// + /// Gets an empty + /// + public static ArraySlice Empty => new ArraySlice(Array.Empty()); + + private readonly T[] _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data buffer. + public ArraySlice(T[] data) + : this(data, 0, data.Length) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data buffer. + /// The offset position in the underlying buffer this slice was created from. + /// The number of items in the slice. + 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; + } + + + /// + /// Gets a value that indicates whether this instance of is Empty. + /// + public bool IsEmpty => Length == 0; + + /// + /// Gets the offset position in the underlying buffer this slice was created from. + /// + public int Start { get; } + + /// + /// Gets the number of items in the slice. + /// + public int Length { get; } + + /// + /// Gets a representing this slice. + /// + public Span Span => new Span(_data, Start, Length); + + /// + /// Returns a reference to specified element of the slice. + /// + /// The index of the element to return. + /// The . + /// + /// Thrown when index less than 0 or index greater than or equal to . + /// + 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]; + } + } + + /// + /// Defines an implicit conversion of a to a + /// + public static implicit operator ReadOnlySlice(ArraySlice slice) + { + return new ReadOnlySlice(slice._data).AsSlice(slice.Start, slice.Length); + } + + /// + /// Defines an implicit conversion of an array to a + /// + public static implicit operator ArraySlice(T[] array) => new ArraySlice(array, 0, array.Length); + + /// + /// Fills the contents of this slice with the given value. + /// + public void Fill(T value) => Span.Fill(value); + + /// + /// Forms a slice out of the given slice, beginning at 'start', of given length + /// + /// The index at which to begin this slice. + /// The desired length for the slice (exclusive). + /// + /// Thrown when the specified or end index is not in range (<0 or >Length). + /// + public ArraySlice Slice(int start, int length) => new ArraySlice(_data, start, length); + + /// + /// Returns a specified number of contiguous elements from the start of the slice. + /// + /// The number of elements to return. + /// A that contains the specified number of elements from the start of this slice. + public ArraySlice Take(int length) + { + if (IsEmpty) + { + return this; + } + + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return new ArraySlice(_data, Start, length); + } + + /// + /// Bypasses a specified number of elements in the slice and then returns the remaining elements. + /// + /// The number of elements to skip before returning the remaining elements. + /// A that contains the elements that occur after the specified index in this slice. + public ArraySlice Skip(int length) + { + if (IsEmpty) + { + return this; + } + + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return new ArraySlice(_data, Start + length, Length - length); + } + + public ImmutableReadOnlyListStructEnumerator GetEnumerator() => + new ImmutableReadOnlyListStructEnumerator(this); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + T IReadOnlyList.this[int index] => this[index]; + + /// + int IReadOnlyCollection.Count => Length; + } +} diff --git a/src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs b/src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs new file mode 100644 index 0000000000..2ab78049fb --- /dev/null +++ b/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 +{ + /// + /// Extension methods for binary searching an IReadOnlyList collection + /// + 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); + } + + /// + /// Performs a binary search on the entire contents of an IReadOnlyList + /// + /// The list element type + /// The list to be searched + /// The value to search for + /// The index of the found item; otherwise the bitwise complement of the index of the next larger item + public static int BinarySearch(this IReadOnlyList list, T value) where T : IComparable + { + return list.BinarySearch(value, Comparer.Default); + } + + /// + /// Performs a binary search on the entire contents of an IReadOnlyList + /// + /// The list element type + /// The list to be searched + /// The value to search for + /// The comparer + /// The index of the found item; otherwise the bitwise complement of the index of the next larger item + public static int BinarySearch(this IReadOnlyList list, T value, IComparer comparer) where T : IComparable + { + return list.BinarySearch(0, list.Count, value, comparer); + } + + /// + /// Performs a binary search on a a subset of an IReadOnlyList + /// + /// The list element type + /// The value type being searched for + /// The list to be searched + /// The start of the range to be searched + /// The length of the range to be searched + /// The value to search for + /// A comparer + /// The index of the found item; otherwise the bitwise complement of the index of the next larger item + public static int BinarySearch(this IReadOnlyList list, int index, int length, T value, IComparer 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; + } + } +} diff --git a/src/Avalonia.Visuals/Utilities/FrugalList.cs b/src/Avalonia.Visuals/Utilities/FrugalList.cs new file mode 100644 index 0000000000..3ef7418533 --- /dev/null +++ b/src/Avalonia.Visuals/Utilities/FrugalList.cs @@ -0,0 +1,2360 @@ +// 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. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; + +// These classes implement a frugal storage model for lists of type . +// Performance measurements show that Avalon has many lists that contain +// a limited number of entries, and frequently zero or a single entry. +// Therefore these classes are structured to prefer a storage model that +// starts at zero, and employs a conservative growth strategy to minimize +// the steady state memory footprint. Also note that the list uses one +// fewer objects than ArrayList and List and does no allocations at all +// until an item is inserted into the list. +// +// The code is also structured to perform well from a CPU standpoint. Perf +// analysis shows that the reduced number of processor cache misses makes +// FrugalList faster than ArrayList or List, especially for lists of 6 +// or fewer items. Timing differ with the size of . +// +// FrugalList is appropriate for small lists or lists that grow slowly. +// Due to the slow growth, if you use it for a list with more than 6 initial +// entries is best to set the capacity of the list at construction time or +// soon after. If you must grow the list by a large amount, set the capacity +// or use Insert() method to force list growth to the final size. Choose +// your collections wisely and pay particular attention to the growth +// patterns and search methods. + +// FrugalList has all of the methods of the IList interface, but implements +// them as virtual methods of the class and not as Interface methods. This +// is to avoid the virtual stub dispatch CPU costs associated with Interfaces. + +namespace Avalonia.Utilities +{ + // These classes implement a frugal storage model for lists of . + // Performance measurements show that many lists contain a single item. + // Therefore this list is structured to prefer a list that contains a single + // item, and does conservative growth to minimize the memory footprint. + + // This enum controls the growth to successively more complex storage models + internal enum FrugalListStoreState + { + Success, + SingleItemList, + ThreeItemList, + SixItemList, + Array + } + +#nullable disable + internal abstract class FrugalListBase + { + /// + /// Number of entries in this store + /// + // Number of entries in this store + public int Count + { + get + { + return _count; + } + } + + // for use only by trusted callers - e.g. FrugalObjectList.Compacter + internal void TrustedSetCount(int newCount) + { + _count = newCount; + } + + /// + /// Capacity of this store + /// + public abstract int Capacity + { + get; + } + + // Increase size if needed, insert item into the store + public abstract FrugalListStoreState Add(T value); + + /// + /// Removes all values from the store + /// + public abstract void Clear(); + + /// + /// Returns true if the store contains the entry. + /// + public abstract bool Contains(T value); + + /// + /// Returns the index into the store that contains the item. + /// -1 is returned if the item is not in the store. + /// + public abstract int IndexOf(T value); + + /// + /// Insert item into the store at index, grows if needed + /// + public abstract void Insert(int index, T value); + + // Place item into the store at index + public abstract void SetAt(int index, T value); + + /// + /// Removes the item from the store. If the item was not + /// in the store false is returned. + /// + public abstract bool Remove(T value); + + /// + /// Removes the item from the store + /// + public abstract void RemoveAt(int index); + + /// + /// Return the item at index in the store + /// + public abstract T EntryAt(int index); + + /// + /// Promotes the values in the current store to the next larger + /// and more complex storage model. + /// + public abstract void Promote(FrugalListBase newList); + + /// + /// Returns the entries as an array + /// + public abstract T[] ToArray(); + + /// + /// Copies the entries to the given array starting at the + /// specified index + /// + public abstract void CopyTo(T[] array, int index); + + /// + /// Creates a shallow copy of the list + /// + public abstract object Clone(); + + // The number of items in the list. + protected int _count; + + public virtual Compacter NewCompacter(int newCount) + { + return new Compacter(this, newCount); + } + + // basic implementation - compacts in-place + internal class Compacter + { + protected readonly FrugalListBase _store; + private readonly int _newCount; + + protected int _validItemCount; + protected int _previousEnd; + + public Compacter(FrugalListBase store, int newCount) + { + _store = store; + _newCount = newCount; + } + + public void Include(int start, int end) + { + Debug.Assert(start >= _previousEnd, "Arguments out of order during Compact"); + Debug.Assert(_validItemCount + end - start <= _newCount, "Too many items copied during Compact"); + + IncludeOverride(start, end); + + _previousEnd = end; + } + + protected virtual void IncludeOverride(int start, int end) + { + // item-by-item move + for (var i = start; i < end; ++i) + { + _store.SetAt(_validItemCount++, _store.EntryAt(i)); + } + } + + public virtual FrugalListBase Finish() + { + // clear out vacated entries + var filler = default(T); + + for (int i = _validItemCount, n = _store._count; i < n; ++i) + { + _store.SetAt(i, filler); + } + + _store._count = _validItemCount; + + return _store; + } + } + } + + /// + /// A simple class to handle a single item + /// + internal sealed class SingleItemList : FrugalListBase + { + private const int SIZE = 1; + + private T _loneEntry; + + // Capacity of this store + public override int Capacity + { + get + { + return SIZE; + } + } + + public override FrugalListStoreState Add(T value) + { + // If we don't have any entries or the existing entry is being overwritten, + // then we can use this store. Otherwise we have to promote. + if (0 == _count) + { + _loneEntry = value; + ++_count; + return FrugalListStoreState.Success; + } + else + { + // Entry already used, move to an ThreeItemList + return FrugalListStoreState.ThreeItemList; + } + } + + public override void Clear() + { + // Wipe out the info + _loneEntry = default; + _count = 0; + } + + public override bool Contains(T value) + { + return _loneEntry.Equals(value); + } + + public override int IndexOf(T value) + { + if (_loneEntry.Equals(value)) + { + return 0; + } + return -1; + } + + public override void Insert(int index, T value) + { + // Should only get here if count and index are 0 + if (_count < SIZE && index < SIZE) + { + _loneEntry = value; + ++_count; + return; + } + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public override void SetAt(int index, T value) + { + // Overwrite item at index + _loneEntry = value; + } + + public override bool Remove(T value) + { + // Wipe out the info in the only entry if it matches the item. + if (_loneEntry.Equals(value)) + { + _loneEntry = default; + --_count; + return true; + } + + return false; + } + + public override void RemoveAt(int index) + { + // Wipe out the info at index + if (0 == index) + { + _loneEntry = default; + --_count; + } + else + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public override T EntryAt(int index) + { + return _loneEntry; + } + + public override void Promote(FrugalListBase oldList) + { + if (SIZE == oldList.Count) + { + SetCount(SIZE); + SetAt(0, oldList.EntryAt(0)); + } + else + { + // this list is smaller than oldList + throw new ArgumentException($"Cannot promote from '{oldList}' to '{ToString()}' because the target map is too small.", nameof(oldList)); + } + } + + // Class specific implementation to avoid virtual method calls and additional logic + public void Promote(SingleItemList oldList) + { + SetCount(oldList.Count); + SetAt(0, oldList.EntryAt(0)); + } + + public override T[] ToArray() + { + var array = new T[1]; + array[0] = _loneEntry; + return array; + } + + public override void CopyTo(T[] array, int index) + { + array[index] = _loneEntry; + } + + public override object Clone() + { + var newList = new SingleItemList(); + newList.Promote(this); + return newList; + } + + private void SetCount(int value) + { + if (value >= 0 && value <= SIZE) + { + _count = value; + } + else + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + } + } + + + /// + /// A simple class to handle a list with 3 items. Perf analysis showed + /// that this yielded better memory locality and perf than an object and an array. + /// + internal sealed class ThreeItemList : FrugalListBase + { + private const int SIZE = 3; + + private T _entry0; + private T _entry1; + private T _entry2; + + // Capacity of this store + public override int Capacity + { + get + { + return SIZE; + } + } + + public override FrugalListStoreState Add(T value) + { + switch (_count) + { + case 0: + _entry0 = value; + break; + + case 1: + _entry1 = value; + break; + + case 2: + _entry2 = value; + break; + + default: + // We have to promote + return FrugalListStoreState.SixItemList; + } + ++_count; + return FrugalListStoreState.Success; + } + + public override void Clear() + { + // Wipe out the info. + _entry0 = default; + _entry1 = default; + _entry2 = default; + _count = 0; + } + + public override bool Contains(T value) + { + return -1 != IndexOf(value); + } + + public override int IndexOf(T value) + { + if (_entry0.Equals(value)) + { + return 0; + } + + if (_count > 1) + { + if (_entry1.Equals(value)) + { + return 1; + } + if (3 == _count && _entry2.Equals(value)) + { + return 2; + } + } + + return -1; + } + + public override void Insert(int index, T value) + { + // Should only get here if count < SIZE + if (_count < SIZE) + { + switch (index) + { + case 0: + _entry2 = _entry1; + _entry1 = _entry0; + _entry0 = value; + break; + + case 1: + _entry2 = _entry1; + _entry1 = value; + break; + + case 2: + _entry2 = value; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + ++_count; + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public override void SetAt(int index, T value) + { + // Overwrite item at index + switch (index) + { + case 0: + _entry0 = value; + break; + + case 1: + _entry1 = value; + break; + + case 2: + _entry2 = value; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public override bool Remove(T value) + { + // If the item matches an existing entry, wipe out the last + // entry and move all the other entries up. Because we only + // have three entries we can just unravel all the cases. + if (_entry0.Equals(value)) + { + RemoveAt(0); + return true; + } + else if (_count > 1) + { + if (_entry1.Equals(value)) + { + RemoveAt(1); + return true; + } + else if (3 == _count && _entry2.Equals(value)) + { + RemoveAt(2); + return true; + } + } + return false; + } + + public override void RemoveAt(int index) + { + // Remove entry at index, wipe out the last entry and move + // all the other entries up. Because we only have three + // entries we can just unravel all the cases. + switch (index) + { + case 0: + _entry0 = _entry1; + _entry1 = _entry2; + break; + + case 1: + _entry1 = _entry2; + break; + + case 2: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + + _entry2 = default; + + --_count; + } + + public override T EntryAt(int index) + { + switch (index) + { + case 0: + return _entry0; + + case 1: + return _entry1; + + case 2: + return _entry2; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public override void Promote(FrugalListBase oldList) + { + var oldCount = oldList.Count; + + if (SIZE >= oldCount) + { + SetCount(oldList.Count); + + switch (oldCount) + { + case 3: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + break; + + case 2: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + break; + + case 1: + SetAt(0, oldList.EntryAt(0)); + break; + + case 0: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(oldList)); + } + } + else + { + // this list is smaller than oldList + throw new ArgumentException($"Cannot promote from '{oldList}' to '{ToString()}' because the target map is too small.", nameof(oldList)); + } + } + + // Class specific implementation to avoid virtual method calls and additional logic + public void Promote(SingleItemList oldList) + { + SetCount(oldList.Count); + SetAt(0, oldList.EntryAt(0)); + } + + // Class specific implementation to avoid virtual method calls and additional logic + public void Promote(ThreeItemList oldList) + { + var oldCount = oldList.Count; + SetCount(oldList.Count); + + switch (oldCount) + { + case 3: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + break; + + case 2: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + break; + + case 1: + SetAt(0, oldList.EntryAt(0)); + break; + + case 0: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(oldList)); + } + } + + public override T[] ToArray() + { + var array = new T[_count]; + + array[0] = _entry0; + if (_count >= 2) + { + array[1] = _entry1; + if (_count == 3) + { + array[2] = _entry2; + } + } + return array; + } + + public override void CopyTo(T[] array, int index) + { + array[index] = _entry0; + if (_count >= 2) + { + array[index + 1] = _entry1; + if (_count == 3) + { + array[index + 2] = _entry2; + } + } + } + + public override object Clone() + { + var newList = new ThreeItemList(); + newList.Promote(this); + return newList; + } + + private void SetCount(int value) + { + if (value >= 0 && value <= SIZE) + { + _count = value; + } + else + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + } + } + + /// + /// A simple class to handle a list with 6 items. + /// + internal sealed class SixItemList : FrugalListBase + { + private const int SIZE = 6; + + private T _entry0; + private T _entry1; + private T _entry2; + private T _entry3; + private T _entry4; + private T _entry5; + + // Capacity of this store + public override int Capacity + { + get + { + return SIZE; + } + } + + public override FrugalListStoreState Add(T value) + { + switch (_count) + { + case 0: + _entry0 = value; + break; + + case 1: + _entry1 = value; + break; + + case 2: + _entry2 = value; + break; + + case 3: + _entry3 = value; + break; + + case 4: + _entry4 = value; + break; + + case 5: + _entry5 = value; + break; + + default: + // We have to promote + return FrugalListStoreState.Array; + } + ++_count; + return FrugalListStoreState.Success; + } + + public override void Clear() + { + // Wipe out the info. + _entry0 = default; + _entry1 = default; + _entry2 = default; + _entry3 = default; + _entry4 = default; + _entry5 = default; + _count = 0; + } + + public override bool Contains(T value) + { + return -1 != IndexOf(value); + } + + public override int IndexOf(T value) + { + if (_entry0.Equals(value)) + { + return 0; + } + if (_count > 1) + { + if (_entry1.Equals(value)) + { + return 1; + } + if (_count > 2) + { + if (_entry2.Equals(value)) + { + return 2; + } + if (_count > 3) + { + if (_entry3.Equals(value)) + { + return 3; + } + if (_count > 4) + { + if (_entry4.Equals(value)) + { + return 4; + } + if (6 == _count && _entry5.Equals(value)) + { + return 5; + } + } + } + } + } + return -1; + } + + public override void Insert(int index, T value) + { + // Should only get here if count is less than SIZE + if (_count < SIZE) + { + switch (index) + { + case 0: + _entry5 = _entry4; + _entry4 = _entry3; + _entry3 = _entry2; + _entry2 = _entry1; + _entry1 = _entry0; + _entry0 = value; + break; + + case 1: + _entry5 = _entry4; + _entry4 = _entry3; + _entry3 = _entry2; + _entry2 = _entry1; + _entry1 = value; + break; + + case 2: + _entry5 = _entry4; + _entry4 = _entry3; + _entry3 = _entry2; + _entry2 = value; + break; + + case 3: + _entry5 = _entry4; + _entry4 = _entry3; + _entry3 = value; + break; + + case 4: + _entry5 = _entry4; + _entry4 = value; + break; + + case 5: + _entry5 = value; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + ++_count; + return; + } + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public override void SetAt(int index, T value) + { + // Overwrite item at index + switch (index) + { + case 0: + _entry0 = value; + break; + + case 1: + _entry1 = value; + break; + + case 2: + _entry2 = value; + break; + + case 3: + _entry3 = value; + break; + + case 4: + _entry4 = value; + break; + + case 5: + _entry5 = value; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public override bool Remove(T value) + { + // If the item matches an existing entry, wipe out the last + // entry and move all the other entries up. Because we only + // have six entries we can just unravel all the cases. + if (_entry0.Equals(value)) + { + RemoveAt(0); + return true; + } + else if (_count > 1) + { + if (_entry1.Equals(value)) + { + RemoveAt(1); + return true; + } + else if (_count > 2) + { + if (_entry2.Equals(value)) + { + RemoveAt(2); + return true; + } + else if (_count > 3) + { + if (_entry3.Equals(value)) + { + RemoveAt(3); + return true; + } + else if (_count > 4) + { + if (_entry4.Equals(value)) + { + RemoveAt(4); + return true; + } + else if (6 == _count && _entry5.Equals(value)) + { + RemoveAt(5); + return true; + } + } + } + } + } + + return false; + } + + public override void RemoveAt(int index) + { + // Remove entry at index, wipe out the last entry and move + // all the other entries up. Because we only have six + // entries we can just unravel all the cases. + switch (index) + { + case 0: + _entry0 = _entry1; + _entry1 = _entry2; + _entry2 = _entry3; + _entry3 = _entry4; + _entry4 = _entry5; + break; + + case 1: + _entry1 = _entry2; + _entry2 = _entry3; + _entry3 = _entry4; + _entry4 = _entry5; + break; + + case 2: + _entry2 = _entry3; + _entry3 = _entry4; + _entry4 = _entry5; + break; + + case 3: + _entry3 = _entry4; + _entry4 = _entry5; + break; + + case 4: + _entry4 = _entry5; + break; + + case 5: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + _entry5 = default; + --_count; + } + + public override T EntryAt(int index) + { + switch (index) + { + case 0: + return _entry0; + + case 1: + return _entry1; + + case 2: + return _entry2; + + case 3: + return _entry3; + + case 4: + return _entry4; + + case 5: + return _entry5; + + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public override void Promote(FrugalListBase oldList) + { + var oldCount = oldList.Count; + if (SIZE >= oldCount) + { + SetCount(oldList.Count); + + switch (oldCount) + { + case 6: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + SetAt(4, oldList.EntryAt(4)); + SetAt(5, oldList.EntryAt(5)); + break; + + case 5: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + SetAt(4, oldList.EntryAt(4)); + break; + + case 4: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + break; + + case 3: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + break; + + case 2: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + break; + + case 1: + SetAt(0, oldList.EntryAt(0)); + break; + + case 0: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(oldList)); + } + } + else + { + // this list is smaller than oldList + throw new ArgumentException($"Cannot promote from '{oldList}' to '{ToString()}' because the target map is too small.", nameof(oldList)); + } + } + + // Class specific implementation to avoid virtual method calls and additional logic + public void Promote(ThreeItemList oldList) + { + var oldCount = oldList.Count; + + if (oldCount <= SIZE) + { + SetCount(oldList.Count); + + switch (oldCount) + { + case 3: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + break; + + case 2: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + break; + + case 1: + SetAt(0, oldList.EntryAt(0)); + break; + + case 0: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(oldList)); + } + } + else + { + // this list is smaller than oldList + throw new ArgumentException($"Cannot promote from '{oldList}' to '{ToString()}' because the target map is too small.", nameof(oldList)); + } + } + + // Class specific implementation to avoid virtual method calls and additional logic + public void Promote(SixItemList oldList) + { + var oldCount = oldList.Count; + + SetCount(oldList.Count); + + switch (oldCount) + { + case 6: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + SetAt(4, oldList.EntryAt(4)); + SetAt(5, oldList.EntryAt(5)); + break; + + case 5: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + SetAt(4, oldList.EntryAt(4)); + break; + + case 4: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + break; + + case 3: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + break; + + case 2: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + break; + + case 1: + SetAt(0, oldList.EntryAt(0)); + break; + + case 0: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(oldList)); + } + } + + public override T[] ToArray() + { + var array = new T[_count]; + + if (_count >= 1) + { + array[0] = _entry0; + if (_count >= 2) + { + array[1] = _entry1; + if (_count >= 3) + { + array[2] = _entry2; + if (_count >= 4) + { + array[3] = _entry3; + if (_count >= 5) + { + array[4] = _entry4; + if (_count == 6) + { + array[5] = _entry5; + } + } + } + } + } + } + return array; + } + + public override void CopyTo(T[] array, int index) + { + if (_count >= 1) + { + array[index] = _entry0; + if (_count >= 2) + { + array[index + 1] = _entry1; + if (_count >= 3) + { + array[index + 2] = _entry2; + if (_count >= 4) + { + array[index + 3] = _entry3; + if (_count >= 5) + { + array[index + 4] = _entry4; + if (_count == 6) + { + array[index + 5] = _entry5; + } + } + } + } + } + } + } + + public override object Clone() + { + var newList = new SixItemList(); + + newList.Promote(this); + + return newList; + } + + private void SetCount(int value) + { + if (value >= 0 && value <= SIZE) + { + _count = value; + } + else + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + } + } + + /// + /// A simple class to handle an array of 7 or more items. It is unsorted and uses + /// a linear search. + /// + internal sealed class ArrayItemList : FrugalListBase + { + // MINSIZE and GROWTH chosen to minimize memory footprint + private const int MINSIZE = 9; + private const int GROWTH = 3; + private const int LARGEGROWTH = 18; + + private T[] _entries; + + public ArrayItemList() + { + } + + public ArrayItemList(int size) + { + // Make size a multiple of GROWTH + size += GROWTH - 1; + + size -= size % GROWTH; + + _entries = new T[size]; + } + + public ArrayItemList(ICollection collection) + { + if (collection == null) + { + return; + } + + _count = collection.Count; + + _entries = new T[_count]; + + collection.CopyTo(_entries, 0); + } + + public ArrayItemList(ICollection collection) + { + if (collection == null) + { + return; + } + + _count = collection.Count; + + _entries = new T[_count]; + + collection.CopyTo(_entries, 0); + } + + // Capacity of this store + public override int Capacity + { + get + { + return _entries?.Length ?? 0; + } + } + + public override FrugalListStoreState Add(T value) + { + // If we don't have any entries or the existing entry is being overwritten, + // then we can use this store. Otherwise we have to promote. + if (null != _entries && _count < _entries.Length) + { + _entries[_count] = value; + + ++_count; + } + else + { + if (null != _entries) + { + var size = _entries.Length; + + // Grow the list slowly while it is small but + // faster once it reaches the LARGEGROWTH size + if (size < LARGEGROWTH) + { + size += GROWTH; + } + else + { + size += size >> 2; + } + + var destEntries = new T[size]; + + // Copy old array + Array.Copy(_entries, 0, destEntries, 0, _entries.Length); + + _entries = destEntries; + } + else + { + _entries = new T[MINSIZE]; + } + + // Insert into new array + _entries[_count] = value; + + ++_count; + } + + return FrugalListStoreState.Success; + } + + public override void Clear() + { + // Wipe out the info. + for (var i = 0; i < _count; ++i) + { + _entries[i] = default; + } + + _count = 0; + } + + public override bool Contains(T value) + { + return -1 != IndexOf(value); + } + + public override int IndexOf(T value) + { + for (var index = 0; index < _count; ++index) + { + if (_entries[index].Equals(value)) + { + return index; + } + } + + return -1; + } + + public override void Insert(int index, T value) + { + if (null != _entries && _count < _entries.Length) + { + // Move down the required number of items + Array.Copy(_entries, index, _entries, index + 1, _count - index); + + // Put in the new item at the specified index + _entries[index] = value; + + ++_count; + + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public override void SetAt(int index, T value) + { + // Overwrite item at index + _entries[index] = value; + } + + public override bool Remove(T value) + { + for (var index = 0; index < _count; ++index) + { + if (_entries[index].Equals(value)) + { + RemoveAt(index); + return true; + } + } + + return false; + } + + public override void RemoveAt(int index) + { + // Shift entries down + var numToCopy = _count - index - 1; + + if (numToCopy > 0) + { + Array.Copy(_entries, index + 1, _entries, index, numToCopy); + } + + // Wipe out the last entry + _entries[_count - 1] = default; + + --_count; + } + + public override T EntryAt(int index) + { + return _entries[index]; + } + + public override void Promote(FrugalListBase oldList) + { + for (var index = 0; index < oldList.Count; ++index) + { + if (FrugalListStoreState.Success == Add(oldList.EntryAt(index))) + { + continue; + } + + // this list is smaller than oldList + throw new ArgumentException($"Cannot promote from '{oldList}' to '{ToString()}' because the target map is too small.", nameof(oldList)); + } + } + + // Class specific implementation to avoid virtual method calls and additional logic + public void Promote(SixItemList oldList) + { + var oldCount = oldList.Count; + + SetCount(oldList.Count); + + switch (oldCount) + { + case 6: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + SetAt(4, oldList.EntryAt(4)); + SetAt(5, oldList.EntryAt(5)); + break; + + case 5: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + SetAt(4, oldList.EntryAt(4)); + break; + + case 4: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + SetAt(3, oldList.EntryAt(3)); + break; + + case 3: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + SetAt(2, oldList.EntryAt(2)); + break; + + case 2: + SetAt(0, oldList.EntryAt(0)); + SetAt(1, oldList.EntryAt(1)); + break; + + case 1: + SetAt(0, oldList.EntryAt(0)); + break; + + case 0: + break; + + default: + throw new ArgumentOutOfRangeException(nameof(oldList)); + } + } + + // Class specific implementation to avoid virtual method calls and additional logic + public void Promote(ArrayItemList oldList) + { + var oldCount = oldList.Count; + + if (_entries.Length >= oldCount) + { + SetCount(oldList.Count); + + for (var index = 0; index < oldCount; ++index) + { + SetAt(index, oldList.EntryAt(index)); + } + } + else + { + // this list is smaller than oldList + throw new ArgumentException($"Cannot promote from '{oldList}' to '{ToString()}' because the target map is too small.", nameof(oldList)); + } + } + + public override T[] ToArray() + { + var array = new T[_count]; + + for (var i = 0; i < _count; ++i) + { + array[i] = _entries[i]; + } + + return array; + } + + public override void CopyTo(T[] array, int index) + { + for (var i = 0; i < _count; ++i) + { + array[index + i] = _entries[i]; + } + } + + public override object Clone() + { + var newList = new ArrayItemList(Capacity); + + newList.Promote(this); + + return newList; + } + + private void SetCount(int value) + { + if (value >= 0 && value <= _entries.Length) + { + _count = value; + } + else + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + } + + public override Compacter NewCompacter(int newCount) + { + return new ArrayCompacter(this, newCount); + } + + // array-based implementation - compacts in-place or into a new array + internal class ArrayCompacter : Compacter + { + private readonly ArrayItemList _targetStore; + private readonly T[] _sourceArray; + private readonly T[] _targetArray; + + public ArrayCompacter(ArrayItemList store, int newCount) + : base(store, newCount) + { + _sourceArray = store._entries; + + // compute capacity for target array + // the first term agrees with AIL.Add, which grows by 5/4 + var newCapacity = Math.Max(newCount + (newCount >> 2), MINSIZE); + + if (newCapacity + (newCapacity >> 2) >= _sourceArray.Length) + { + // if there's not much space to be reclaimed, compact in place + _targetStore = store; + } + else + { + // otherwise, compact into a smaller array + _targetStore = new ArrayItemList(newCapacity); + } + + _targetArray = _targetStore._entries; + } + + protected override void IncludeOverride(int start, int end) + { + // bulk move + var size = end - start; + Array.Copy(_sourceArray, start, _targetArray, _validItemCount, size); + _validItemCount += size; + + /* The following code is necessary in the general case, to avoid + * aliased entries in the old array. But the only user of Compacter + * is DependentList, where aliased entries are not a problem - they'll + * just get GC'd along with the old array. + + // when not compacting in place, clear out entries in the source + if (_targetArray != _sourceArray) + { + T filler = default(T); + for (int i=_previousEnd; i Finish() + { + // clear out vacated entries in the source + var filler = default(T); + if (_sourceArray == _targetArray) + { + // in-place array source + for (int i = _validItemCount, n = _store.Count; i < n; ++i) + { + _sourceArray[i] = filler; + } + } + else + { + // array source to new array target + /* this code is not needed - see remarks in IncludeOverride() + for (int i=_previousEnd, n=_store._count; i + { + internal FrugalListBase _listStore; + + public FrugalObjectList() + { + } + + public FrugalObjectList(int size) + { + Capacity = size; + } + + public int Capacity + { + get + { + return _listStore?.Capacity ?? 0; + } + set + { + var capacity = 0; + + if (null != _listStore) + { + capacity = _listStore.Capacity; + } + + if (capacity < value) + { + // Need to move to a more complex storage + FrugalListBase newStore; + + if (value == 1) + { + newStore = new SingleItemList(); + } + else if (value <= 3) + { + newStore = new ThreeItemList(); + } + else if (value <= 6) + { + newStore = new SixItemList(); + } + else + { + newStore = new ArrayItemList(value); + } + + if (null != _listStore) + { + // Move entries in the old store to the new one + newStore.Promote(_listStore); + } + + _listStore = newStore; + } + } + } + + public int Count + { + get + { + return _listStore?.Count ?? 0; + } + } + + + public T this[int index] + { + get + { + // If no entry, default(T) is returned + if (null != _listStore && index < _listStore.Count && index >= 0) + { + return _listStore.EntryAt(index); + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + set + { + // Ensure write success + if (null != _listStore && index < _listStore.Count && index >= 0) + { + _listStore.SetAt(index, value); + + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public int Add(T value) + { + if (null != _listStore) + { + // This is done because forward branches + // default prediction is not to be taken + // making this a CPU win because Add is + // a common operation. + } + else + { + _listStore = new SingleItemList(); + } + + var myState = _listStore.Add(value); + + switch (myState) { + case FrugalListStoreState.Success: + break; + // Need to move to a more complex storage + // Allocate the store, promote, and add using the derived classes + // to avoid virtual method calls + case FrugalListStoreState.ThreeItemList: + { + var newStore = new ThreeItemList(); + + // Extract the values from the old store and insert them into the new store + newStore.Promote(_listStore); + + // Insert the new item + newStore.Add(value); + + _listStore = newStore; + break; + } + case FrugalListStoreState.SixItemList: + { + var newStore = new SixItemList(); + + // Extract the values from the old store and insert them into the new store + newStore.Promote(_listStore); + + _listStore = newStore; + + // Insert the new item + newStore.Add(value); + + _listStore = newStore; + break; + } + case FrugalListStoreState.Array: + { + var newStore = new ArrayItemList(_listStore.Count + 1); + + // Extract the values from the old store and insert them into the new store + newStore.Promote(_listStore); + + _listStore = newStore; + + // Insert the new item + newStore.Add(value); + + _listStore = newStore; + break; + } + default: + throw new InvalidOperationException("Cannot promote from Array."); + } + + return _listStore.Count - 1; + } + + public void Clear() + { + _listStore?.Clear(); + } + + public bool Contains(T value) + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.Contains(value); + } + + return false; + } + + public int IndexOf(T value) + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.IndexOf(value); + } + + return -1; + } + + public void Insert(int index, T value) + { + if (index == 0 || _listStore != null && index <= _listStore.Count && index >= 0) + { + // Make sure we have a place to put the item + var minCapacity = 1; + + if (null != _listStore && _listStore.Count == _listStore.Capacity) + { + // Store is full + minCapacity = Capacity + 1; + } + + // Make the Capacity at *least* this big + Capacity = minCapacity; + + _listStore?.Insert(index, value); + + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public bool Remove(T value) + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.Remove(value); + } + + return false; + } + + public void RemoveAt(int index) + { + if (null != _listStore && index < _listStore.Count && index >= 0) + { + _listStore.RemoveAt(index); + + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public void EnsureIndex(int index) + { + if (index >= 0) + { + var delta = index + 1 - Count; + if (delta > 0) + { + // Grow the store + Capacity = index + 1; + + var filler = default(T); + + // Insert filler structs or objects + for (var i = 0; i < delta; ++i) + { + _listStore.Add(filler); + } + } + + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public T[] ToArray() + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.ToArray(); + } + + return null; + } + + public void CopyTo(T[] array, int index) + { + if (null != _listStore && _listStore.Count > 0) + { + _listStore.CopyTo(array, index); + } + } + + public FrugalObjectList Clone() + { + var myClone = new FrugalObjectList(); + + if (null != _listStore) + { + myClone._listStore = (FrugalListBase)_listStore.Clone(); + } + + return myClone; + } + + // helper class - compacts the valid entries, while removing the invalid ones. + // Usage: + // Compacter compacter = new Compacter(this, newCount); + // compacter.Include(start, end); // repeat as necessary + // compacter.Finish(); + // newCount is the expected number of valid entries - used to help choose + // a target array of appropriate capacity + // Include(start, end) moves the entries in positions start, ..., end-1 toward + // the beginning, appending to the end of the "valid" area. Successive calls + // must be monotonic - i.e. the next 'start' must be >= the previous 'end'. + // Also, the sum of the block sizes (end-start) cannot exceed newCount. + // Finish() puts the provisional target array into permanent use. + + protected class Compacter + { + private readonly FrugalObjectList _list; + private readonly FrugalListBase.Compacter _storeCompacter; + + public Compacter(FrugalObjectList list, int newCount) + { + _list = list; + + var store = _list._listStore; + + _storeCompacter = store?.NewCompacter(newCount); + } + + public void Include(int start, int end) + { + _storeCompacter.Include(start, end); + } + + public void Finish() + { + if (_storeCompacter != null) + { + _list._listStore = _storeCompacter.Finish(); + } + } + } + } + + // Use FrugalStructList when only one reference to the list is needed. + // The "struct" in FrugalStructList refers to the list itself, not what the list contains. + internal struct FrugalStructList + { + internal FrugalListBase _listStore; + + public FrugalStructList(int size) + { + _listStore = null; + Capacity = size; + } + + public FrugalStructList(ICollection collection) + { + if (collection.Count > 6) + { + _listStore = new ArrayItemList(collection); + } + else + { + _listStore = null; + + Capacity = collection.Count; + + foreach (T item in collection) + { + Add(item); + } + } + } + + public FrugalStructList(ICollection collection) + { + if (collection.Count > 6) + { + _listStore = new ArrayItemList(collection); + } + else + { + _listStore = null; + + Capacity = collection.Count; + + foreach (var item in collection) + { + Add(item); + } + } + } + + public int Capacity + { + get + { + if (null != _listStore) + { + return _listStore.Capacity; + } + + return 0; + } + set + { + var capacity = 0; + + if (null != _listStore) + { + capacity = _listStore.Capacity; + } + + if (capacity < value) + { + // Need to move to a more complex storage + FrugalListBase newStore; + + if (value == 1) + { + newStore = new SingleItemList(); + } + else if (value <= 3) + { + newStore = new ThreeItemList(); + } + else if (value <= 6) + { + newStore = new SixItemList(); + } + else + { + newStore = new ArrayItemList(value); + } + + if (null != _listStore) + { + // Move entries in the old store to the new one + newStore.Promote(_listStore); + } + + _listStore = newStore; + } + } + } + + public int Count + { + get + { + return _listStore?.Count ?? 0; + } + } + + + public T this[int index] + { + get + { + // If no entry, default(T) is returned + if (null != _listStore && index < _listStore.Count && index >= 0) + { + return _listStore.EntryAt(index); + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + set + { + // Ensure write success + if (null != _listStore && index < _listStore.Count && index >= 0) + { + _listStore.SetAt(index, value); + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public int Add(T value) + { + if (null != _listStore) + { + // This is done because forward branches + // default prediction is not to be taken + // making this a CPU win because Add is + // a common operation. + } + else + { + _listStore = new SingleItemList(); + } + + var myState = _listStore.Add(value); + switch (myState) { + case FrugalListStoreState.Success: + break; + // Need to move to a more complex storage + // Allocate the store, promote, and add using the derived classes + // to avoid virtual method calls + case FrugalListStoreState.ThreeItemList: + { + var newStore = new ThreeItemList(); + + // Extract the values from the old store and insert them into the new store + newStore.Promote(_listStore); + + // Insert the new item + newStore.Add(value); + _listStore = newStore; + break; + } + case FrugalListStoreState.SixItemList: + { + var newStore = new SixItemList(); + + // Extract the values from the old store and insert them into the new store + newStore.Promote(_listStore); + _listStore = newStore; + + // Insert the new item + newStore.Add(value); + _listStore = newStore; + break; + } + case FrugalListStoreState.Array: + { + var newStore = new ArrayItemList(_listStore.Count + 1); + + // Extract the values from the old store and insert them into the new store + newStore.Promote(_listStore); + _listStore = newStore; + + // Insert the new item + newStore.Add(value); + _listStore = newStore; + break; + } + default: + throw new InvalidOperationException("Cannot promote from Array."); + } + + return _listStore.Count - 1; + } + + public void Clear() + { + _listStore?.Clear(); + } + + public bool Contains(T value) + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.Contains(value); + } + + return false; + } + + public int IndexOf(T value) + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.IndexOf(value); + } + + return -1; + } + + public void Insert(int index, T value) + { + if (index == 0 || null != _listStore && index <= _listStore.Count && index >= 0) + { + // Make sure we have a place to put the item + var minCapacity = 1; + + if (null != _listStore && _listStore.Count == _listStore.Capacity) + { + // Store is full + minCapacity = Capacity + 1; + } + + // Make the Capacity at *least* this big + Capacity = minCapacity; + + _listStore.Insert(index, value); + + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public bool Remove(T value) + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.Remove(value); + } + + return false; + } + + public void RemoveAt(int index) + { + if (null != _listStore && index < _listStore.Count && index >= 0) + { + _listStore.RemoveAt(index); + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public void EnsureIndex(int index) + { + if (index >= 0) + { + var delta = index + 1 - Count; + if (delta > 0) + { + // Grow the store + Capacity = index + 1; + + var filler = default(T); + + // Insert filler structs or objects + for (var i = 0; i < delta; ++i) + { + _listStore.Add(filler); + } + } + return; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + + public T[] ToArray() + { + if (null != _listStore && _listStore.Count > 0) + { + return _listStore.ToArray(); + } + + return null; + } + + public void CopyTo(T[] array, int index) + { + if (null != _listStore && _listStore.Count > 0) + { + _listStore.CopyTo(array, index); + } + } + + public FrugalStructList Clone() + { + var myClone = new FrugalStructList(); + + if (null != _listStore) + { + myClone._listStore = (FrugalListBase)_listStore.Clone(); + } + + return myClone; + } + } +} + diff --git a/src/Avalonia.Visuals/Utilities/MappedArraySlice.cs b/src/Avalonia.Visuals/Utilities/MappedArraySlice.cs new file mode 100644 index 0000000000..299c607731 --- /dev/null +++ b/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 +{ + /// + /// Provides a mapped view of an underlying slice, selecting arbitrary indices + /// from the source array. + /// + /// The type of item contained in the underlying array. + internal readonly struct MappedArraySlice + where T : struct + { + private readonly ArraySlice _data; + private readonly ArraySlice _map; + + /// + /// Initializes a new instance of the struct. + /// + /// The data slice. + /// The map slice. + public MappedArraySlice(in ArraySlice data, in ArraySlice map) + { +#if DEBUG + if (map.Length.CompareTo(data.Length) > 0) + { + throw new ArgumentOutOfRangeException(nameof(map)); + } +#endif + + _data = data; + _map = map; + } + + /// + /// Gets the number of items in the map. + /// + public int Length => _map.Length; + + /// + /// Returns a reference to specified element of the slice. + /// + /// The index of the element to return. + /// The . + /// + /// Thrown when index less than 0 or index greater than or equal to . + /// + public ref T this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => ref _data[_map[index]]; + } + } +} diff --git a/src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs b/src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs index 5feaa88e26..ee85d1e876 100644 --- a/src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs +++ b/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 /// /// The type of elements in the slice. [DebuggerTypeProxy(typeof(ReadOnlySlice<>.ReadOnlySliceDebugView))] - public readonly struct ReadOnlySlice : IReadOnlyList + public readonly struct ReadOnlySlice : IReadOnlyList where T : struct { + private readonly int _offset; + + /// + /// Gets an empty + /// + public static ReadOnlySlice Empty => new ReadOnlySlice(Array.Empty()); + + private readonly ReadOnlyMemory _buffer; + public ReadOnlySlice(ReadOnlyMemory buffer) : this(buffer, 0, buffer.Length) { } - public ReadOnlySlice(ReadOnlyMemory buffer, int start, int length) + public ReadOnlySlice(ReadOnlyMemory 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; } /// @@ -51,12 +74,38 @@ namespace Avalonia.Utilities public bool IsEmpty => Length == 0; /// - /// The buffer. + /// The underlying span. /// - public ReadOnlyMemory Buffer { get; } + public ReadOnlySpan Span => _buffer.Span.Slice(_offset, Length); - public T this[int index] => Buffer.Span[index]; + /// + /// The underlying buffer. + /// + public ReadOnlyMemory Buffer => _buffer; + /// + /// Returns a value to specified element of the slice. + /// + /// The index of the element to return. + /// The . + /// + /// Thrown when index less than 0 or index greater than or equal to . + /// + 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]; + } + } + /// /// Returns a sub slice of elements that start at the specified index and has the specified number of elements. /// @@ -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(Buffer.Slice(bufferOffset), start, length); + return new ReadOnlySlice(_buffer, start, length, _offset); } /// @@ -102,7 +154,7 @@ namespace Avalonia.Utilities throw new ArgumentOutOfRangeException(nameof(length)); } - return new ReadOnlySlice(Buffer.Slice(0, length), Start, length); + return new ReadOnlySlice(_buffer, Start, length, _offset); } /// @@ -122,7 +174,7 @@ namespace Avalonia.Utilities throw new ArgumentOutOfRangeException(nameof(length)); } - return new ReadOnlySlice(Buffer.Slice(length), Start + length, Length - length); + return new ReadOnlySlice(_buffer, Start + length, Length - length, _offset + length); } /// @@ -174,7 +226,7 @@ namespace Avalonia.Utilities public bool IsEmpty => _readOnlySlice.IsEmpty; - public ReadOnlyMemory Items => _readOnlySlice.Buffer; + public ReadOnlySpan Items => _readOnlySlice.Span; } } } diff --git a/src/Avalonia.Visuals/Utilities/Span.cs b/src/Avalonia.Visuals/Utilities/Span.cs new file mode 100644 index 0000000000..7eb9652d9f --- /dev/null +++ b/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; + } + } + + /// + /// VECTOR: A series of spans + /// + internal class SpanVector : IEnumerable + { + private static readonly Equals s_referenceEquals = object.ReferenceEquals; + private static readonly Equals s_equals = object.Equals; + + private FrugalStructList _spans; + + internal SpanVector( + object? defaultObject, + FrugalStructList spans = new FrugalStructList()) + { + Default = defaultObject; + _spans = spans; + } + + + /// + /// Get enumerator to vector + /// + public IEnumerator GetEnumerator() + { + return new SpanEnumerator(this); + } + + /// + /// Add a new span to vector + /// + private void Add(Span span) + { + _spans.Add(span); + } + + /// + /// Delete n elements of vector + /// + 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); + } + } + + /// + /// Insert n elements to vector + /// + private void Insert(int index, int count) + { + for (var c = 0; c < count; c++) + _spans.Insert(index, new Span(null, 0)); + } + + /// + /// Finds the span that contains the specified character position. + /// + /// position to find + /// 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. + /// 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. + /// Returns true if cp is in range or false if not. + 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; + } + + + /// + /// Set an element as a value to a character range + /// + /// + /// Implementation of span element object must implement Object.Equals to + /// avoid runtime reflection cost on equality check of nested-type object. + /// + public void SetValue(int first, int length, object element) + { + Set(first, length, element, SpanVector.s_equals, new SpanPosition()); + } + + /// + /// 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 + /// + public SpanPosition SetValue(int first, int length, object element, SpanPosition spanPosition) + { + return Set(first, length, element, SpanVector.s_equals, spanPosition); + } + + /// + /// Set an element as a reference to a character range + /// + public void SetReference(int first, int length, object element) + { + Set(first, length, element, SpanVector.s_referenceEquals, new SpanPosition()); + } + + /// + /// 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 + /// + 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); + } + + /// + /// Number of spans in vector + /// + public int Count + { + get { return _spans.Count; } + } + + /// + /// The default element of vector + /// + public object? Default { get; } + + /// + /// Span accessor at nth element + /// + 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; + } + } + + /// + /// Equality check method + /// + internal delegate bool Equals(object? first, object? second); + + /// + /// ENUMERATOR: To navigate a vector through its element + /// + internal sealed class SpanEnumerator : IEnumerator + { + private readonly SpanVector _spans; + private int _current; // current span + + internal SpanEnumerator(SpanVector spans) + { + _spans = spans; + _current = -1; + } + + /// + /// The current span + /// + public object Current + { + get { return _spans[_current]; } + } + + /// + /// Move to the next span + /// + public bool MoveNext() + { + _current++; + + return _current < _spans.Count; + } + + /// + /// Reset the enumerator + /// + public void Reset() + { + _current = -1; + } + } + + + /// + /// 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. + /// + internal readonly struct SpanPosition + { + internal SpanPosition(int spanIndex, int spanOffset) + { + Index = spanIndex; + Offset = spanOffset; + } + + internal int Index { get; } + + internal int Offset { get; } + } + + /// + /// RIDER: To navigate a vector through character index + /// + 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); + } + + /// + /// Move rider to a given cp + /// + 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; + } + + /// + /// The first cp of the current span + /// + public int CurrentSpanStart + { + get { return _spanPosition.Offset; } + } + + /// + /// The length of current span start from the current cp + /// + public int Length { get; private set; } + + /// + /// The current position + /// + public int CurrentPosition { get; private set; } + + /// + /// The element of the current span + /// + public object? CurrentElement + { + get { return _spanPosition.Index >= _spans.Count ? _spans.Default : _spans[_spanPosition.Index].element; } + } + + /// + /// Index of the span at the current position. + /// + public int CurrentSpanIndex + { + get { return _spanPosition.Index; } + } + + /// + /// Index and first cp of the current span. + /// + public SpanPosition SpanPosition + { + get { return _spanPosition; } + } + } +} diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 2694a61353..e695a9cb41 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -441,17 +441,7 @@ namespace Avalonia.Skia } } } - - /// - 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); - } - } - + /// public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun) { diff --git a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs deleted file mode 100644 index 625d1c6f52..0000000000 --- a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs +++ /dev/null @@ -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 -{ - /// - /// Skia formatted text implementation. - /// - internal class FormattedTextImpl : IFormattedTextImpl - { - private static readonly ThreadLocal t_builder = new ThreadLocal(() => new SKTextBlobBuilder()); - - private const float MAX_LINE_WIDTH = 10000; - - private readonly List> _foregroundBrushes = - new List>(); - private readonly List _lines = new List(); - private readonly SKPaint _paint; - private readonly List _rects = new List(); - 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 _skiaLines; - private ReadOnlySlice _glyphs; - private ReadOnlySlice _advances; - - public FormattedTextImpl( - string text, - Typeface typeface, - double fontSize, - TextAlignment textAlignment, - TextWrapping wrapping, - Size constraint, - IReadOnlyList 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 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 HitTestTextRange(int index, int length) - { - List result = new List(); - - 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 GetRects() - { - if (Text.Length > _rects.Count) - { - BuildRects(); - } - - return _rects; - } - - private void Rebuild() - { - var length = Text.Length; - - _lines.Clear(); - _rects.Clear(); - _skiaLines = new List(); - - 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(glyphs); - _advances = new ReadOnlySlice(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(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}"; - } - } - } -} diff --git a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs index c3ac5e1774..af3b570fd7 100644 --- a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs +++ b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs @@ -37,19 +37,6 @@ namespace Avalonia.Skia _skiaGpu = new GlSkiaGpu(gl, maxResourceBytes); } - /// - public IFormattedTextImpl CreateFormattedText( - string text, - Typeface typeface, - double fontSize, - TextAlignment textAlignment, - TextWrapping wrapping, - Size constraint, - IReadOnlyList 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 /// 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(); } diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index 5cf72e2ce8..c4d11f4613 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/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 text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture) + public ShapedBuffer ShapeText(ReadOnlySlice 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(glyphIndices), - new ReadOnlySlice(glyphAdvances), - new ReadOnlySlice(glyphOffsets), - text, - new ReadOnlySlice(clusters), - buffer.Direction == Direction.LeftToRight ? 0 : 1); + return shapedBuffer; } } - private static void FillBuffer(Buffer buffer, ReadOnlySlice 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 glyphPositions, int index, double textScale, - ref Vector[] offsetBuffer) + private static Vector GetGlyphOffset(ReadOnlySpan 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 glyphPositions, int index, double textScale, - ref double[] advanceBuffer) + private static double GetGlyphAdvance(ReadOnlySpan 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; } } } diff --git a/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs b/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs index eef4416101..c32c58605f 100644 --- a/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs +++ b/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 spans) - { - return new FormattedTextImpl( - text, - typeface, - fontSize, - textAlignment, - wrapping, - constraint, - spans); - } - public IRenderTarget CreateRenderTarget(IEnumerable 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); } diff --git a/src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs index 470157110c..b62a6fa5a6 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs @@ -376,29 +376,6 @@ namespace Avalonia.Direct2D1.Media } } - /// - /// Draws text. - /// - /// The foreground brush. - /// The upper-left corner of the text. - /// The text. - 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); - } - } - } - } - /// /// Draws a glyph run. /// diff --git a/src/Windows/Avalonia.Direct2D1/Media/FormattedTextImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/FormattedTextImpl.cs deleted file mode 100644 index c59067d82d..0000000000 --- a/src/Windows/Avalonia.Direct2D1/Media/FormattedTextImpl.cs +++ /dev/null @@ -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 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 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 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); - } - } -} diff --git a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs index 20b09a9aac..62cf031f86 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs +++ b/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 text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture) + public ShapedBuffer ShapeText(ReadOnlySlice 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(glyphIndices), - new ReadOnlySlice(glyphAdvances), - new ReadOnlySlice(glyphOffsets), - text, - new ReadOnlySlice(clusters)); + return shapedBuffer; } } - private static void FillBuffer(Buffer buffer, ReadOnlySlice 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 glyphPositions, int index, double textScale, - ref Vector[] offsetBuffer) + private static Vector GetGlyphOffset(ReadOnlySpan 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 glyphPositions, int index, double textScale, - ref double[] advanceBuffer) + private static double GetGlyphAdvance(ReadOnlySpan 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; } } } diff --git a/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs b/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs index 549f450ece..59067e642f 100644 --- a/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs +++ b/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) { } diff --git a/tests/Avalonia.Benchmarks/NullFormattedTextImpl.cs b/tests/Avalonia.Benchmarks/NullFormattedTextImpl.cs deleted file mode 100644 index f886d077cc..0000000000 --- a/tests/Avalonia.Benchmarks/NullFormattedTextImpl.cs +++ /dev/null @@ -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 GetLines() - { - throw new NotImplementedException(); - } - - public TextHitTestResult HitTestPoint(Point point) - { - throw new NotImplementedException(); - } - - public Rect HitTestTextPosition(int index) - { - throw new NotImplementedException(); - } - - public IEnumerable HitTestTextRange(int index, int length) - { - throw new NotImplementedException(); - } - } -} diff --git a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs b/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs index 3e11c74e1c..ccdb0eed2f 100644 --- a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs +++ b/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 spans) - { - return new NullFormattedTextImpl(); - } - public IGeometryImpl CreateEllipseGeometry(Rect rect) { return new MockStreamGeometryImpl(); diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj index 8dd8e843ac..4aed8c60c3 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs index 1a251a5cef..7115c9c144 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/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()); private static TestServices Services => TestServices.MockThreadingInterface.With( - standardCursorFactory: Mock.Of()); + standardCursorFactory: Mock.Of(), + 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)); } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs index d49ee35901..8cc8e4c16f 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs +++ b/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); } } } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 23cae8fd0d..e9b00f9bb1 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/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().ToSingleton(); @@ -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(), textShaperImpl: new MockTextShaperImpl(), - standardCursorFactory: Mock.Of()); + fontManagerImpl: new MockFontManagerImpl()); private static TestServices Services => TestServices.MockThreadingInterface.With( - standardCursorFactory: Mock.Of()); + standardCursorFactory: Mock.Of(), + 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)); } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs index 570b9ee4ea..48cc365029 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs +++ b/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()); + standardCursorFactory: Mock.Of(), + textShaperImpl: new MockTextShaperImpl(), + fontManagerImpl: new MockFontManagerImpl()); private IControlTemplate CreateTemplate() { diff --git a/tests/Avalonia.RenderTests/Media/FormattedTextImplTests.cs b/tests/Avalonia.RenderTests/Media/FormattedTextImplTests.cs deleted file mode 100644 index 7528424521..0000000000 --- a/tests/Avalonia.RenderTests/Media/FormattedTextImplTests.cs +++ /dev/null @@ -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(); - 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); - } - } - } -} diff --git a/tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs index b1a2d00b00..981ae0d0a4 100644 --- a/tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs +++ b/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. diff --git a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs index f9c45e7d22..904f0935c4 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs +++ b/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 BuildRects(GlyphRun glyphRun) + { + var height = glyphRun.Size.Height; + + var currentX = glyphRun.IsLeftToRight ? 0d : glyphRun.Metrics.WidthIncludingTrailingWhitespace; + + var rects = new List(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 diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index a19f97e74e..10a05f98d1 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/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() - .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 ‑‑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().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(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().ToList(); + + var actualGlyphs = shapedRuns.SelectMany(x => x.GlyphRun.GlyphIndices).ToList(); + + Assert.Equal(expectedGlyphs, actualGlyphs); + } + } + } + } + public static IDisposable Start() { var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index afa1fbf461..ad9dc65411 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/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(0, i, - new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground)) - }; + new ValueSpan(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().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(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().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()) + .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().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 diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 5961806c5c..8cb010f42b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/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(); + + 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(); + + 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().SelectMany(x => x.GlyphRun.GlyphClusters) + var clusters = textLine.TextRuns.Cast().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().SelectMany(x => x.GlyphRun.GlyphClusters) + var clusters = textLine.TextRuns.Cast().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().Any(x => !x.ShapedBuffer.IsLeftToRight); + } + + private static List BuildGlyphClusters(TextLine textLine) + { + var glyphClusters = new List(); + + var shapedTextRuns = textLine.TextRuns.Cast().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 BuildRects(TextLine textLine) { - using (Start()) - { - var defaultTextRunProperties = - new GenericTextRunProperties(Typeface.Default); + var rects = new List(); + 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().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() diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs index 62d2c54ffe..57676ad581 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs +++ b/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]); } } diff --git a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj index f5e502bca8..850db7efc6 100644 --- a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj +++ b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj @@ -8,6 +8,7 @@ latest ..\..\build\avalonia.snk false + true diff --git a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs index 687fddd71a..8ad3284490 100644 --- a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs +++ b/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 text, Typeface typeface, double fontRenderingEmSize, - CultureInfo culture) + public ShapedBuffer ShapeText(ReadOnlySlice 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(glyphIndices), - new ReadOnlySlice(glyphAdvances), - new ReadOnlySlice(glyphOffsets), - text, - new ReadOnlySlice(clusters), - buffer.Direction == Direction.LeftToRight ? 0 : 1); + return shapedBuffer; } } - private static void FillBuffer(Buffer buffer, ReadOnlySlice 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 glyphPositions, int index, double textScale, - ref Vector[] offsetBuffer) + private static Vector GetGlyphOffset(ReadOnlySpan 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 glyphPositions, int index, double textScale, - ref double[] advanceBuffer) + private static double GetGlyphAdvance(ReadOnlySpan 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; } } } diff --git a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs b/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs index 1f632034be..2858a9f228 100644 --- a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs +++ b/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 spans) - { - return Mock.Of(); - } - public IGeometryImpl CreateEllipseGeometry(Rect rect) { return Mock.Of(); diff --git a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs b/tests/Avalonia.UnitTests/MockTextShaperImpl.cs index fe1c34385f..3018c07819 100644 --- a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs +++ b/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 text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture) + public ShapedBuffer ShapeText(ReadOnlySlice 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(glyphIndices.AsMemory(0, glyphCount)), characters: text); + return shapedBuffer; } } } diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index da678fd74b..6bf91ef81d 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -183,14 +183,6 @@ namespace Avalonia.UnitTests private static IPlatformRenderInterface CreateRenderInterfaceMock() { return Mock.Of(x => - x.CreateFormattedText( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()) == Mock.Of() && x.CreateStreamGeometry() == Mock.Of( y => y.Open() == Mock.Of())); } diff --git a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs index f52bdc39c8..ba334fdb74 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs +++ b/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().ToSingleton(); } - [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(new char[count], start, count); + var characters = new ReadOnlySlice(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); } } } diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs new file mode 100644 index 0000000000..f8a2abc716 --- /dev/null +++ b/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 resultLevels; + + bidi.Process( + testData.Classes, + ArraySlice.Empty, + ArraySlice.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; + } + } +} diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTestDataGenerator.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTestDataGenerator.cs new file mode 100644 index 0000000000..2f1064be21 --- /dev/null +++ b/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 + { + private readonly List _testData; + + public BiDiClassTestDataGenerator() + { + _testData = ReadData(); + } + + public IEnumerator GetEnumerator() + { + return _testData.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private static List ReadData() + { + var testData = new List(); + + 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; } + } +} diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiClassTests.cs new file mode 100644 index 0000000000..1ed33e6132 --- /dev/null +++ b/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(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; + } + } +} diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs new file mode 100644 index 0000000000..f90fe10ef9 --- /dev/null +++ b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs @@ -0,0 +1,7 @@ +namespace Avalonia.Visuals.UnitTests.Media.TextFormatting +{ + public class BiDiPairedBracketTypeTests + { + + } +} diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiTestDataGenerator.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiTestDataGenerator.cs new file mode 100644 index 0000000000..4102de10ec --- /dev/null +++ b/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 + { + private readonly List _testData; + + public BiDiTestDataGenerator() + { + _testData = ReadTestData(); + } + + public IEnumerator GetEnumerator() + { + return _testData.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private static List ReadTestData() + { + var testData = new List(); + + 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; } + } +} diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGenerator.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGenerator.cs index cbe8edefb6..da9e494405 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGenerator.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGenerator.cs @@ -4,6 +4,7 @@ using System.IO; using System.Net.Http; using System.Text.RegularExpressions; using Avalonia.Media.TextFormatting.Unicode; +using Xunit; namespace Avalonia.Visuals.UnitTests.Media.TextFormatting { @@ -11,93 +12,145 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting { public const string Ucd = "https://www.unicode.org/Public/13.0.0/ucd/"; - public static void Execute() + public static UnicodeTrie GenerateBiDiTrie(out BiDiDataEntries biDiDataEntries,out Dictionary biDiData) { - var codepoints = new Dictionary(); + biDiData = new Dictionary(); - var generalCategoryEntries = - UnicodeEnumsGenerator.CreateGeneralCategoryEnum(); + var biDiClassEntries = + UnicodeEnumsGenerator.CreateBiDiClassEnum(); - var generalCategoryMappings = CreateTagToIndexMappings(generalCategoryEntries); + var biDiClassMappings = CreateTagToIndexMappings(biDiClassEntries); - var generalCategoryData = ReadGeneralCategoryData(); + var biDiClassData = ReadBiDiData(); - foreach (var (range, name) in generalCategoryData) + foreach (var (range, name) in biDiClassData) { - var generalCategory = generalCategoryMappings[name]; + var biDiClass = biDiClassMappings[name]; - AddGeneralCategoryRange(codepoints, range, generalCategory); + AddBiDiClassRange(biDiData, range, biDiClass); } - var scriptEntries = UnicodeEnumsGenerator.CreateScriptEnum(); + var biDiPairedBracketTypeEntries = UnicodeEnumsGenerator.CreateBiDiPairedBracketTypeEnum(); - var scriptMappings = CreateNameToIndexMappings(scriptEntries); + var biDiPairedBracketTypeMappings = CreateTagToIndexMappings(biDiPairedBracketTypeEntries); - var scriptData = ReadScriptData(); + var biDiPairedBracketData = ReadBiDiPairedBracketData(); + + foreach (var (range, name) in biDiPairedBracketData) + { + var bracketType = biDiPairedBracketTypeMappings[name]; - foreach (var (range, name) in scriptData) + AddBiDiBracket(biDiData, range, bracketType); + } + + var biDiTrieBuilder = new UnicodeTrieBuilder(/*initialValue*/); + + foreach (var properties in biDiData.Values) { - var script = scriptMappings[name]; + //[bracket]|[bracketType]|[biDiClass] + var value = (properties.BiDiClass << UnicodeData.BIDICLASS_SHIFT) | + (properties.BracketType << UnicodeData.BIDIPAIREDBRACKEDTYPE_SHIFT) | properties.Bracket; - AddScriptRange(codepoints, range, script); + biDiTrieBuilder.Set(properties.Codepoint, (uint)value); } - var biDiClassEntries = - UnicodeEnumsGenerator.CreateBiDiClassEnum(); + biDiDataEntries = new BiDiDataEntries() + { + PairedBracketTypes = biDiPairedBracketTypeEntries, BiDiClasses = biDiClassEntries + }; + + using (var stream = File.Create("Generated\\BiDi.trie")) + { + var trie = biDiTrieBuilder.Freeze(); - var biDiClassMappings = CreateTagToIndexMappings(biDiClassEntries); + trie.Save(stream); - var biDiData = ReadBiDiData(); + return trie; + } + } - foreach (var (range, name) in biDiData) - { - var biDiClass = biDiClassMappings[name]; + public static UnicodeTrie GenerateUnicodeDataTrie(out UnicodeDataEntries dataEntries, out Dictionary unicodeData) + { + var generalCategoryEntries = + UnicodeEnumsGenerator.CreateGeneralCategoryEnum(); - AddBiDiClassRange(codepoints, range, biDiClass); - } + var generalCategoryMappings = CreateTagToIndexMappings(generalCategoryEntries); + + var scriptEntries = UnicodeEnumsGenerator.CreateScriptEnum(); + var scriptMappings = CreateNameToIndexMappings(scriptEntries); + var lineBreakClassEntries = UnicodeEnumsGenerator.CreateLineBreakClassEnum(); var lineBreakClassMappings = CreateTagToIndexMappings(lineBreakClassEntries); - var lineBreakClassData = ReadLineBreakClassData(); + unicodeData = GetUnicodeData(generalCategoryMappings, scriptMappings, lineBreakClassMappings); + + var unicodeDataTrieBuilder = new UnicodeTrieBuilder(/*initialValue*/); + + foreach (var properties in unicodeData.Values) + { + //[line break]|[biDi]|[script]|[category] + var value = (properties.LineBreakClass << UnicodeData.LINEBREAK_SHIFT) | + (properties.Script << UnicodeData.SCRIPT_SHIFT) | properties.GeneralCategory; - foreach (var (range, name) in lineBreakClassData) + unicodeDataTrieBuilder.Set(properties.Codepoint, (uint)value); + } + + dataEntries = new UnicodeDataEntries { - var lineBreakClass = lineBreakClassMappings[name]; + Scripts = scriptEntries, + GeneralCategories = generalCategoryEntries, + LineBreakClasses = lineBreakClassEntries + }; - AddLineBreakClassRange(codepoints, range, lineBreakClass); + using (var stream = File.Create("Generated\\UnicodeData.trie")) + { + var trie = unicodeDataTrieBuilder.Freeze(); + + trie.Save(stream); + + return trie; } + } - //const int initialValue = (0 << UnicodeData.LINEBREAK_SHIFT) | - // (0 << UnicodeData.BIDI_SHIFT) | - // (0 << UnicodeData.SCRIPT_SHIFT) | (int)GeneralCategory.Other; + private static Dictionary GetUnicodeData(IReadOnlyDictionary generalCategoryMappings, + IReadOnlyDictionary scriptMappings, IReadOnlyDictionary lineBreakClassMappings) + { + var unicodeData = new Dictionary(); + + var generalCategoryData = ReadGeneralCategoryData(); - var builder = new UnicodeTrieBuilder(/*initialValue*/); + foreach (var (range, name) in generalCategoryData) + { + var generalCategory = generalCategoryMappings[name]; - foreach (var properties in codepoints.Values) + AddGeneralCategoryRange(unicodeData, range, generalCategory); + } + + var scriptData = ReadScriptData(); + + foreach (var (range, name) in scriptData) { - //[line break]|[biDi]|[script]|[category] - var value = (properties.LineBreakClass << UnicodeData.LINEBREAK_SHIFT) | - (properties.BiDiClass << UnicodeData.BIDI_SHIFT) | - (properties.Script << UnicodeData.SCRIPT_SHIFT) | properties.GeneralCategory; + var script = scriptMappings[name]; - builder.Set(properties.Codepoint, (uint)value); + AddScriptRange(unicodeData, range, script); } + + var lineBreakClassData = ReadLineBreakClassData(); - using (var stream = File.Create("Generated\\UnicodeData.trie")) + foreach (var (range, name) in lineBreakClassData) { - var trie = builder.Freeze(); + var lineBreakClass = lineBreakClassMappings[name]; - trie.Save(stream); + AddLineBreakClassRange(unicodeData, range, lineBreakClass); } - UnicodeEnumsGenerator.CreatePropertyValueAliasHelper(scriptEntries, generalCategoryEntries, - biDiClassEntries, lineBreakClassEntries); + return unicodeData; } - private static Dictionary CreateTagToIndexMappings(List entries) + private static Dictionary CreateTagToIndexMappings(IReadOnlyList entries) { var mappings = new Dictionary(); @@ -109,7 +162,7 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting return mappings; } - private static Dictionary CreateNameToIndexMappings(List entries) + private static Dictionary CreateNameToIndexMappings(IReadOnlyList entries) { var mappings = new Dictionary(); @@ -153,14 +206,14 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting } } - private static void AddBiDiClassRange(Dictionary codepoints, CodepointRange range, + private static void AddBiDiClassRange(Dictionary codepoints, CodepointRange range, int biDiClass) { for (var i = range.Start; i <= range.End; i++) { if (!codepoints.ContainsKey(i)) { - codepoints.Add(i, new UnicodeDataItem { Codepoint = i, BiDiClass = biDiClass }); + codepoints.Add(i, new BiDiDataItem { Codepoint = i, BiDiClass = biDiClass }); } else { @@ -169,6 +222,23 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting } } + private static void AddBiDiBracket(Dictionary codepoints, CodepointRange range, + int bracketType) + { + if (!codepoints.ContainsKey(range.Start)) + { + codepoints.Add(range.Start, + new BiDiDataItem { Codepoint = range.Start, Bracket = range.End, BracketType = bracketType }); + } + else + { + var codepoint = codepoints[range.Start]; + + codepoint.Bracket = range.End; + codepoint.BracketType = bracketType; + } + } + private static void AddLineBreakClassRange(Dictionary codepoints, CodepointRange range, int lineBreakClass) { @@ -204,12 +274,68 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting { return ReadUnicodeData("extracted/DerivedLineBreak.txt"); } + + public static List<(CodepointRange, string)> ReadBiDiPairedBracketData() + { + const string file = "BidiBrackets.txt"; + + var data = new List<(CodepointRange, string)>(); + + var regex = new Regex(@"^([0-9A-F]+);\s([0-9A-F]+);\s([ocn])"); + + using (var client = new HttpClient()) + { + var url = Path.Combine(Ucd, file); + + using (var result = client.GetAsync(url).GetAwaiter().GetResult()) + { + if (!result.IsSuccessStatusCode) + { + return data; + } + + using (var stream = result.Content.ReadAsStreamAsync().GetAwaiter().GetResult()) + using (var reader = new StreamReader(stream)) + { + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + + if (string.IsNullOrEmpty(line)) + { + continue; + } + + var match = regex.Match(line); + + if (!match.Success) + { + continue; + } + + var start = Convert.ToInt32(match.Groups[1].Value, 16); + + var end = start; + + if (!string.IsNullOrEmpty(match.Groups[2].Value)) + { + end = Convert.ToInt32(match.Groups[2].Value, 16); + } + + data.Add((new CodepointRange(start, end), match.Groups[3].Value)); + } + } + } + } + + return data; + } private static List<(CodepointRange, string)> ReadUnicodeData(string file) { var data = new List<(CodepointRange, string)>(); - var rx = new Regex(@"([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s+;\s+(\w+)\s+#.*", RegexOptions.Compiled); + var regex = new Regex(@"([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s+;\s+(\w+)\s+#.*", RegexOptions.Compiled); using (var client = new HttpClient()) { @@ -234,7 +360,7 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting continue; } - var match = rx.Match(line); + var match = regex.Match(line); if (!match.Success) { @@ -271,6 +397,32 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting public int LineBreakClass { get; set; } } + + internal class BiDiDataItem + { + public int Codepoint { get; set; } + + public int Bracket { get; set; } + + public int BracketType { get; set; } + + public int BiDiClass { get; set; } + } + + + } + + internal class UnicodeDataEntries + { + public IReadOnlyList Scripts { get; set; } + public IReadOnlyList GeneralCategories{ get; set; } + public IReadOnlyList LineBreakClasses{ get; set; } + } + + internal class BiDiDataEntries + { + public IReadOnlyList PairedBracketTypes { get; set; } + public IReadOnlyList BiDiClasses{ get; set; } } internal readonly struct CodepointRange diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGeneratorTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGeneratorTests.cs index 47aef84533..f122876322 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGeneratorTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeDataGeneratorTests.cs @@ -1,4 +1,6 @@ -using Xunit; +using System.IO; +using Avalonia.Media.TextFormatting.Unicode; +using Xunit; namespace Avalonia.Visuals.UnitTests.Media.TextFormatting { @@ -11,7 +13,43 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting [Fact(Skip = "Only run when the Unicode spec changes.")] public void Should_Generate_Data() { - UnicodeDataGenerator.Execute(); + if (!Directory.Exists("Generated")) + { + Directory.CreateDirectory("Generated"); + } + + var unicodeDataTrie = UnicodeDataGenerator.GenerateUnicodeDataTrie(out var unicodeDataEntries, out var unicodeData); + + foreach (var value in unicodeData.Values) + { + var data = unicodeDataTrie.Get(value.Codepoint); + + Assert.Equal(value.GeneralCategory, GetValue(data, 0, UnicodeData.CATEGORY_MASK)); + + Assert.Equal(value.Script, GetValue(data, UnicodeData.SCRIPT_SHIFT, UnicodeData.SCRIPT_MASK)); + + Assert.Equal(value.LineBreakClass, GetValue(data, UnicodeData.LINEBREAK_SHIFT, UnicodeData.LINEBREAK_MASK)); + } + + var biDiTrie = UnicodeDataGenerator.GenerateBiDiTrie(out var biDiDataEntries, out var biDiData); + + foreach (var value in biDiData.Values) + { + var data = biDiTrie.Get(value.Codepoint); + + Assert.Equal(value.Bracket, GetValue(data, 0, UnicodeData.BIDIPAIREDBRACKED_MASK)); + + Assert.Equal(value.BracketType, GetValue(data, UnicodeData.BIDIPAIREDBRACKEDTYPE_SHIFT, UnicodeData.BIDIPAIREDBRACKEDTYPE_MASK)); + + Assert.Equal(value.BiDiClass, GetValue(data, UnicodeData.BIDICLASS_SHIFT, UnicodeData.BIDICLASS_MASK)); + } + + UnicodeEnumsGenerator.CreatePropertyValueAliasHelper(unicodeDataEntries, biDiDataEntries); + } + + private static int GetValue(uint value, int shift, int mask) + { + return (int)((value >> shift) & mask); } } } diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeEnumsGenerator.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeEnumsGenerator.cs index 3a936ff3b0..e483728423 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeEnumsGenerator.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeEnumsGenerator.cs @@ -270,9 +270,36 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting return entries; } + + public static List CreateBiDiPairedBracketTypeEnum() + { + var entries = new List { new DataEntry("None", "n", string.Empty) }; + + ParseDataEntries("# Bidi_Paired_Bracket_Type (bpt)", entries); + + using (var stream = File.Create("Generated\\BiDiPairedBracketType.cs")) + using (var writer = new StreamWriter(stream)) + { + writer.WriteLine("namespace Avalonia.Media.TextFormatting.Unicode"); + writer.WriteLine("{"); + writer.WriteLine(" public enum BiDiPairedBracketType"); + writer.WriteLine(" {"); + + foreach (var entry in entries) + { + writer.WriteLine(" " + entry.Name.Replace("_", "") + ", //" + entry.Tag + + (string.IsNullOrEmpty(entry.Comment) ? string.Empty : "#" + entry.Comment)); + } - public static void CreatePropertyValueAliasHelper(List scriptEntries, IEnumerable generalCategoryEntries, - IEnumerable biDiClassEntries, IEnumerable lineBreakClassEntries) + writer.WriteLine(" }"); + writer.WriteLine("}"); + } + + return entries; + } + + public static void CreatePropertyValueAliasHelper(UnicodeDataEntries unicodeDataEntries, + BiDiDataEntries biDiDataEntries) { using (var stream = File.Create("Generated\\PropertyValueAliasHelper.cs")) using (var writer = new StreamWriter(stream)) @@ -285,15 +312,17 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting writer.WriteLine(" internal static class PropertyValueAliasHelper"); writer.WriteLine(" {"); - WritePropertyValueAliasGetTag(writer, scriptEntries, "Script", "Zzzz"); - - WritePropertyValueAlias(writer, scriptEntries, "Script", "Unknown"); + WritePropertyValueAliasGetTag(writer, unicodeDataEntries.Scripts, "Script", "Zzzz"); - WritePropertyValueAlias(writer, generalCategoryEntries, "GeneralCategory", "Other"); + WritePropertyValueAlias(writer, unicodeDataEntries.Scripts, "Script", "Unknown"); - WritePropertyValueAlias(writer, biDiClassEntries, "BiDiClass", "LeftToRight"); + WritePropertyValueAlias(writer, unicodeDataEntries.GeneralCategories, "GeneralCategory", "Other"); + + WritePropertyValueAlias(writer, unicodeDataEntries.LineBreakClasses, "LineBreakClass", "Unknown"); - WritePropertyValueAlias(writer, lineBreakClassEntries, "LineBreakClass", "Unknown"); + WritePropertyValueAlias(writer, biDiDataEntries.PairedBracketTypes, "BiDiPairedBracketType", "None"); + + WritePropertyValueAlias(writer, biDiDataEntries.BiDiClasses, "BiDiClass", "LeftToRight"); writer.WriteLine(" }"); writer.WriteLine("}"); diff --git a/tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/TextNodeTests.cs b/tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/TextNodeTests.cs deleted file mode 100644 index a6eaea334c..0000000000 --- a/tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/TextNodeTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Rendering.SceneGraph; -using Moq; -using Xunit; - -namespace Avalonia.Visuals.UnitTests.Rendering.SceneGraph -{ - public class TextNodeTests - { - [Fact] - public void Bounds_Should_Be_Offset_By_Origin() - { - var target = new TextNode( - Matrix.Identity, - Brushes.Black, - new Point(10, 10), - Mock.Of(x => x.Bounds == new Rect(5, 5, 50, 50))); - - Assert.Equal(new Rect(15, 15, 50, 50), target.Bounds); - } - } -} diff --git a/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs b/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs index 229bb8aef3..6100f2cb74 100644 --- a/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs +++ b/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs @@ -10,18 +10,6 @@ namespace Avalonia.Visuals.UnitTests.VisualTree { class MockRenderInterface : IPlatformRenderInterface { - public IFormattedTextImpl CreateFormattedText( - string text, - Typeface typeface, - double fontSize, - TextAlignment textAlignment, - TextWrapping wrapping, - Size constraint, - IReadOnlyList spans) - { - throw new NotImplementedException(); - } - public IRenderTarget CreateRenderTarget(IEnumerable surfaces) { throw new NotImplementedException(); From 3f32f8e390c921f974e22260b9132f855b640758 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 28 Jan 2022 13:32:34 +0100 Subject: [PATCH 02/18] Fix GetLineIndexFromCharacterIndex --- src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 646204021b..0cc09e8259 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -394,12 +394,12 @@ namespace Avalonia.Media.TextFormatting { var textLine = TextLines[index]; - if (textLine.TextRange.End < charIndex) + if (textLine.TextRange.Start + textLine.TextRange.Length < charIndex) { continue; } - if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.End) + if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.Start + textLine.TextRange.Length) { return index; } From 3fdfdd64d2350db0888aed168b439a3b0ad827b9 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 28 Jan 2022 21:48:45 +0100 Subject: [PATCH 03/18] Fix font fallback Fix TextPresenter measure --- .../Presenters/TextPresenter.cs | 4 +- src/Avalonia.Visuals/Media/GlyphRun.cs | 10 +++ src/Avalonia.Visuals/Media/GlyphTypeface.cs | 2 - .../TextFormatting/ShapedTextCharacters.cs | 9 +++ .../Media/TextFormatting/TextCharacters.cs | 76 +++++++++---------- .../Media/TextFormatting/TextFormatterImpl.cs | 7 +- src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs | 2 + .../TextFormatting/TextFormatterTests.cs | 32 ++++++++ 8 files changed, 98 insertions(+), 44 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 3dcaff5171..a0558df23c 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -515,9 +515,11 @@ namespace Avalonia.Controls.Presenters protected override Size MeasureOverride(Size availableSize) { - if (availableSize != Size.Infinity) + if (!double.IsInfinity(availableSize.Width) && availableSize != _constraint) { _constraint = availableSize; + + InvalidateTextLayout(); } return TextLayout.Size; diff --git a/src/Avalonia.Visuals/Media/GlyphRun.cs b/src/Avalonia.Visuals/Media/GlyphRun.cs index dfefa98f50..ef5ffb8d78 100644 --- a/src/Avalonia.Visuals/Media/GlyphRun.cs +++ b/src/Avalonia.Visuals/Media/GlyphRun.cs @@ -669,6 +669,15 @@ namespace Avalonia.Media var codepointIndex = IsLeftToRight ? cluster - _characters.Start : _characters.End - cluster; + if (codepointIndex < 0) + { + trailingWhitespaceLength = _characters.Length; + + glyphCount = GlyphClusters.Count; + + break; + } + var codepoint = Codepoint.ReadAt(_characters, codepointIndex, out _); if (!codepoint.IsWhiteSpace) @@ -682,6 +691,7 @@ namespace Avalonia.Media } trailingWhitespaceLength++; + glyphCount++; } } diff --git a/src/Avalonia.Visuals/Media/GlyphTypeface.cs b/src/Avalonia.Visuals/Media/GlyphTypeface.cs index 67dfbb84b6..45ef04e77f 100644 --- a/src/Avalonia.Visuals/Media/GlyphTypeface.cs +++ b/src/Avalonia.Visuals/Media/GlyphTypeface.cs @@ -5,8 +5,6 @@ namespace Avalonia.Media { public sealed class GlyphTypeface : IDisposable { - public const int InvisibleGlyph = 3; - public GlyphTypeface(Typeface typeface) : this(FontManager.Current.PlatformImpl.CreateGlyphTypeface(typeface)) { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs index d5af819c39..96b3857098 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs @@ -152,6 +152,15 @@ namespace Avalonia.Media.TextFormatting var first = new ShapedTextCharacters(splitBuffer.First, Properties); + #if DEBUG + + if (first.Text.Length != length) + { + throw new InvalidOperationException("Split length mismatch."); + } + + #endif + var second = new ShapedTextCharacters(splitBuffer.Second!, Properties); return new SplitResult(first, second); diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs index 9116b58695..494cbcb890 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Utilities; @@ -37,17 +38,20 @@ namespace Avalonia.Media.TextFormatting /// Gets a list of . /// /// The shapeable text characters. - internal IList GetShapeableCharacters(ReadOnlySlice runText, sbyte biDiLevel) + internal IList GetShapeableCharacters(ReadOnlySlice runText, sbyte biDiLevel, + ref TextRunProperties? previousProperties) { var shapeableCharacters = new List(2); while (!runText.IsEmpty) { - var shapeableRun = CreateShapeableRun(runText, Properties, biDiLevel); + var shapeableRun = CreateShapeableRun(runText, Properties, biDiLevel, ref previousProperties); shapeableCharacters.Add(shapeableRun); runText = runText.Skip(shapeableRun.Text.Length); + + previousProperties = shapeableRun.Properties; } return shapeableCharacters; @@ -59,15 +63,29 @@ namespace Avalonia.Media.TextFormatting /// The text to create text runs from. /// The default text run properties. /// The bidi level of the run. + /// /// A list of shapeable text runs. - private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text, TextRunProperties defaultProperties, sbyte biDiLevel) + private static ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text, + TextRunProperties defaultProperties, sbyte biDiLevel, ref TextRunProperties? previousProperties) { var defaultTypeface = defaultProperties.Typeface; var currentTypeface = defaultTypeface; - if (TryGetShapeableLength(text, currentTypeface, defaultTypeface, out var count)) + if (TryGetShapeableLength(text, currentTypeface, out var count, out var script)) { + var previousTypeface = previousProperties?.Typeface; + + if (script == Script.Common && previousTypeface is not null) + { + if(TryGetShapeableLength(text, previousTypeface.Value, out var fallbackCount, out _)) + { + return new ShapeableTextCharacters(text.Take(fallbackCount), + new GenericTextRunProperties(previousTypeface.Value, defaultProperties.FontRenderingEmSize, + defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel); + } + } + return new ShapeableTextCharacters(text.Take(count), new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize, defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel); @@ -94,7 +112,7 @@ namespace Avalonia.Media.TextFormatting FontManager.Current.TryMatchCharacter(codepoint, defaultTypeface.Style, defaultTypeface.Weight, defaultTypeface.FontFamily, defaultProperties.CultureInfo, out currentTypeface); - if (matchFound && TextCharacters.TryGetShapeableLength(text, currentTypeface, defaultTypeface, out count)) + if (matchFound && TryGetShapeableLength(text, currentTypeface, out count, out _)) { //Fallback found return new ShapeableTextCharacters(text.Take(count), @@ -127,30 +145,26 @@ namespace Avalonia.Media.TextFormatting } /// - /// Tries to get run properties. + /// Tries to get a shapeable length that is supported by the specified typeface. /// - /// - /// + /// The text. /// The typeface that is used to find matching characters. - /// + /// The shapeable length. + /// /// - protected static bool TryGetShapeableLength(ReadOnlySlice text, Typeface typeface, Typeface defaultTypeface, - out int length) + protected static bool TryGetShapeableLength(ReadOnlySlice text, Typeface typeface, out int length, + out Script script) { + length = 0; + script = Script.Unknown; + if (text.Length == 0) { - length = 0; return false; } - var isFallback = typeface != defaultTypeface; - - length = 0; - var script = Script.Unknown; - var font = typeface.GlyphTypeface; - var defaultFont = defaultTypeface.GlyphTypeface; - + var enumerator = new GraphemeEnumerator(text); while (enumerator.MoveNext()) @@ -161,7 +175,8 @@ namespace Avalonia.Media.TextFormatting if (currentScript != script) { - if (script is Script.Unknown || currentScript != Script.Common && (script is Script.Common || script is Script.Inherited)) + if (script is Script.Unknown || currentScript != Script.Common && + (script is Script.Common || script is Script.Inherited)) { script = currentScript; } @@ -174,23 +189,8 @@ namespace Avalonia.Media.TextFormatting } } - //Only handle non whitespace here - if(!currentGrapheme.FirstCodepoint.IsWhiteSpace) - { - //Stop at the first glyph that is present in the default typeface. - if (isFallback && defaultFont.TryGetGlyph(currentGrapheme.FirstCodepoint, out _)) - { - break; - } - - //Stop at the first missing glyph - if (!font.TryGetGlyph(currentGrapheme.FirstCodepoint, out _)) - { - break; - } - } - - if (!currentGrapheme.FirstCodepoint.IsWhiteSpace && !font.TryGetGlyph(currentGrapheme.FirstCodepoint, out _)) + //Stop at the first missing glyph + if (!font.TryGetGlyph(currentGrapheme.FirstCodepoint, out _)) { break; } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs index 9a41d01b56..101f273798 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs @@ -208,6 +208,7 @@ namespace Avalonia.Media.TextFormatting var levelIndex = 0; var runLevel = levels[0]; + TextRunProperties? previousProperties = null; TextCharacters? currentRun = null; var runText = ReadOnlySlice.Empty; @@ -231,7 +232,7 @@ namespace Avalonia.Media.TextFormatting if (j == runText.Length) { - yield return currentRun.GetShapeableCharacters(runText.Take(j), runLevel); + yield return currentRun.GetShapeableCharacters(runText.Take(j), runLevel, ref previousProperties); runLevel = levels[levelIndex]; @@ -244,7 +245,7 @@ namespace Avalonia.Media.TextFormatting } // End of this run - yield return currentRun.GetShapeableCharacters(runText.Take(j), runLevel); + yield return currentRun.GetShapeableCharacters(runText.Take(j), runLevel, ref previousProperties); runText = runText.Skip(j); @@ -260,7 +261,7 @@ namespace Avalonia.Media.TextFormatting yield break; } - yield return currentRun.GetShapeableCharacters(runText, runLevel); + yield return currentRun.GetShapeableCharacters(runText, runLevel, ref previousProperties); } /// diff --git a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs index 9601fece25..5b6e5af60f 100644 --- a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs +++ b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs @@ -64,6 +64,8 @@ namespace Avalonia.Skia public SKTypeface Typeface { get; } + public int ReplacementCodepoint { get; } + /// public short DesignEmHeight { get; } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 10a05f98d1..326997328b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -461,6 +461,38 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(expectedOffset, textLine.Start); } } + + [Fact] + public void Should_Wrap_Syriac() + { + using (Start()) + { + const string text = + "܀ ܁ ܂ ܃ ܄ ܅ ܆ ܇ ܈ ܉ ܊ ܋ ܌ ܍ ܏ ܐ ܑ ܒ ܓ ܔ ܕ ܖ ܗ ܘ ܙ ܚ ܛ ܜ ܝ ܞ ܟ ܠ ܡ ܢ ܣ ܤ ܥ ܦ ܧ ܨ ܩ ܪ ܫ ܬ ܰ ܱ ܲ ܳ ܴ ܵ ܶ ܷ ܸ ܹ ܺ ܻ ܼ ܽ ܾ ܿ ݀ ݁ ݂ ݃ ݄ ݅ ݆ ݇ ݈ ݉ ݊"; + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + + var paragraphProperties = + new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + + var textSource = new SingleBufferTextSource(text, defaultProperties); + var formatter = new TextFormatterImpl(); + + var textPosition = 87; + TextLineBreak lastBreak = null; + + while (textPosition < text.Length) + { + var textLine = + formatter.FormatLine(textSource, textPosition, 50, paragraphProperties, lastBreak); + + Assert.Equal(textLine.TextRange.Length, textLine.TextRuns.Sum(x => x.TextSourceLength)); + + textPosition += textLine.TextRange.Length; + + lastBreak = textLine.TextLineBreak; + } + } + } [Fact] public void Should_FormatLine_With_Emergency_Breaks() From 6fcba32bbff0040c13ba121fae726070c6f7ade9 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 28 Jan 2022 22:28:02 +0100 Subject: [PATCH 04/18] Fix TryGetShapeableLength --- src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs index 494cbcb890..e287238f82 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs @@ -190,7 +190,7 @@ namespace Avalonia.Media.TextFormatting } //Stop at the first missing glyph - if (!font.TryGetGlyph(currentGrapheme.FirstCodepoint, out _)) + if (!currentGrapheme.FirstCodepoint.IsBreakChar && !font.TryGetGlyph(currentGrapheme.FirstCodepoint, out _)) { break; } From 4d866b544cc41ed882e31dc23868ddb450cf4b61 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 28 Jan 2022 20:51:04 -0500 Subject: [PATCH 05/18] Replace shared project with Avalonia.PlatformSupport --- Avalonia.sln | 41 +- .../Avalonia.Android/AndroidPlatform.cs | 5 +- src/Android/Avalonia.Android/AppBuilder.cs | 2 +- .../Avalonia.Android/Avalonia.Android.csproj | 4 +- src/Android/Avalonia.Android/RuntimeInfo.cs | 18 - .../Platform/IRuntimePlatform.cs | 5 +- .../ApiCompatBaseline.txt | 3 + src/Avalonia.DesktopRuntime/AppBuilder.cs | 2 +- .../Avalonia.DesktopRuntime.csproj | 2 +- src/Avalonia.DesktopRuntime/RuntimeInfo.cs | 40 -- .../AssetLoader.cs | 17 +- .../Avalonia.PlatformSupport.csproj | 18 + .../DynLoader.cs | 65 +- .../StandardRuntimePlatform.cs | 79 ++- .../StandardRuntimePlatformServices.cs | 31 + .../PlatformSupport/PlatformSupport.projitems | 17 - .../PlatformSupport/PlatformSupport.shproj | 11 - .../StandardRuntimePlatformServices.cs | 28 - src/Shared/RenderHelpers/QuadBezierHelper.cs | 13 - .../RenderHelpers/RenderHelpers.projitems | 14 - src/Shared/RenderHelpers/RenderHelpers.shproj | 13 - src/Shared/WindowResizeDragHelper.cs | 84 --- src/Skia/Avalonia.Skia/Avalonia.Skia.csproj | 3 +- .../Avalonia.Web.Blazor.csproj | 3 +- .../AvaloniaBlazorAppBuilder.cs | 5 +- .../BlazorRuntimePlatform.cs | 61 -- .../Avalonia.Direct2D1.csproj | 1 - src/iOS/Avalonia.iOS/Avalonia.iOS.csproj | 11 +- .../Avalonia.iOS/Boilerplate/AppBuilder.cs | 5 +- .../Boilerplate/RuntimePlatform.cs | 19 - src/iOS/Avalonia.iOS/Boilerplate/Shared.cs | 595 ------------------ .../Themes/FluentBenchmark.cs | 4 +- .../Themes/ThemeBenchmark.cs | 2 +- tests/Avalonia.RenderTests/TestBase.cs | 2 +- .../Avalonia.UnitTests.csproj | 2 +- tests/Avalonia.UnitTests/RuntimeInfo.cs | 16 - tests/Avalonia.UnitTests/TestServices.cs | 2 +- .../Avalonia.UnitTests/UnitTestApplication.cs | 2 +- 38 files changed, 212 insertions(+), 1033 deletions(-) delete mode 100644 src/Android/Avalonia.Android/RuntimeInfo.cs create mode 100644 src/Avalonia.DesktopRuntime/ApiCompatBaseline.txt delete mode 100644 src/Avalonia.DesktopRuntime/RuntimeInfo.cs rename src/{Shared/PlatformSupport => Avalonia.PlatformSupport}/AssetLoader.cs (97%) create mode 100644 src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj rename src/{Shared/PlatformSupport => Avalonia.PlatformSupport}/DynLoader.cs (77%) rename src/{Shared/PlatformSupport => Avalonia.PlatformSupport}/StandardRuntimePlatform.cs (66%) create mode 100644 src/Avalonia.PlatformSupport/StandardRuntimePlatformServices.cs delete mode 100644 src/Shared/PlatformSupport/PlatformSupport.projitems delete mode 100644 src/Shared/PlatformSupport/PlatformSupport.shproj delete mode 100644 src/Shared/PlatformSupport/StandardRuntimePlatformServices.cs delete mode 100644 src/Shared/RenderHelpers/QuadBezierHelper.cs delete mode 100644 src/Shared/RenderHelpers/RenderHelpers.projitems delete mode 100644 src/Shared/RenderHelpers/RenderHelpers.shproj delete mode 100644 src/Shared/WindowResizeDragHelper.cs delete mode 100644 src/Web/Avalonia.Web.Blazor/BlazorRuntimePlatform.cs delete mode 100644 src/iOS/Avalonia.iOS/Boilerplate/RuntimePlatform.cs delete mode 100644 src/iOS/Avalonia.iOS/Boilerplate/Shared.cs delete mode 100644 tests/Avalonia.UnitTests/RuntimeInfo.cs diff --git a/Avalonia.sln b/Avalonia.sln index 4e7b4cc318..0354e20d4f 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -60,20 +60,17 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{A689DEF5-D50F-4975-8B72-124C9EB54066}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + src\Shared\ModuleInitializer.cs = src\Shared\ModuleInitializer.cs EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.ReactiveUI", "src\Avalonia.ReactiveUI\Avalonia.ReactiveUI.csproj", "{6417B24E-49C2-4985-8DB2-3AB9D898EC91}" EndProject -Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "PlatformSupport", "src\Shared\PlatformSupport\PlatformSupport.shproj", "{E4D9629C-F168-4224-3F51-A5E482FFBC42}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Markup", "src\Markup\Avalonia.Markup\Avalonia.Markup.csproj", "{6417E941-21BC-467B-A771-0DE389353CE6}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Markup.UnitTests", "tests\Avalonia.Markup.UnitTests\Avalonia.Markup.UnitTests.csproj", "{8EF392D5-1416-45AA-9956-7CBBC3229E8A}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BindingDemo", "samples\BindingDemo\BindingDemo.csproj", "{08B3E6B9-1CD5-443C-9F61-6D49D1C5F162}" EndProject -Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "RenderHelpers", "src\Shared\RenderHelpers\RenderHelpers.shproj", "{3C4C0CB4-0C0F-4450-A37B-148C84FF905F}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Skia", "Skia", "{3743B0F2-CC41-4F14-A8C8-267F579BF91E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Android", "Android", "{7CF9789C-F1D3-4D0E-90E5-F1DF67A2753F}" @@ -235,15 +232,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsInteropTest", "sampl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlSamples", "samples\SampleControls\ControlSamples.csproj", "{A0D0A6A4-5C72-4ADA-9B27-621C7D94F270}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.PlatformSupport", "src\Avalonia.PlatformSupport\Avalonia.PlatformSupport.csproj", "{E8A597F0-2AB5-4BDA-A235-41162DAF53CF}" +EndProject Global - GlobalSection(SharedMSBuildProjectFiles) = preSolution - src\Shared\RenderHelpers\RenderHelpers.projitems*{3c4c0cb4-0c0f-4450-a37b-148c84ff905f}*SharedItemsImports = 13 - src\Shared\RenderHelpers\RenderHelpers.projitems*{3e908f67-5543-4879-a1dc-08eace79b3cd}*SharedItemsImports = 5 - src\Shared\PlatformSupport\PlatformSupport.projitems*{7b92af71-6287-4693-9dcb-bd5b6e927e23}*SharedItemsImports = 5 - src\Shared\RenderHelpers\RenderHelpers.projitems*{7d2d3083-71dd-4cc9-8907-39a0d86fb322}*SharedItemsImports = 5 - src\Shared\PlatformSupport\PlatformSupport.projitems*{88060192-33d5-4932-b0f9-8bd2763e857d}*SharedItemsImports = 5 - src\Shared\PlatformSupport\PlatformSupport.projitems*{e4d9629c-f168-4224-3f51-a5e482ffbc42}*SharedItemsImports = 13 - EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU Ad-Hoc|iPhone = Ad-Hoc|iPhone @@ -2169,6 +2160,30 @@ Global {A0D0A6A4-5C72-4ADA-9B27-621C7D94F270}.Release|iPhone.Build.0 = Release|Any CPU {A0D0A6A4-5C72-4ADA-9B27-621C7D94F270}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {A0D0A6A4-5C72-4ADA-9B27-621C7D94F270}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.AppStore|iPhone.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Debug|iPhone.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Release|Any CPU.Build.0 = Release|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Release|iPhone.ActiveCfg = Release|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Release|iPhone.Build.0 = Release|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {E8A597F0-2AB5-4BDA-A235-41162DAF53CF}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -2187,11 +2202,9 @@ Global {EFB11458-9CDF-41C0-BE4F-44AF45A4CAB8} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {99135EAB-653D-47E4-A378-C96E1278CA44} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {3E53A01A-B331-47F3-B828-4A5717E77A24} = {8B6A8209-894F-4BA1-B880-965FD453982C} - {E4D9629C-F168-4224-3F51-A5E482FFBC42} = {A689DEF5-D50F-4975-8B72-124C9EB54066} {6417E941-21BC-467B-A771-0DE389353CE6} = {8B6A8209-894F-4BA1-B880-965FD453982C} {8EF392D5-1416-45AA-9956-7CBBC3229E8A} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {08B3E6B9-1CD5-443C-9F61-6D49D1C5F162} = {9B9E3891-2366-4253-A952-D08BCEB71098} - {3C4C0CB4-0C0F-4450-A37B-148C84FF905F} = {A689DEF5-D50F-4975-8B72-124C9EB54066} {7B92AF71-6287-4693-9DCB-BD5B6E927E23} = {7CF9789C-F1D3-4D0E-90E5-F1DF67A2753F} {FF69B927-C545-49AE-8E16-3D14D621AA12} = {7CF9789C-F1D3-4D0E-90E5-F1DF67A2753F} {4488AD85-1495-4809-9AA4-DDFE0A48527E} = {0CB0B92E-6CFF-4240-80A5-CCAFE75D91E1} diff --git a/src/Android/Avalonia.Android/AndroidPlatform.cs b/src/Android/Avalonia.Android/AndroidPlatform.cs index 6a940a54f1..2d4f6a305f 100644 --- a/src/Android/Avalonia.Android/AndroidPlatform.cs +++ b/src/Android/Avalonia.Android/AndroidPlatform.cs @@ -10,7 +10,7 @@ using Avalonia.Input.Platform; using Avalonia.OpenGL.Egl; using Avalonia.Platform; using Avalonia.Rendering; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; using Avalonia.Skia; namespace Avalonia @@ -59,8 +59,7 @@ namespace Avalonia.Android .Bind().ToSingleton() .Bind().ToConstant(new ChoreographerTimer()) .Bind().ToConstant(new RenderLoop()) - .Bind().ToSingleton() - .Bind().ToConstant(new AssetLoader(appType.Assembly)); + .Bind().ToSingleton(); SkiaPlatform.Initialize(); diff --git a/src/Android/Avalonia.Android/AppBuilder.cs b/src/Android/Avalonia.Android/AppBuilder.cs index 805bb61655..04f1ff00d0 100644 --- a/src/Android/Avalonia.Android/AppBuilder.cs +++ b/src/Android/Avalonia.Android/AppBuilder.cs @@ -1,5 +1,5 @@ using Avalonia.Controls; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; namespace Avalonia { diff --git a/src/Android/Avalonia.Android/Avalonia.Android.csproj b/src/Android/Avalonia.Android/Avalonia.Android.csproj index 8c6775733f..5c33dbcea6 100644 --- a/src/Android/Avalonia.Android/Avalonia.Android.csproj +++ b/src/Android/Avalonia.Android/Avalonia.Android.csproj @@ -5,9 +5,11 @@ + + TargetFramework=netstandard2.0 + - diff --git a/src/Android/Avalonia.Android/RuntimeInfo.cs b/src/Android/Avalonia.Android/RuntimeInfo.cs deleted file mode 100644 index bb2466c357..0000000000 --- a/src/Android/Avalonia.Android/RuntimeInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Avalonia.Platform; - -namespace Avalonia.Shared.PlatformSupport -{ - internal partial class StandardRuntimePlatform - { - public RuntimePlatformInfo GetRuntimeInfo() => new RuntimePlatformInfo - { - IsCoreClr = false, - IsDesktop = false, - IsMobile = true, - IsDotNetFramework = false, - IsMono = true, - IsUnix = true, - OperatingSystem = OperatingSystemType.Android - }; - } -} \ No newline at end of file diff --git a/src/Avalonia.Base/Platform/IRuntimePlatform.cs b/src/Avalonia.Base/Platform/IRuntimePlatform.cs index a0d5d611b3..850757a1ee 100644 --- a/src/Avalonia.Base/Platform/IRuntimePlatform.cs +++ b/src/Avalonia.Base/Platform/IRuntimePlatform.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; namespace Avalonia.Platform { @@ -23,6 +22,7 @@ namespace Avalonia.Platform public OperatingSystemType OperatingSystem { get; set; } public bool IsDesktop { get; set; } public bool IsMobile { get; set; } + public bool IsBrowser { get; set; } public bool IsCoreClr { get; set; } public bool IsMono { get; set; } public bool IsDotNetFramework { get; set; } @@ -36,6 +36,7 @@ namespace Avalonia.Platform Linux, OSX, Android, - iOS + iOS, + Browser } } diff --git a/src/Avalonia.DesktopRuntime/ApiCompatBaseline.txt b/src/Avalonia.DesktopRuntime/ApiCompatBaseline.txt new file mode 100644 index 0000000000..0493db9ab3 --- /dev/null +++ b/src/Avalonia.DesktopRuntime/ApiCompatBaseline.txt @@ -0,0 +1,3 @@ +Compat issues with assembly Avalonia.DesktopRuntime: +TypesMustExist : Type 'Avalonia.Shared.PlatformSupport.AssetLoader' does not exist in the implementation but it does exist in the contract. +Total Issues: 1 diff --git a/src/Avalonia.DesktopRuntime/AppBuilder.cs b/src/Avalonia.DesktopRuntime/AppBuilder.cs index ff0d84a6e9..2946324c83 100644 --- a/src/Avalonia.DesktopRuntime/AppBuilder.cs +++ b/src/Avalonia.DesktopRuntime/AppBuilder.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Reflection; using Avalonia.Controls; using Avalonia.Platform; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; namespace Avalonia { diff --git a/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj b/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj index f2a0faeb22..25effae46e 100644 --- a/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj +++ b/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj @@ -8,6 +8,7 @@ + @@ -16,6 +17,5 @@ - diff --git a/src/Avalonia.DesktopRuntime/RuntimeInfo.cs b/src/Avalonia.DesktopRuntime/RuntimeInfo.cs deleted file mode 100644 index 82eaadb895..0000000000 --- a/src/Avalonia.DesktopRuntime/RuntimeInfo.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using Avalonia.Platform; - - -namespace Avalonia.Shared.PlatformSupport -{ - internal partial class StandardRuntimePlatform - { - private static readonly Lazy Info = new Lazy(() => - { - OperatingSystemType os; - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - os = OperatingSystemType.OSX; - else if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - os = OperatingSystemType.Linux; - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - os = OperatingSystemType.WinNT; - else - throw new Exception("Unknown OS platform " + RuntimeInformation.OSDescription); - - return new RuntimePlatformInfo - { -#if NETCOREAPP2_0 - IsCoreClr = true, -#elif NET461 - IsDotNetFramework = false, -#endif - IsDesktop = true, - IsMono = false, - IsMobile = false, - IsUnix = os != OperatingSystemType.WinNT, - OperatingSystem = os, - }; - }); - - - public RuntimePlatformInfo GetRuntimeInfo() => Info.Value; - } -} diff --git a/src/Shared/PlatformSupport/AssetLoader.cs b/src/Avalonia.PlatformSupport/AssetLoader.cs similarity index 97% rename from src/Shared/PlatformSupport/AssetLoader.cs rename to src/Avalonia.PlatformSupport/AssetLoader.cs index 43f9211889..7220694d7b 100644 --- a/src/Shared/PlatformSupport/AssetLoader.cs +++ b/src/Avalonia.PlatformSupport/AssetLoader.cs @@ -6,9 +6,7 @@ using System.Reflection; using Avalonia.Platform; using Avalonia.Utilities; -#nullable enable - -namespace Avalonia.Shared.PlatformSupport +namespace Avalonia.PlatformSupport { /// /// Loads assets compiled into the application binary. @@ -233,14 +231,15 @@ namespace Avalonia.Shared.PlatformSupport else { // iOS does not support loading assemblies dynamically! - // -#if __IOS__ - throw new InvalidOperationException( - $"Assembly {name} needs to be referenced and explicitly loaded before loading resources"); -#else +#if NET6_0_OR_GREATER + if (OperatingSystem.IsIOS()) + { + throw new InvalidOperationException( + $"Assembly {name} needs to be referenced and explicitly loaded before loading resources"); + } +#endif name = Uri.UnescapeDataString(name); AssemblyNameCache[name] = rv = new AssemblyDescriptor(Assembly.Load(name)); -#endif } } diff --git a/src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj b/src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj new file mode 100644 index 0000000000..be73d87e2c --- /dev/null +++ b/src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj @@ -0,0 +1,18 @@ + + + + net6.0;net461;netstandard2.0 + + + + + + + + + + + + + + diff --git a/src/Shared/PlatformSupport/DynLoader.cs b/src/Avalonia.PlatformSupport/DynLoader.cs similarity index 77% rename from src/Shared/PlatformSupport/DynLoader.cs rename to src/Avalonia.PlatformSupport/DynLoader.cs index 8fd4b1ad1e..ad76ac4724 100644 --- a/src/Shared/PlatformSupport/DynLoader.cs +++ b/src/Avalonia.PlatformSupport/DynLoader.cs @@ -1,35 +1,40 @@ using System; using System.Runtime.InteropServices; -using Avalonia.Platform; using Avalonia.Platform.Interop; -namespace Avalonia.Shared.PlatformSupport +// ReSharper disable InconsistentNaming +namespace Avalonia.PlatformSupport { -#if !__IOS__ class UnixLoader : IDynamicLibraryLoader { - // ReSharper disable InconsistentNaming static class LinuxImports { -#if __ANDROID__ - [DllImport("libdl.so")] -#else [DllImport("libdl.so.2")] -#endif private static extern IntPtr dlopen(string path, int flags); -#if __ANDROID__ - [DllImport("libdl.so")] -#else [DllImport("libdl.so.2")] -#endif private static extern IntPtr dlsym(IntPtr handle, string symbol); -#if __ANDROID__ - [DllImport("libdl.so")] -#else [DllImport("libdl.so.2")] -#endif + private static extern IntPtr dlerror(); + + public static void Init() + { + DlOpen = dlopen; + DlSym = dlsym; + DlError = dlerror; + } + } + + static class AndroidImports + { + [DllImport("libdl.so")] + private static extern IntPtr dlopen(string path, int flags); + + [DllImport("libdl.so")] + private static extern IntPtr dlsym(IntPtr handle, string symbol); + + [DllImport("libdl.so")] private static extern IntPtr dlerror(); public static void Init() @@ -42,8 +47,6 @@ namespace Avalonia.Shared.PlatformSupport static class OsXImports { - - [DllImport("/usr/lib/libSystem.dylib")] private static extern IntPtr dlopen(string path, int flags); @@ -72,32 +75,36 @@ namespace Avalonia.Shared.PlatformSupport uname(buffer); var unixName = Marshal.PtrToStringAnsi(buffer); Marshal.FreeHGlobal(buffer); - if(unixName == "Darwin") + if (unixName == "Darwin") OsXImports.Init(); +#if NET6_0_OR_GREATER + else if (OperatingSystem.IsAndroid()) + AndroidImports.Init(); +#endif else LinuxImports.Init(); } - private static Func DlOpen; - private static Func DlSym; - private static Func DlError; + private static Func? DlOpen; + private static Func? DlSym; + private static Func? DlError; // ReSharper restore InconsistentNaming - static string DlErrorString() => Marshal.PtrToStringAnsi(DlError()); + static string? DlErrorString() => Marshal.PtrToStringAnsi(DlError!.Invoke()); public IntPtr LoadLibrary(string dll) { - var handle = DlOpen(dll, 1); + var handle = DlOpen!.Invoke(dll, 1); if (handle == IntPtr.Zero) - throw new DynamicLibraryLoaderException(DlErrorString()); + throw new DynamicLibraryLoaderException(DlErrorString()!); return handle; } public IntPtr GetProcAddress(IntPtr dll, string proc, bool optional) { - var ptr = DlSym(dll, proc); + var ptr = DlSym!.Invoke(dll, proc); if (ptr == IntPtr.Zero && !optional) - throw new DynamicLibraryLoaderException(DlErrorString()); + throw new DynamicLibraryLoaderException(DlErrorString()!); return ptr; } } @@ -129,8 +136,7 @@ namespace Avalonia.Shared.PlatformSupport } } -#else - internal class IOSLoader : IDynamicLibraryLoader + internal class NotSupportedLoader : IDynamicLibraryLoader { IntPtr IDynamicLibraryLoader.LoadLibrary(string dll) { @@ -142,5 +148,4 @@ namespace Avalonia.Shared.PlatformSupport throw new PlatformNotSupportedException(); } } -#endif } diff --git a/src/Shared/PlatformSupport/StandardRuntimePlatform.cs b/src/Avalonia.PlatformSupport/StandardRuntimePlatform.cs similarity index 66% rename from src/Shared/PlatformSupport/StandardRuntimePlatform.cs rename to src/Avalonia.PlatformSupport/StandardRuntimePlatform.cs index 8521a50087..768966ba2d 100644 --- a/src/Shared/PlatformSupport/StandardRuntimePlatform.cs +++ b/src/Avalonia.PlatformSupport/StandardRuntimePlatform.cs @@ -1,14 +1,13 @@ using System; using System.Collections.Generic; -using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; using Avalonia.Platform; -namespace Avalonia.Shared.PlatformSupport +namespace Avalonia.PlatformSupport { - internal partial class StandardRuntimePlatform : IRuntimePlatform + public class StandardRuntimePlatform : IRuntimePlatform { public IDisposable StartSystemTimer(TimeSpan interval, Action tick) { @@ -16,15 +15,15 @@ namespace Avalonia.Shared.PlatformSupport } public IUnmanagedBlob AllocBlob(int size) => new UnmanagedBlob(this, size); - - class UnmanagedBlob : IUnmanagedBlob + + private class UnmanagedBlob : IUnmanagedBlob { private readonly StandardRuntimePlatform _plat; private IntPtr _address; private readonly object _lock = new object(); #if DEBUG private static readonly List Backtraces = new List(); - private static Thread GCThread; + private static Thread? GCThread; private readonly string _backtrace; private static readonly object _btlock = new object(); @@ -38,15 +37,14 @@ namespace Avalonia.Shared.PlatformSupport [MethodImpl(MethodImplOptions.NoInlining)] static void Spawn() => new GCThreadDetector(); - + static UnmanagedBlob() { Spawn(); GC.WaitForPendingFinalizers(); } - #endif - + public UnmanagedBlob(StandardRuntimePlatform plat, int size) { try @@ -117,14 +115,12 @@ namespace Avalonia.Shared.PlatformSupport DoDispose(); } - public IntPtr Address => IsDisposed ? throw new ObjectDisposedException("UnmanagedBlob") : _address; + public IntPtr Address => IsDisposed ? throw new ObjectDisposedException("UnmanagedBlob") : _address; public int Size { get; private set; } public bool IsDisposed { get; private set; } } - - - -#if NET461 || NETCOREAPP2_0 + +#if NET461 || NETCOREAPP2_0_OR_GREATER [DllImport("libc", SetLastError = true)] private static extern IntPtr mmap(IntPtr addr, IntPtr length, int prot, int flags, int fd, IntPtr offset); [DllImport("libc", SetLastError = true)] @@ -133,15 +129,15 @@ namespace Avalonia.Shared.PlatformSupport private static extern long sysconf(int name); private bool? _useMmap; - private bool UseMmap + private bool UseMmap => _useMmap ?? ((_useMmap = GetRuntimeInfo().OperatingSystem == OperatingSystemType.Linux)).Value; - + IntPtr Alloc(int size) { if (UseMmap) { var rv = mmap(IntPtr.Zero, new IntPtr(size), 3, 0x22, -1, IntPtr.Zero); - if (rv.ToInt64() == -1 || (ulong) rv.ToInt64() == 0xffffffff) + if (rv.ToInt64() == -1 || (ulong)rv.ToInt64() == 0xffffffff) { var errno = Marshal.GetLastWin32Error(); throw new Exception("Unable to allocate memory: " + errno); @@ -169,5 +165,54 @@ namespace Avalonia.Shared.PlatformSupport IntPtr Alloc(int size) => Marshal.AllocHGlobal(size); void Free(IntPtr ptr, int len) => Marshal.FreeHGlobal(ptr); #endif + + private static readonly Lazy Info = new Lazy(() => + { + OperatingSystemType os; + +#if NET5_0_OR_GREATER + if (OperatingSystem.IsWindows()) + os = OperatingSystemType.WinNT; + else if (OperatingSystem.IsMacOS()) + os = OperatingSystemType.OSX; + else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD()) + os = OperatingSystemType.Linux; + else if (OperatingSystem.IsAndroid()) + os = OperatingSystemType.Android; + else if (OperatingSystem.IsIOS()) + os = OperatingSystemType.iOS; + else if (OperatingSystem.IsBrowser()) + os = OperatingSystemType.Browser; + else + throw new Exception("Unknown OS platform " + RuntimeInformation.OSDescription); +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + os = OperatingSystemType.OSX; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + os = OperatingSystemType.Linux; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + os = OperatingSystemType.WinNT; + else + throw new Exception("Unknown OS platform " + RuntimeInformation.OSDescription); +#endif + + return new RuntimePlatformInfo + { +#if NETCOREAPP + IsCoreClr = true, +#elif NETFRAMEWORK + IsDotNetFramework = false, +#endif + IsDesktop = os == OperatingSystemType.Linux || os == OperatingSystemType.OSX || os == OperatingSystemType.WinNT, + IsMono = os == OperatingSystemType.Android || os == OperatingSystemType.iOS || os == OperatingSystemType.Browser, + IsMobile = os == OperatingSystemType.Android || os == OperatingSystemType.iOS, + IsUnix = os == OperatingSystemType.Linux || os == OperatingSystemType.OSX || os == OperatingSystemType.Android, + IsBrowser = os == OperatingSystemType.Browser, + OperatingSystem = os, + }; + }); + + + public virtual RuntimePlatformInfo GetRuntimeInfo() => Info.Value; } } diff --git a/src/Avalonia.PlatformSupport/StandardRuntimePlatformServices.cs b/src/Avalonia.PlatformSupport/StandardRuntimePlatformServices.cs new file mode 100644 index 0000000000..ae7478feb9 --- /dev/null +++ b/src/Avalonia.PlatformSupport/StandardRuntimePlatformServices.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Avalonia.Platform; +using Avalonia.Platform.Interop; + +namespace Avalonia.PlatformSupport +{ + public static class StandardRuntimePlatformServices + { + public static void Register(Assembly? assembly = null) + { + var standardPlatform = new StandardRuntimePlatform(); + var os = standardPlatform.GetRuntimeInfo().OperatingSystem; + + AssetLoader.RegisterResUriParsers(); + AvaloniaLocator.CurrentMutable + .Bind().ToConstant(standardPlatform) + .Bind().ToConstant(new AssetLoader(assembly)) + .Bind().ToConstant( + os switch + { + OperatingSystemType.WinNT => new Win32Loader(), + OperatingSystemType.OSX => new UnixLoader(), + OperatingSystemType.Linux => new UnixLoader(), + OperatingSystemType.Android => new UnixLoader(), + // iOS, WASM, ... + _ => (IDynamicLibraryLoader)new NotSupportedLoader() + } + ); + } + } +} diff --git a/src/Shared/PlatformSupport/PlatformSupport.projitems b/src/Shared/PlatformSupport/PlatformSupport.projitems deleted file mode 100644 index 34515a0912..0000000000 --- a/src/Shared/PlatformSupport/PlatformSupport.projitems +++ /dev/null @@ -1,17 +0,0 @@ - - - - $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - true - {E4D9629C-F168-4224-8F51-F5E482FFEC42} - - - Avalonia.Shared.PlatformSupport - - - - - - - - \ No newline at end of file diff --git a/src/Shared/PlatformSupport/PlatformSupport.shproj b/src/Shared/PlatformSupport/PlatformSupport.shproj deleted file mode 100644 index 5b0e7dbb08..0000000000 --- a/src/Shared/PlatformSupport/PlatformSupport.shproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - {E4D9629C-F168-4224-3F51-A5E482FFBC42} - - - - - - - \ No newline at end of file diff --git a/src/Shared/PlatformSupport/StandardRuntimePlatformServices.cs b/src/Shared/PlatformSupport/StandardRuntimePlatformServices.cs deleted file mode 100644 index 0dd7aea356..0000000000 --- a/src/Shared/PlatformSupport/StandardRuntimePlatformServices.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; -using Avalonia.Platform; -using Avalonia.Platform.Interop; - -namespace Avalonia.Shared.PlatformSupport -{ - static class StandardRuntimePlatformServices - { - public static void Register(Assembly assembly = null) - { - var standardPlatform = new StandardRuntimePlatform(); - AssetLoader.RegisterResUriParsers(); - AvaloniaLocator.CurrentMutable - .Bind().ToConstant(standardPlatform) - .Bind().ToConstant(new AssetLoader(assembly)) - .Bind().ToConstant( -#if __IOS__ - new IOSLoader() -#else - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? (IDynamicLibraryLoader)new Win32Loader() - : new UnixLoader() -#endif - ); - } - } -} diff --git a/src/Shared/RenderHelpers/QuadBezierHelper.cs b/src/Shared/RenderHelpers/QuadBezierHelper.cs deleted file mode 100644 index 8439265e73..0000000000 --- a/src/Shared/RenderHelpers/QuadBezierHelper.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Avalonia.Platform; - -namespace Avalonia.RenderHelpers -{ - static class QuadBezierHelper - { - public static void QuadraticBezierTo(IStreamGeometryContextImpl context, Point current, Point controlPoint, Point endPoint) - { - //(s, (s + 2c)/ 3, (e + 2c)/ 3, e) - context.CubicBezierTo((current + 2*controlPoint)/3, (endPoint + 2*controlPoint)/3, endPoint); - } - } -} diff --git a/src/Shared/RenderHelpers/RenderHelpers.projitems b/src/Shared/RenderHelpers/RenderHelpers.projitems deleted file mode 100644 index 4c80ec50c4..0000000000 --- a/src/Shared/RenderHelpers/RenderHelpers.projitems +++ /dev/null @@ -1,14 +0,0 @@ - - - - $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - true - 3c4c0cb4-0c0f-4450-a37b-148c84ff905f - - - Avalonia.RenderHelpers - - - - - \ No newline at end of file diff --git a/src/Shared/RenderHelpers/RenderHelpers.shproj b/src/Shared/RenderHelpers/RenderHelpers.shproj deleted file mode 100644 index ef561259f3..0000000000 --- a/src/Shared/RenderHelpers/RenderHelpers.shproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - 3c4c0cb4-0c0f-4450-a37b-148c84ff905f - 14.0 - - - - - - - - diff --git a/src/Shared/WindowResizeDragHelper.cs b/src/Shared/WindowResizeDragHelper.cs deleted file mode 100644 index e0e0936b69..0000000000 --- a/src/Shared/WindowResizeDragHelper.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using Avalonia.Controls; -using Avalonia.Input.Raw; -using Avalonia.Platform; - -namespace Avalonia -{ - internal class ManagedWindowResizeDragHelper - { - private readonly IWindowBaseImpl _window; - private readonly Action _captureMouse; - private readonly Action _resize; - private WindowEdge? _edge; - private Point _prevPoint; - - public ManagedWindowResizeDragHelper(IWindowBaseImpl window, Action captureMouse, Action resize = null) - { - _window = window; - _captureMouse = captureMouse; - _resize = resize; - } - - public void BeginResizeDrag(WindowEdge edge, Point currentMousePosition) - { - _captureMouse(true); - _prevPoint = currentMousePosition; - _edge = edge; - } - - public bool PreprocessInputEvent(ref RawInputEventArgs e) - { - if (_edge == null) - return false; - if (e is RawMouseEventArgs args) - { - if (args.Type == RawMouseEventType.LeftButtonUp) - { - _edge = null; - _captureMouse(false); - } - if (args.Type == RawMouseEventType.Move) - { - MoveWindow(args.Position); - return true; - } - - - _edge = null; - } - - return false; - } - - private void MoveWindow(Point position) - { - var diff = position - _prevPoint; - var edge = _edge.Value; - var rc = new Rect(_window.Position, _window.ClientSize); - if (edge == WindowEdge.East || edge == WindowEdge.NorthEast || edge == WindowEdge.SouthEast) - { - rc = rc.WithWidth(rc.Width + diff.X); - _prevPoint = _prevPoint.WithX(position.X); - } - if (edge == WindowEdge.West || edge == WindowEdge.NorthWest || edge == WindowEdge.SouthWest) - rc = rc.WithX(rc.X + diff.X).WithWidth(rc.Width - diff.X); - if (edge == WindowEdge.South || edge == WindowEdge.SouthWest || edge == WindowEdge.SouthEast) - { - rc = rc.WithHeight(rc.Height + diff.Y); - _prevPoint = _prevPoint.WithY(position.Y); - } - if (edge == WindowEdge.North || edge == WindowEdge.NorthWest || edge == WindowEdge.NorthEast) - rc = rc.WithY(rc.Y + diff.Y).WithHeight(rc.Height - diff.Y); - if (_resize != null) - _resize(rc); - else - { - if (_window.Position != rc.Position) - _window.Position = rc.Position; - if (_window.ClientSize != rc.Size) - _window.Resize(rc.Size); - } - } - } -} diff --git a/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj b/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj index bc14494f6a..50ace8209c 100644 --- a/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj +++ b/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj @@ -16,6 +16,5 @@ - - + diff --git a/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj b/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj index 94350b40cf..cc604a9753 100644 --- a/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj +++ b/src/Web/Avalonia.Web.Blazor/Avalonia.Web.Blazor.csproj @@ -10,7 +10,6 @@ - @@ -51,7 +50,7 @@ - + diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaBlazorAppBuilder.cs b/src/Web/Avalonia.Web.Blazor/AvaloniaBlazorAppBuilder.cs index 65c73b101a..9bf1fe6663 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaBlazorAppBuilder.cs +++ b/src/Web/Avalonia.Web.Blazor/AvaloniaBlazorAppBuilder.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Avalonia.Platform; +using Avalonia.PlatformSupport; namespace Avalonia.Web.Blazor { @@ -10,7 +11,9 @@ namespace Avalonia.Web.Blazor { } - public AvaloniaBlazorAppBuilder() : base(BlazorRuntimePlatform.Instance, BlazorRuntimePlatform.RegisterServices) + public AvaloniaBlazorAppBuilder() + : base(new StandardRuntimePlatform(), + builder => StandardRuntimePlatformServices.Register(builder.ApplicationType.Assembly)) { UseWindowingSubsystem(BlazorWindowingPlatform.Register); } diff --git a/src/Web/Avalonia.Web.Blazor/BlazorRuntimePlatform.cs b/src/Web/Avalonia.Web.Blazor/BlazorRuntimePlatform.cs deleted file mode 100644 index 9a5bf6b151..0000000000 --- a/src/Web/Avalonia.Web.Blazor/BlazorRuntimePlatform.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Runtime.InteropServices; -using Avalonia.Platform; -using Avalonia.Shared.PlatformSupport; - -namespace Avalonia.Web.Blazor -{ - internal class BlazorRuntimePlatform : IRuntimePlatform - { - public static readonly IRuntimePlatform Instance = new BlazorRuntimePlatform(); - - public IDisposable StartSystemTimer(TimeSpan interval, Action tick) - { - return new Timer(_ => tick(), null, interval, interval); - } - - public RuntimePlatformInfo GetRuntimeInfo() - { - return new RuntimePlatformInfo - { - IsDesktop = false, - IsMobile = false, - IsMono = true, - IsUnix = false, - IsCoreClr = false, - IsDotNetFramework = false - }; - } - - private class BasicBlob : IUnmanagedBlob - { - public BasicBlob(int size) - { - Address = Marshal.AllocHGlobal(size); - Size = size; - } - public void Dispose() - { - if (Address != IntPtr.Zero) - Marshal.FreeHGlobal(Address); - Address = IntPtr.Zero; - } - - public IntPtr Address { get; private set; } - - public int Size { get; } - public bool IsDisposed => Address == IntPtr.Zero; - } - - public IUnmanagedBlob AllocBlob(int size) - { - return new BasicBlob(size); - } - - public static void RegisterServices(AvaloniaBlazorAppBuilder builder) - { - AssetLoader.RegisterResUriParsers(); - AvaloniaLocator.CurrentMutable.Bind().ToConstant(Instance); - AvaloniaLocator.CurrentMutable.Bind().ToConstant(new AssetLoader()); - } - } -} diff --git a/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj b/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj index bc529208ea..a1d8a60cb9 100644 --- a/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj +++ b/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj @@ -17,6 +17,5 @@ - diff --git a/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj b/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj index c219e08c8b..e9015d857c 100644 --- a/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj +++ b/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj @@ -4,19 +4,14 @@ true latest - - - Code - - - Code - - + + TargetFramework=netstandard2.0 + diff --git a/src/iOS/Avalonia.iOS/Boilerplate/AppBuilder.cs b/src/iOS/Avalonia.iOS/Boilerplate/AppBuilder.cs index 65143c939f..d5830510f6 100644 --- a/src/iOS/Avalonia.iOS/Boilerplate/AppBuilder.cs +++ b/src/iOS/Avalonia.iOS/Boilerplate/AppBuilder.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; -using Avalonia.iOS; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; namespace Avalonia { @@ -12,4 +11,4 @@ namespace Avalonia this.UseSkia().UseWindowingSubsystem(iOS.Platform.Register); } } -} \ No newline at end of file +} diff --git a/src/iOS/Avalonia.iOS/Boilerplate/RuntimePlatform.cs b/src/iOS/Avalonia.iOS/Boilerplate/RuntimePlatform.cs deleted file mode 100644 index c5c4d66450..0000000000 --- a/src/iOS/Avalonia.iOS/Boilerplate/RuntimePlatform.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Avalonia.Platform; - -namespace Avalonia.Shared.PlatformSupport -{ - partial class StandardRuntimePlatform - { - public RuntimePlatformInfo GetRuntimeInfo() - { - return new RuntimePlatformInfo - { - IsDesktop = false, - IsMobile = true, - IsMono = true, - IsUnix = true, - OperatingSystem = OperatingSystemType.iOS - }; - } - } -} \ No newline at end of file diff --git a/src/iOS/Avalonia.iOS/Boilerplate/Shared.cs b/src/iOS/Avalonia.iOS/Boilerplate/Shared.cs deleted file mode 100644 index c6e6e01e64..0000000000 --- a/src/iOS/Avalonia.iOS/Boilerplate/Shared.cs +++ /dev/null @@ -1,595 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using Avalonia.Platform; -using Avalonia.Platform.Interop; -using Avalonia.Utilities; - -namespace Avalonia.Shared.PlatformSupport -{ - static class StandardRuntimePlatformServices - { - public static void Register(Assembly assembly = null) - { - var standardPlatform = new StandardRuntimePlatform(); - AssetLoader.RegisterResUriParsers(); - AvaloniaLocator.CurrentMutable - .Bind().ToConstant(standardPlatform) - .Bind().ToConstant(new AssetLoader(assembly)) - .Bind().ToConstant( -#if __IOS__ - new IOSLoader() -#else - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? (IDynamicLibraryLoader)new Win32Loader() - : new UnixLoader() -#endif - ); - } - } - - - internal partial class StandardRuntimePlatform : IRuntimePlatform - { - public IDisposable StartSystemTimer(TimeSpan interval, Action tick) - { - return new Timer(_ => tick(), null, interval, interval); - } - - public IUnmanagedBlob AllocBlob(int size) => new UnmanagedBlob(this, size); - - class UnmanagedBlob : IUnmanagedBlob - { - private readonly StandardRuntimePlatform _plat; - private IntPtr _address; - private readonly object _lock = new object(); -#if DEBUG - private static readonly List Backtraces = new List(); - private static Thread GCThread; - private readonly string _backtrace; - private static readonly object _btlock = new object(); - - class GCThreadDetector - { - ~GCThreadDetector() - { - GCThread = Thread.CurrentThread; - } - } - - [MethodImpl(MethodImplOptions.NoInlining)] - static void Spawn() => new GCThreadDetector(); - - static UnmanagedBlob() - { - Spawn(); - GC.WaitForPendingFinalizers(); - } - -#endif - - public UnmanagedBlob(StandardRuntimePlatform plat, int size) - { - if (size <= 0) - throw new ArgumentException("Positive number required", nameof(size)); - _plat = plat; - _address = plat.Alloc(size); - GC.AddMemoryPressure(size); - Size = size; -#if DEBUG - _backtrace = Environment.StackTrace; - lock (_btlock) - Backtraces.Add(_backtrace); -#endif - } - - void DoDispose() - { - lock (_lock) - { - if (!IsDisposed) - { -#if DEBUG - lock (_btlock) - Backtraces.Remove(_backtrace); -#endif - _plat?.Free(_address, Size); - GC.RemoveMemoryPressure(Size); - IsDisposed = true; - _address = IntPtr.Zero; - Size = 0; - } - } - } - - public void Dispose() - { -#if DEBUG - if (Thread.CurrentThread.ManagedThreadId == GCThread?.ManagedThreadId) - { - lock (_lock) - { - if (!IsDisposed) - { - Console.Error.WriteLine("Native blob disposal from finalizer thread\nBacktrace: " - + Environment.StackTrace - + "\n\nBlob created by " + _backtrace); - } - } - } -#endif - DoDispose(); - GC.SuppressFinalize(this); - } - - ~UnmanagedBlob() - { -#if DEBUG - Console.Error.WriteLine("Undisposed native blob created by " + _backtrace); -#endif - DoDispose(); - } - - public IntPtr Address => IsDisposed ? throw new ObjectDisposedException("UnmanagedBlob") : _address; - public int Size { get; private set; } - public bool IsDisposed { get; private set; } - } - - - -#if NET461 || NETCOREAPP2_0 - [DllImport("libc", SetLastError = true)] - private static extern IntPtr mmap(IntPtr addr, IntPtr length, int prot, int flags, int fd, IntPtr offset); - [DllImport("libc", SetLastError = true)] - private static extern int munmap(IntPtr addr, IntPtr length); - [DllImport("libc", SetLastError = true)] - private static extern long sysconf(int name); - - private bool? _useMmap; - private bool UseMmap - => _useMmap ?? ((_useMmap = GetRuntimeInfo().OperatingSystem == OperatingSystemType.Linux)).Value; - - IntPtr Alloc(int size) - { - if (UseMmap) - { - var rv = mmap(IntPtr.Zero, new IntPtr(size), 3, 0x22, -1, IntPtr.Zero); - if (rv.ToInt64() == -1 || (ulong) rv.ToInt64() == 0xffffffff) - { - var errno = Marshal.GetLastWin32Error(); - throw new Exception("Unable to allocate memory: " + errno); - } - return rv; - } - else - return Marshal.AllocHGlobal(size); - } - - void Free(IntPtr ptr, int len) - { - if (UseMmap) - { - if (munmap(ptr, new IntPtr(len)) == -1) - { - var errno = Marshal.GetLastWin32Error(); - throw new Exception("Unable to free memory: " + errno); - } - } - else - Marshal.FreeHGlobal(ptr); - } -#else - IntPtr Alloc(int size) => Marshal.AllocHGlobal(size); - void Free(IntPtr ptr, int len) => Marshal.FreeHGlobal(ptr); -#endif - - - } - - internal class IOSLoader : IDynamicLibraryLoader - { - IntPtr IDynamicLibraryLoader.LoadLibrary(string dll) - { - throw new PlatformNotSupportedException(); - } - - IntPtr IDynamicLibraryLoader.GetProcAddress(IntPtr dll, string proc, bool optional) - { - throw new PlatformNotSupportedException(); - } - } - - public class AssetLoader : IAssetLoader - { - private const string AvaloniaResourceName = "!AvaloniaResources"; - private static readonly Dictionary AssemblyNameCache - = new Dictionary(); - - private AssemblyDescriptor _defaultResmAssembly; - - /// - /// Initializes a new instance of the class. - /// - /// - /// The default assembly from which to load resm: assets for which no assembly is specified. - /// - public AssetLoader(Assembly assembly = null) - { - if (assembly == null) - assembly = Assembly.GetEntryAssembly(); - if (assembly != null) - _defaultResmAssembly = new AssemblyDescriptor(assembly); - } - - /// - /// Sets the default assembly from which to load assets for which no assembly is specified. - /// - /// The default assembly. - public void SetDefaultAssembly(Assembly assembly) - { - _defaultResmAssembly = new AssemblyDescriptor(assembly); - } - - /// - /// Checks if an asset with the specified URI exists. - /// - /// The URI. - /// - /// A base URI to use if is relative. - /// - /// True if the asset could be found; otherwise false. - public bool Exists(Uri uri, Uri baseUri = null) - { - return GetAsset(uri, baseUri) != null; - } - - /// - /// Opens the asset with the requested URI. - /// - /// The URI. - /// - /// A base URI to use if is relative. - /// - /// A stream containing the asset contents. - /// - /// The asset could not be found. - /// - public Stream Open(Uri uri, Uri baseUri = null) => OpenAndGetAssembly(uri, baseUri).Item1; - - /// - /// Opens the asset with the requested URI and returns the asset stream and the - /// assembly containing the asset. - /// - /// The URI. - /// - /// A base URI to use if is relative. - /// - /// - /// The stream containing the resource contents together with the assembly. - /// - /// - /// The asset could not be found. - /// - public (Stream stream, Assembly assembly) OpenAndGetAssembly(Uri uri, Uri baseUri = null) - { - var asset = GetAsset(uri, baseUri); - - if (asset == null) - { - throw new FileNotFoundException($"The resource {uri} could not be found."); - } - - return (asset.GetStream(), asset.Assembly); - } - - public Assembly GetAssembly(Uri uri, Uri baseUri) - { - if (!uri.IsAbsoluteUri && baseUri != null) - uri = new Uri(baseUri, uri); - return GetAssembly(uri).Assembly; - } - - /// - /// Gets all assets of a folder and subfolders that match specified uri. - /// - /// The URI. - /// Base URI that is used if is relative. - /// All matching assets as a tuple of the absolute path to the asset and the assembly containing the asset - public IEnumerable GetAssets(Uri uri, Uri baseUri) - { - if (uri.IsAbsoluteUri && uri.Scheme == "resm") - { - var assembly = GetAssembly(uri); - - return assembly?.Resources.Where(x => x.Key.Contains(uri.AbsolutePath)) - .Select(x =>new Uri($"resm:{x.Key}?assembly={assembly.Name}")) ?? - Enumerable.Empty(); - } - - uri = EnsureAbsolute(uri, baseUri); - if (uri.Scheme == "avares") - { - var (asm, path) = GetResAsmAndPath(uri); - if (asm == null) - { - throw new ArgumentException( - "No default assembly, entry assembly or explicit assembly specified; " + - "don't know where to look up for the resource, try specifying assembly explicitly."); - } - - if (asm?.AvaloniaResources == null) - return Enumerable.Empty(); - path = path.TrimEnd('/') + '/'; - return asm.AvaloniaResources.Where(r => r.Key.StartsWith(path)) - .Select(x => new Uri($"avares://{asm.Name}{x.Key}")); - } - - return Enumerable.Empty(); - } - - private Uri EnsureAbsolute(Uri uri, Uri baseUri) - { - if (uri.IsAbsoluteUri) - return uri; - if(baseUri == null) - throw new ArgumentException($"Relative uri {uri} without base url"); - if (!baseUri.IsAbsoluteUri) - throw new ArgumentException($"Base uri {baseUri} is relative"); - if (baseUri.Scheme == "resm") - throw new ArgumentException( - $"Relative uris for 'resm' scheme aren't supported; {baseUri} uses resm"); - return new Uri(baseUri, uri); - } - - private IAssetDescriptor GetAsset(Uri uri, Uri baseUri) - { - if (uri.IsAbsoluteUri && uri.Scheme == "resm") - { - var asm = GetAssembly(uri) ?? GetAssembly(baseUri) ?? _defaultResmAssembly; - - if (asm == null) - { - throw new ArgumentException( - "No default assembly, entry assembly or explicit assembly specified; " + - "don't know where to look up for the resource, try specifying assembly explicitly."); - } - - IAssetDescriptor rv; - - var resourceKey = uri.AbsolutePath; - asm.Resources.TryGetValue(resourceKey, out rv); - return rv; - } - - uri = EnsureAbsolute(uri, baseUri); - - if (uri.Scheme == "avares") - { - var (asm, path) = GetResAsmAndPath(uri); - if (asm.AvaloniaResources == null) - return null; - asm.AvaloniaResources.TryGetValue(path, out var desc); - return desc; - } - - throw new ArgumentException($"Unsupported url type: " + uri.Scheme, nameof(uri)); - } - - private (AssemblyDescriptor asm, string path) GetResAsmAndPath(Uri uri) - { - var asm = GetAssembly(uri.Authority); - return (asm, uri.AbsolutePath); - } - - private AssemblyDescriptor GetAssembly(Uri uri) - { - if (uri != null) - { - if (!uri.IsAbsoluteUri) - return null; - if (uri.Scheme == "avares") - return GetResAsmAndPath(uri).asm; - - if (uri.Scheme == "resm") - { - var qs = ParseQueryString(uri); - string assemblyName; - - if (qs.TryGetValue("assembly", out assemblyName)) - { - return GetAssembly(assemblyName); - } - } - } - - return null; - } - - private AssemblyDescriptor GetAssembly(string name) - { - if (name == null) - throw new ArgumentNullException(nameof(name)); - - AssemblyDescriptor rv; - if (!AssemblyNameCache.TryGetValue(name, out rv)) - { - var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); - var match = loadedAssemblies.FirstOrDefault(a => a.GetName().Name == name); - if (match != null) - { - AssemblyNameCache[name] = rv = new AssemblyDescriptor(match); - } - else - { - // iOS does not support loading assemblies dynamically! - // -#if __IOS__ - throw new InvalidOperationException( - $"Assembly {name} needs to be referenced and explicitly loaded before loading resources"); -#else - name = Uri.UnescapeDataString(name); - AssemblyNameCache[name] = rv = new AssemblyDescriptor(Assembly.Load(name)); -#endif - } - } - - return rv; - } - - private Dictionary ParseQueryString(Uri uri) - { - return uri.Query.TrimStart('?') - .Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries) - .Select(p => p.Split('=')) - .ToDictionary(p => p[0], p => p[1]); - } - - private interface IAssetDescriptor - { - Stream GetStream(); - Assembly Assembly { get; } - } - - private class AssemblyResourceDescriptor : IAssetDescriptor - { - private readonly Assembly _asm; - private readonly string _name; - - public AssemblyResourceDescriptor(Assembly asm, string name) - { - _asm = asm; - _name = name; - } - - public Stream GetStream() - { - return _asm.GetManifestResourceStream(_name); - } - - public Assembly Assembly => _asm; - } - - private class AvaloniaResourceDescriptor : IAssetDescriptor - { - private readonly int _offset; - private readonly int _length; - public Assembly Assembly { get; } - - public AvaloniaResourceDescriptor(Assembly asm, int offset, int length) - { - _offset = offset; - _length = length; - Assembly = asm; - } - - public Stream GetStream() - { - return new SlicedStream(Assembly.GetManifestResourceStream(AvaloniaResourceName), _offset, _length); - } - } - - class SlicedStream : Stream - { - private readonly Stream _baseStream; - private readonly int _from; - - public SlicedStream(Stream baseStream, int from, int length) - { - Length = length; - _baseStream = baseStream; - _from = from; - _baseStream.Position = from; - } - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - return _baseStream.Read(buffer, offset, (int)Math.Min(count, Length - Position)); - } - - public override long Seek(long offset, SeekOrigin origin) - { - if (origin == SeekOrigin.Begin) - Position = offset; - if (origin == SeekOrigin.End) - Position = _from + Length + offset; - if (origin == SeekOrigin.Current) - Position = Position + offset; - return Position; - } - - public override void SetLength(long value) => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override bool CanRead => true; - public override bool CanSeek => _baseStream.CanRead; - public override bool CanWrite => false; - public override long Length { get; } - public override long Position - { - get => _baseStream.Position - _from; - set => _baseStream.Position = value + _from; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - _baseStream.Dispose(); - } - - public override void Close() => _baseStream.Close(); - } - - private class AssemblyDescriptor - { - public AssemblyDescriptor(Assembly assembly) - { - Assembly = assembly; - - if (assembly != null) - { - Resources = assembly.GetManifestResourceNames() - .ToDictionary(n => n, n => (IAssetDescriptor)new AssemblyResourceDescriptor(assembly, n)); - Name = assembly.GetName().Name; - using (var resources = assembly.GetManifestResourceStream(AvaloniaResourceName)) - { - if (resources != null) - { - Resources.Remove(AvaloniaResourceName); - - var indexLength = new BinaryReader(resources).ReadInt32(); - var index = AvaloniaResourcesIndexReaderWriter.Read(new SlicedStream(resources, 4, indexLength)); - var baseOffset = indexLength + 4; - AvaloniaResources = index.ToDictionary(r => "/" + r.Path.TrimStart('/'), r => (IAssetDescriptor) - new AvaloniaResourceDescriptor(assembly, baseOffset + r.Offset, r.Size)); - } - } - } - } - - public Assembly Assembly { get; } - public Dictionary Resources { get; } - public Dictionary AvaloniaResources { get; } - public string Name { get; } - } - - public static void RegisterResUriParsers() - { - if (!UriParser.IsKnownScheme("avares")) - UriParser.Register(new GenericUriParser( - GenericUriParserOptions.GenericAuthority | - GenericUriParserOptions.NoUserInfo | - GenericUriParserOptions.NoPort | - GenericUriParserOptions.NoQuery | - GenericUriParserOptions.NoFragment), "avares", -1); - } - } -} \ No newline at end of file diff --git a/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs b/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs index 363a4fabaf..6f04bb5206 100644 --- a/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs +++ b/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs @@ -1,9 +1,7 @@ using System; using Avalonia.Controls; -using Avalonia.Markup.Xaml; -using Avalonia.Markup.Xaml.Styling; using Avalonia.Platform; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; using Avalonia.Styling; using Avalonia.UnitTests; using BenchmarkDotNet.Attributes; diff --git a/tests/Avalonia.Benchmarks/Themes/ThemeBenchmark.cs b/tests/Avalonia.Benchmarks/Themes/ThemeBenchmark.cs index 79a11d0cea..81264f109c 100644 --- a/tests/Avalonia.Benchmarks/Themes/ThemeBenchmark.cs +++ b/tests/Avalonia.Benchmarks/Themes/ThemeBenchmark.cs @@ -2,7 +2,7 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml.Styling; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; using Avalonia.Styling; using Avalonia.UnitTests; diff --git a/tests/Avalonia.RenderTests/TestBase.cs b/tests/Avalonia.RenderTests/TestBase.cs index a0fbf704cf..b70c721085 100644 --- a/tests/Avalonia.RenderTests/TestBase.cs +++ b/tests/Avalonia.RenderTests/TestBase.cs @@ -24,7 +24,7 @@ namespace Avalonia.Skia.RenderTests namespace Avalonia.Direct2D1.RenderTests #endif { - using Avalonia.Shared.PlatformSupport; + using Avalonia.PlatformSupport; public class TestBase { diff --git a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj index f5e502bca8..8b37fa1b41 100644 --- a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj +++ b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj @@ -13,6 +13,7 @@ + @@ -25,7 +26,6 @@ - diff --git a/tests/Avalonia.UnitTests/RuntimeInfo.cs b/tests/Avalonia.UnitTests/RuntimeInfo.cs deleted file mode 100644 index eb5781a725..0000000000 --- a/tests/Avalonia.UnitTests/RuntimeInfo.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Avalonia.Platform; -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Text; - -namespace Avalonia.Shared.PlatformSupport -{ - internal partial class StandardRuntimePlatform : IRuntimePlatform - { - public RuntimePlatformInfo GetRuntimeInfo() - { - return new RuntimePlatformInfo(); - } - } -} diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index da678fd74b..e5cea4823f 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -5,7 +5,7 @@ using Avalonia.Layout; using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Platform; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; using Avalonia.Styling; using Avalonia.Themes.Default; using Avalonia.Rendering; diff --git a/tests/Avalonia.UnitTests/UnitTestApplication.cs b/tests/Avalonia.UnitTests/UnitTestApplication.cs index dc10754ea2..bb6e53d74f 100644 --- a/tests/Avalonia.UnitTests/UnitTestApplication.cs +++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs @@ -10,7 +10,7 @@ using System.Reactive.Disposables; using System.Reactive.Concurrency; using Avalonia.Input.Platform; using Avalonia.Animation; -using Avalonia.Shared.PlatformSupport; +using Avalonia.PlatformSupport; namespace Avalonia.UnitTests { From eaa62b782824915687dcc8ddefe1746db8d06776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 30 Jan 2022 13:51:19 +0100 Subject: [PATCH 06/18] Set combobox items from xaml on ViewboxPage --- samples/ControlCatalog/Pages/ViewboxPage.xaml | 21 +++++++++++++++---- .../ControlCatalog/Pages/ViewboxPage.xaml.cs | 19 ----------------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/samples/ControlCatalog/Pages/ViewboxPage.xaml b/samples/ControlCatalog/Pages/ViewboxPage.xaml index 81e5046636..ac52a6d0ee 100644 --- a/samples/ControlCatalog/Pages/ViewboxPage.xaml +++ b/samples/ControlCatalog/Pages/ViewboxPage.xaml @@ -12,8 +12,8 @@ + Stretch="{Binding #StretchSelector.SelectedItem.Content, FallbackValue={x:Static Stretch.Uniform}}" + StretchDirection="{Binding #StretchDirectionSelector.SelectedItem.Content, FallbackValue={x:Static StretchDirection.Both}}"> @@ -25,9 +25,22 @@ - + + + + + + + + - + + + + + + + diff --git a/samples/ControlCatalog/Pages/ViewboxPage.xaml.cs b/samples/ControlCatalog/Pages/ViewboxPage.xaml.cs index 94b3f3ea14..12b5086e12 100644 --- a/samples/ControlCatalog/Pages/ViewboxPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ViewboxPage.xaml.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; -using Avalonia.Media; namespace ControlCatalog.Pages { @@ -9,24 +8,6 @@ namespace ControlCatalog.Pages public ViewboxPage() { InitializeComponent(); - - var stretchSelector = this.FindControl("StretchSelector"); - - stretchSelector.Items = new[] - { - Stretch.Uniform, Stretch.UniformToFill, Stretch.Fill, Stretch.None - }; - - stretchSelector.SelectedIndex = 0; - - var stretchDirectionSelector = this.FindControl("StretchDirectionSelector"); - - stretchDirectionSelector.Items = new[] - { - StretchDirection.Both, StretchDirection.DownOnly, StretchDirection.UpOnly - }; - - stretchDirectionSelector.SelectedIndex = 0; } private void InitializeComponent() From 979a5aa43758fba3dad4871245cedabd3fc398c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 30 Jan 2022 21:39:43 +0100 Subject: [PATCH 07/18] Set Border clip from xaml on ClippingPage --- samples/RenderDemo/Pages/ClippingPage.xaml | 54 ++++++++++--------- samples/RenderDemo/Pages/ClippingPage.xaml.cs | 17 ------ 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/samples/RenderDemo/Pages/ClippingPage.xaml b/samples/RenderDemo/Pages/ClippingPage.xaml index 10225f7c49..698e18d496 100644 --- a/samples/RenderDemo/Pages/ClippingPage.xaml +++ b/samples/RenderDemo/Pages/ClippingPage.xaml @@ -19,30 +19,36 @@ - + + + + diff --git a/samples/RenderDemo/Pages/ClippingPage.xaml.cs b/samples/RenderDemo/Pages/ClippingPage.xaml.cs index 5357181838..c5b669343a 100644 --- a/samples/RenderDemo/Pages/ClippingPage.xaml.cs +++ b/samples/RenderDemo/Pages/ClippingPage.xaml.cs @@ -1,35 +1,18 @@ -using System; -using System.Reactive.Linq; -using Avalonia; -using Avalonia.Animation; using Avalonia.Controls; -using Avalonia.Data; using Avalonia.Markup.Xaml; -using Avalonia.Media; namespace RenderDemo.Pages { public class ClippingPage : UserControl { - private Geometry _clip; - public ClippingPage() { InitializeComponent(); - WireUpCheckbox(); } private void InitializeComponent() { AvaloniaXamlLoader.Load(this); } - - private void WireUpCheckbox() - { - var useMask = this.FindControl("useMask"); - var clipped = this.FindControl("clipped"); - _clip = clipped.Clip; - useMask.Click += (s, e) => clipped.Clip = clipped.Clip == null ? _clip : null; - } } } From 26eba8d80e16edb1ba5f269f2e38f344ff126423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 30 Jan 2022 23:41:39 +0100 Subject: [PATCH 08/18] Use ArrayList for items --- samples/ControlCatalog/Pages/ViewboxPage.xaml | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/samples/ControlCatalog/Pages/ViewboxPage.xaml b/samples/ControlCatalog/Pages/ViewboxPage.xaml index ac52a6d0ee..e7e3007d35 100644 --- a/samples/ControlCatalog/Pages/ViewboxPage.xaml +++ b/samples/ControlCatalog/Pages/ViewboxPage.xaml @@ -1,5 +1,6 @@ @@ -12,8 +13,8 @@ + Stretch="{Binding #StretchSelector.SelectedItem, FallbackValue={x:Static Stretch.Uniform}}" + StretchDirection="{Binding #StretchDirectionSelector.SelectedItem, FallbackValue={x:Static StretchDirection.Both}}"> @@ -25,21 +26,21 @@ - - - - - - - - + + + Uniform + UniformToFill + Fill + None + + - - - - - - + + + Both + DownOnly + UpOnly + From 888cc331705bc11c2b189799d4a00756366c144a Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 31 Jan 2022 13:51:28 +0100 Subject: [PATCH 09/18] Fix failing unit tests in debug mode. #7369 introduced a validating layer over `WindowBase.PlatformImpl` that is only enabled in debug mode. This validating layer was causing unit tests to fail in debug mode but not on CI which runs tests in release mode. Fix the problems: - `PopupRoot` was not correctly disposing itself on `Dispose` - make it call `HandleClosed` in order to perform the same steps as when the popup is closed via other means - Some unit tests try to access the `PlatformImpl` to get hold of a mock. Added `ValidatingWindowImpl.Unwrap` to allow this - `ValidatingWindowBaseImpl.Activated` was setting the wrong property on the wrapped `PlatformImpl`. --- src/Avalonia.Controls/Primitives/PopupRoot.cs | 2 +- src/Avalonia.Controls/Properties/AssemblyInfo.cs | 1 + src/Avalonia.Controls/ValidatingToplevel.cs | 9 ++++++++- tests/Avalonia.Controls.UnitTests/WindowTests.cs | 2 +- tests/Avalonia.LeakTests/ControlTests.cs | 4 ++-- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index c59c7c11d9..9d26eb05f3 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -74,7 +74,7 @@ namespace Avalonia.Controls.Primitives IStyleHost IStyleHost.StylingParent => Parent; /// - public void Dispose() => PlatformImpl?.Dispose(); + public void Dispose() => HandleClosed(); private void UpdatePosition() { diff --git a/src/Avalonia.Controls/Properties/AssemblyInfo.cs b/src/Avalonia.Controls/Properties/AssemblyInfo.cs index d1743611cd..05561a38ef 100644 --- a/src/Avalonia.Controls/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Controls/Properties/AssemblyInfo.cs @@ -3,6 +3,7 @@ using Avalonia.Metadata; [assembly: InternalsVisibleTo("Avalonia.Controls.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.DesignerSupport, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] +[assembly: InternalsVisibleTo("Avalonia.LeakTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls")] diff --git a/src/Avalonia.Controls/ValidatingToplevel.cs b/src/Avalonia.Controls/ValidatingToplevel.cs index d1edd28ebc..4fb9b7c99f 100644 --- a/src/Avalonia.Controls/ValidatingToplevel.cs +++ b/src/Avalonia.Controls/ValidatingToplevel.cs @@ -184,7 +184,7 @@ internal class ValidatingWindowBaseImpl : ValidatingToplevelImpl, IWindowBaseImp public Action Activated { get => Inner.Activated; - set => Inner.Deactivated = value; + set => Inner.Activated = value; } public IPlatformHandle Handle => Inner.Handle; @@ -211,6 +211,13 @@ internal class ValidatingWindowImpl : ValidatingWindowBaseImpl, IWindowImpl } } + public static IWindowImpl Unwrap(IWindowImpl impl) + { + if (impl is ValidatingWindowImpl v) + return v.Inner; + return impl; + } + public static IWindowImpl Wrap(IWindowImpl impl) { #if DEBUG diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index eb128ef038..4166242455 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -821,7 +821,7 @@ namespace Avalonia.Controls.UnitTests target.Width = 410; target.LayoutManager.ExecuteLayoutPass(); - var windowImpl = Mock.Get(target.PlatformImpl); + var windowImpl = Mock.Get(ValidatingWindowImpl.Unwrap(target.PlatformImpl)); windowImpl.Verify(x => x.Resize(new Size(410, 800), PlatformResizeReason.Application)); Assert.Equal(410, target.Width); } diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index 087d42370e..eed767e771 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -496,7 +496,7 @@ namespace Avalonia.LeakTests AttachShowAndDetachContextMenu(window); - Mock.Get(window.PlatformImpl).Invocations.Clear(); + Mock.Get(ValidatingWindowImpl.Unwrap(window.PlatformImpl)).Invocations.Clear(); dotMemory.Check(memory => Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); dotMemory.Check(memory => @@ -541,7 +541,7 @@ namespace Avalonia.LeakTests BuildAndShowContextMenu(window); BuildAndShowContextMenu(window); - Mock.Get(window.PlatformImpl).Invocations.Clear(); + Mock.Get(ValidatingWindowImpl.Unwrap(window.PlatformImpl)).Invocations.Clear(); dotMemory.Check(memory => Assert.Equal(initialMenuCount, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); dotMemory.Check(memory => From 8ff251ec96c0b473a344855135ab151f08d070cf Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 31 Jan 2022 15:54:30 +0100 Subject: [PATCH 10/18] Actually dispose PlatformImpl. --- src/Avalonia.Controls/Primitives/PopupRoot.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index 9d26eb05f3..517ada79f1 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -74,7 +74,11 @@ namespace Avalonia.Controls.Primitives IStyleHost IStyleHost.StylingParent => Parent; /// - public void Dispose() => HandleClosed(); + public void Dispose() + { + PlatformImpl?.Dispose(); + HandleClosed(); + } private void UpdatePosition() { From 6866ba12aaf6e0a90b921104dc5c62bc8538d693 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Mon, 31 Jan 2022 15:08:25 +0000 Subject: [PATCH 11/18] fix nuget cycle error. --- build/CoreLibraries.props | 1 + 1 file changed, 1 insertion(+) diff --git a/build/CoreLibraries.props b/build/CoreLibraries.props index fff00041c3..3fccad2641 100644 --- a/build/CoreLibraries.props +++ b/build/CoreLibraries.props @@ -17,5 +17,6 @@ + From 27c7a5c7243d8c05b2d7210bb571c4a272f0abfe Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 31 Jan 2022 16:23:03 +0100 Subject: [PATCH 12/18] Fix issues mentioned during review --- .../Presenters/TextPresenter.cs | 48 ++--- src/Avalonia.Controls/TextBox.cs | 167 +++++++++++------- .../Media/TextFormatting/FontMetrics.cs | 3 + .../Media/TextFormatting/TextCharacters.cs | 16 +- .../Media/TextFormatting/TextLayout.cs | 18 +- .../Media/TextFormatting/TextLineImpl.cs | 2 +- .../Utilities/BinarySearchExtension.cs | 15 +- .../MaskedTextBoxTests.cs | 4 +- .../TextBoxTests.cs | 4 +- .../Media/TextFormatting/TextLayoutTests.cs | 15 ++ .../BiDiPairedBracketTypeTests.cs | 7 - 11 files changed, 180 insertions(+), 119 deletions(-) delete mode 100644 tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index a0558df23c..e6f966fa3d 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -385,9 +385,8 @@ namespace Avalonia.Controls.Presenters } var (p1, p2) = GetCaretPoints(); - context.DrawLine( - new ImmutablePen(caretBrush, 1), - p1, p2); + + context.DrawLine(new ImmutablePen(caretBrush), p1, p2); } private (Point, Point) GetCaretPoints() @@ -396,13 +395,7 @@ namespace Avalonia.Controls.Presenters 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) + if (x >= Bounds.Width) { x = Math.Floor(_caretBounds.X - 1) + 0.5; } @@ -444,8 +437,6 @@ namespace Avalonia.Controls.Presenters if (IsMeasureValid) { - //var rect = TextLayout.HitTestTextPosition(caretIndex); - //_caretPosition = rect; this.BringIntoView(_caretBounds); } else @@ -456,7 +447,6 @@ namespace Avalonia.Controls.Presenters Dispatcher.UIThread.Post( () => { - //var rect = TextLayout.HitTestTextPosition(caretIndex); this.BringIntoView(_caretBounds); }, DispatcherPriority.Render); @@ -540,7 +530,7 @@ namespace Avalonia.Controls.Presenters public void MoveCaretToTextPosition(int textPosition, bool trailingEdge = false) { - var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(textPosition); + var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(textPosition, trailingEdge); var textLine = TextLayout.TextLines[lineIndex]; var characterHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(textPosition)); @@ -573,16 +563,14 @@ namespace Avalonia.Controls.Presenters public void MoveCaretVertical(LogicalDirection direction = LogicalDirection.Forward) { - var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(CaretIndex); + var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(CaretIndex, _lastCharacterHit.TrailingLength > 0); if (lineIndex < 0) { return; } - var currentX = _navigationPosition.X; - - var currentY = _navigationPosition.Y; + var (currentX, currentY) = _navigationPosition; if (direction == LogicalDirection.Forward) { @@ -607,9 +595,11 @@ namespace Avalonia.Controls.Presenters currentY -= textLine.Height; } + var navigationPosition = _navigationPosition; + MoveCaretToPoint(new Point(currentX, currentY)); - _navigationPosition = _navigationPosition.WithY(_caretBounds.Y); + _navigationPosition = navigationPosition.WithY(_caretBounds.Y); } public void MoveCaretHorizontal(LogicalDirection direction = LogicalDirection.Forward) @@ -617,7 +607,7 @@ namespace Avalonia.Controls.Presenters var characterHit = _lastCharacterHit; var caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(caretIndex); + var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(caretIndex, false); if (lineIndex < 0) { @@ -634,11 +624,23 @@ namespace Avalonia.Controls.Presenters caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - if (caretIndex - textLine.TrailingWhitespaceLength == textLine.TextRange.End) + if (textLine.NewLineLength > 0 && caretIndex == textLine.TextRange.Start + textLine.TextRange.Length) { - break; + characterHit = new CharacterHit(caretIndex); } + if (caretIndex >= Text.Length) + { + characterHit = new CharacterHit(Text.Length); + + break; + } + + if (caretIndex - textLine.NewLineLength == textLine.TextRange.Start + textLine.TextRange.Length) + { + break; + } + if (caretIndex <= CaretIndex) { lineIndex++; @@ -681,7 +683,7 @@ namespace Avalonia.Controls.Presenters var caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(caretIndex); + var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(caretIndex, characterHit.TrailingLength > 0); var textLine = TextLayout.TextLines[lineIndex]; var distanceX = textLine.GetDistanceFromCharacterHit(characterHit); diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index cf5427732b..08a268f763 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -307,7 +307,7 @@ namespace Avalonia.Controls if (SelectionStart == SelectionEnd) { - CaretIndex = SelectionEnd; + CaretIndex = SelectionStart; } } } @@ -328,11 +328,6 @@ namespace Avalonia.Controls { UpdateCommandStates(); } - - if (SelectionStart == SelectionEnd) - { - CaretIndex = SelectionEnd; - } } } @@ -760,6 +755,11 @@ namespace Avalonia.Controls protected override void OnKeyDown(KeyEventArgs e) { + if (_presenter == null) + { + return; + } + var text = Text ?? string.Empty; var caretIndex = CaretIndex; var movement = false; @@ -905,25 +905,45 @@ namespace Avalonia.Controls break; case Key.Up: - _presenter?.MoveCaretVertical(LogicalDirection.Backward); - if (caretIndex != CaretIndex) + { + selection = DetectSelection(); + + _presenter.MoveCaretVertical(LogicalDirection.Backward); + + if (caretIndex != _presenter.CaretIndex) { movement = true; } - selection = DetectSelection(); - break; + if (selection) + { + SelectionEnd = _presenter.CaretIndex; + } + + break; + } case Key.Down: - _presenter?.MoveCaretVertical(LogicalDirection.Forward); - if (caretIndex != CaretIndex) + { + selection = DetectSelection(); + + _presenter?.MoveCaretVertical(); + + if (caretIndex != _presenter.CaretIndex) { movement = true; } - selection = DetectSelection(); + + if (selection) + { + SelectionEnd = _presenter.CaretIndex; + } + break; - + } case Key.Back: + { SnapshotUndoRedo(); + if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { SetSelectionForControlBackspace(); @@ -952,15 +972,18 @@ namespace Avalonia.Controls SetTextInternal(text.Substring(0, length) + text.Substring(caretIndex)); + CaretIndex = caretIndex - removedCharacters; + ClearSelection(); } handled = true; break; - + } case Key.Delete: SnapshotUndoRedo(); + if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { SetSelectionForControlDelete(); @@ -968,16 +991,16 @@ namespace Avalonia.Controls if (!DeleteSelection() && caretIndex < text.Length) { - _presenter.MoveCaretHorizontal(); + _presenter.MoveCaretHorizontal(); - var removedCharacters = Math.Max(0, _presenter.CaretIndex - caretIndex); + var removedCharacters = Math.Max(0, _presenter.CaretIndex - caretIndex); - SetTextInternal(text.Substring(0, caretIndex) + - text.Substring(caretIndex + removedCharacters)); - - CaretIndex = caretIndex; + SetTextInternal(text.Substring(0, caretIndex) + + text.Substring(caretIndex + removedCharacters)); + + CaretIndex = caretIndex; } - + SnapshotUndoRedo(); handled = true; @@ -1017,11 +1040,7 @@ namespace Avalonia.Controls } } - if (movement && selection) - { - SelectionEnd = CaretIndex; - } - else if (movement) + if (movement && !selection) { ClearSelection(); } @@ -1034,23 +1053,29 @@ namespace Avalonia.Controls protected override void OnPointerPressed(PointerPressedEventArgs e) { + if (_presenter == null) + { + return; + } + var text = Text; - var clickInfo = e.GetCurrentPoint(this); - if (text != null && clickInfo.Properties.IsLeftButtonPressed && !(clickInfo.Pointer?.Captured is Border)) + + if (text != null && clickInfo.Properties.IsLeftButtonPressed && + !(clickInfo.Pointer?.Captured is Border)) { var point = e.GetPosition(_presenter); 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 @@ -1065,6 +1090,7 @@ namespace Avalonia.Controls { SelectionStart = SelectionEnd = index; } + break; case 2: if (!StringUtils.IsStartOfWord(text, index)) @@ -1086,8 +1112,13 @@ namespace Avalonia.Controls protected override void OnPointerMoved(PointerEventArgs e) { + if (_presenter == null) + { + return; + } + // selection should not change during pointer move if the user right clicks - if (_presenter != null && e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + if (e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { var point = e.GetPosition(_presenter); @@ -1095,7 +1126,7 @@ 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))); - _presenter?.MoveCaretToPoint(point); + _presenter.MoveCaretToPoint(point); SelectionEnd = _presenter.CaretIndex; } @@ -1103,27 +1134,37 @@ namespace Avalonia.Controls protected override void OnPointerReleased(PointerReleasedEventArgs e) { - if (_presenter != null && e.Pointer.Captured == _presenter) + if (_presenter == null) + { + return; + } + + if (e.Pointer.Captured != _presenter) + { + return; + } + + if (e.InitialPressMouseButton == MouseButton.Right) { - if (e.InitialPressMouseButton == MouseButton.Right) + var point = e.GetPosition(_presenter); + + _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 + var firstSelection = Math.Min(SelectionStart, SelectionEnd); + var lastSelection = Math.Max(SelectionStart, SelectionEnd); + var didClickInSelection = SelectionStart != SelectionEnd && + caretIndex >= firstSelection && caretIndex <= lastSelection; + if (!didClickInSelection) { - var point = e.GetPosition(_presenter); - _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 - var firstSelection = Math.Min(SelectionStart, SelectionEnd); - var lastSelection = Math.Max(SelectionStart, SelectionEnd); - var didClickInSelection = SelectionStart != SelectionEnd && - caretIndex >= firstSelection && caretIndex <= lastSelection; - if (!didClickInSelection) - { - CaretIndex = SelectionEnd = SelectionStart = caretIndex; - } + CaretIndex = SelectionEnd = SelectionStart = caretIndex; } - e.Pointer.Capture(null); } + + e.Pointer.Capture(null); } protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value) @@ -1170,31 +1211,37 @@ namespace Avalonia.Controls private void MoveHorizontal(int direction, bool wholeWord, bool isSelecting) { var text = Text ?? string.Empty; - var caretIndex = CaretIndex; + var selectionStart = SelectionStart; if (!wholeWord) { - if (SelectionStart != SelectionEnd && !isSelecting) + if (_presenter == null) { - var start = Math.Min(SelectionStart, SelectionEnd); - var end = Math.Max(SelectionStart, SelectionEnd); - CaretIndex = direction < 0 ? start : end; return; } - + _presenter.MoveCaretHorizontal(direction > 0 ? LogicalDirection.Forward : LogicalDirection.Backward); + + if (isSelecting) + { + SelectionEnd = _presenter.CaretIndex; + } + else + { + SelectionStart = SelectionEnd = _presenter.CaretIndex; + } } else { if (direction > 0) { - var offset = StringUtils.NextWord(text, caretIndex) - caretIndex; + var offset = StringUtils.NextWord(text, selectionStart) - selectionStart; CaretIndex += offset; } else { - var offset = StringUtils.PreviousWord(text, caretIndex) - caretIndex; + var offset = StringUtils.PreviousWord(text, selectionStart) - selectionStart; CaretIndex += offset; } @@ -1277,7 +1324,7 @@ namespace Avalonia.Controls caretIndex = pos; } - CaretIndex = text.Length; + CaretIndex = caretIndex; } /// diff --git a/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs b/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs index c3e63739b7..e01bba00a4 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs @@ -30,6 +30,9 @@ StrikethroughPosition = glyphTypeface.StrikethroughPosition * scale; } + /// + /// Em size of font used to format and display text + /// public double FontRenderingEmSize { get; } /// diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs index e287238f82..faa73719c8 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs @@ -69,13 +69,11 @@ namespace Avalonia.Media.TextFormatting TextRunProperties defaultProperties, sbyte biDiLevel, ref TextRunProperties? previousProperties) { var defaultTypeface = defaultProperties.Typeface; - var currentTypeface = defaultTypeface; + var previousTypeface = previousProperties?.Typeface; if (TryGetShapeableLength(text, currentTypeface, out var count, out var script)) { - var previousTypeface = previousProperties?.Typeface; - if (script == Script.Common && previousTypeface is not null) { if(TryGetShapeableLength(text, previousTypeface.Value, out var fallbackCount, out _)) @@ -90,6 +88,16 @@ namespace Avalonia.Media.TextFormatting new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize, defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel); } + + if (previousTypeface is not null) + { + if(TryGetShapeableLength(text, previousTypeface.Value, out count, out _)) + { + return new ShapeableTextCharacters(text.Take(count), + new GenericTextRunProperties(previousTypeface.Value, defaultProperties.FontRenderingEmSize, + defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel); + } + } var codepoint = Codepoint.ReplacementCodepoint; @@ -176,7 +184,7 @@ namespace Avalonia.Media.TextFormatting if (currentScript != script) { if (script is Script.Unknown || currentScript != Script.Common && - (script is Script.Common || script is Script.Inherited)) + script is Script.Common or Script.Inherited) { script = currentScript; } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 0cc09e8259..08821c3523 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -10,7 +10,7 @@ namespace Avalonia.Media.TextFormatting /// public class TextLayout { - private static readonly char[] s_empty = { '\u200B' }; + private static readonly char[] s_empty = { ' ' }; private readonly ReadOnlySlice _text; private readonly TextParagraphProperties _paragraphProperties; @@ -378,14 +378,14 @@ namespace Avalonia.Media.TextFormatting } - public int GetLineIndexFromCharacterIndex(int charIndex) + public int GetLineIndexFromCharacterIndex(int charIndex, bool trailingEdge) { if (charIndex < 0) { - return -1; + return 0; } - if (charIndex > _text.Length - 1) + if (charIndex > _text.Length) { return TextLines.Count - 1; } @@ -399,7 +399,7 @@ namespace Avalonia.Media.TextFormatting continue; } - if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.Start + textLine.TextRange.Length) + if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.End + (trailingEdge ? 1 : 0)) { return index; } @@ -430,7 +430,13 @@ namespace Avalonia.Media.TextFormatting { textPosition -= textLine.NewLineLength; } - + + if (textLine.NewLineLength > 0 && textPosition + textLine.NewLineLength == + characterHit.FirstCharacterIndex + characterHit.TrailingLength) + { + characterHit = new CharacterHit(characterHit.FirstCharacterIndex); + } + return new TextHitTestResult(characterHit, textPosition, isInside, isTrailing); } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs index 66666457f7..53e44de779 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs @@ -557,7 +557,7 @@ namespace Avalonia.Media.TextFormatting var characterIndex = codepointIndex - run.Text.Start; - if (characterIndex < 0 && characterHit.TrailingLength == 0) + if (characterIndex < 0 && run.ShapedBuffer.IsLeftToRight) { foundCharacterHit = new CharacterHit(foundCharacterHit.FirstCharacterIndex); } diff --git a/src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs b/src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs index 2ab78049fb..a4f6ae89c1 100644 --- a/src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs +++ b/src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs @@ -22,7 +22,7 @@ namespace Avalonia.Utilities /// /// Extension methods for binary searching an IReadOnlyList collection /// - public static class BinarySearchExtension + internal static class BinarySearchExtension { private static int GetMedian(int low, int hi) { @@ -31,18 +31,6 @@ namespace Avalonia.Utilities return low + (hi - low >> 1); } - /// - /// Performs a binary search on the entire contents of an IReadOnlyList - /// - /// The list element type - /// The list to be searched - /// The value to search for - /// The index of the found item; otherwise the bitwise complement of the index of the next larger item - public static int BinarySearch(this IReadOnlyList list, T value) where T : IComparable - { - return list.BinarySearch(value, Comparer.Default); - } - /// /// Performs a binary search on the entire contents of an IReadOnlyList /// @@ -60,7 +48,6 @@ namespace Avalonia.Utilities /// Performs a binary search on a a subset of an IReadOnlyList /// /// The list element type - /// The value type being searched for /// The list to be searched /// The start of the range to be searched /// The length of the range to be searched diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs index 7115c9c144..f06eb61a92 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs @@ -907,14 +907,14 @@ namespace Avalonia.Controls.UnitTests Name = "PART_TextPresenter", [!!TextPresenter.TextProperty] = new Binding { - Path = "Text", + Path = nameof(TextPresenter.Text), Mode = BindingMode.TwoWay, Priority = BindingPriority.TemplatedParent, RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), }, [!!TextPresenter.CaretIndexProperty] = new Binding { - Path = "CaretIndex", + Path = nameof(TextPresenter.CaretIndex), Mode = BindingMode.TwoWay, Priority = BindingPriority.TemplatedParent, RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index e9b00f9bb1..cebae0a5e4 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -885,14 +885,14 @@ namespace Avalonia.Controls.UnitTests Name = "PART_TextPresenter", [!!TextPresenter.TextProperty] = new Binding { - Path = "Text", + Path = nameof(TextPresenter.Text), Mode = BindingMode.TwoWay, Priority = BindingPriority.TemplatedParent, RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), }, [!!TextPresenter.CaretIndexProperty] = new Binding { - Path = "CaretIndex", + Path = nameof(TextPresenter.CaretIndex), Mode = BindingMode.TwoWay, Priority = BindingPriority.TemplatedParent, RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index ad9dc65411..b525931061 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -829,6 +829,21 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } } + + [Fact] + public void Should_Layout_Empty_String() + { + using (Start()) + { + var layout = new TextLayout( + string.Empty, + Typeface.Default, + 12, + Brushes.Black); + + Assert.True(layout.Size.Height > 0); + } + } private static IDisposable Start() { diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs deleted file mode 100644 index f90fe10ef9..0000000000 --- a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiPairedBracketTypeTests.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Avalonia.Visuals.UnitTests.Media.TextFormatting -{ - public class BiDiPairedBracketTypeTests - { - - } -} From c640bc21c68a6566c5ae6fea698cce5ba3657e7d Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 31 Jan 2022 16:53:31 +0100 Subject: [PATCH 13/18] Fix failing tests --- src/Avalonia.Controls/TextBox.cs | 7 ++++++- tests/Avalonia.Controls.UnitTests/TextBoxTests.cs | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 08a268f763..ee7346b747 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -305,7 +305,7 @@ namespace Avalonia.Controls UpdateCommandStates(); } - if (SelectionStart == SelectionEnd) + if (value == SelectionEnd) { CaretIndex = SelectionStart; } @@ -328,6 +328,11 @@ namespace Avalonia.Controls { UpdateCommandStates(); } + + if (value == SelectionStart) + { + CaretIndex = value; + } } } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index cebae0a5e4..c0c9e841f4 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -136,6 +136,8 @@ namespace Avalonia.Controls.UnitTests Template = CreateTemplate(), Text = "1234" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.A, KeyModifiers.Control); @@ -304,6 +306,7 @@ namespace Avalonia.Controls.UnitTests textBox.SelectionStart = 2; textBox.SelectionEnd = 2; + Assert.Equal(2, textBox.CaretIndex); } } From 617ca9492f929c0b47bb1710e20334baf87c6384 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 31 Jan 2022 17:11:25 +0100 Subject: [PATCH 14/18] Fix MaskedTextBoxTests --- .../MaskedTextBoxTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs index f06eb61a92..af54be61f7 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs @@ -135,6 +135,8 @@ namespace Avalonia.Controls.UnitTests Template = CreateTemplate(), Text = "1234" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.A, KeyModifiers.Control); @@ -212,9 +214,12 @@ namespace Avalonia.Controls.UnitTests { MaskedTextBox textBox = new MaskedTextBox { + Template = CreateTemplate(), Text = "First Second Third Fourth", CaretIndex = 5 }; + + textBox.ApplyTemplate(); // (First| Second Third Fourth) RaiseKeyEvent(textBox, Key.Back, KeyModifiers.Control); @@ -251,9 +256,12 @@ namespace Avalonia.Controls.UnitTests { var textBox = new MaskedTextBox { + Template = CreateTemplate(), Text = "First Second Third Fourth", CaretIndex = 19 }; + + textBox.ApplyTemplate(); // (First Second Third |Fourth) RaiseKeyEvent(textBox, Key.Delete, KeyModifiers.Control); @@ -355,6 +363,8 @@ namespace Avalonia.Controls.UnitTests Template = CreateTemplate(), AcceptsReturn = true }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.Enter, 0); @@ -470,6 +480,8 @@ namespace Avalonia.Controls.UnitTests AcceptsReturn = true, NewLine = "Test" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.Enter, 0); @@ -836,6 +848,8 @@ namespace Avalonia.Controls.UnitTests SelectionStart = selectionStart, SelectionEnd = selectionEnd }; + + target.ApplyTemplate(); if (fromClipboard) { From cda32a83b405c6994bbfaeb2f49ed3333b5394f7 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 31 Jan 2022 20:11:31 +0100 Subject: [PATCH 15/18] Keep _presenter reference --- src/Avalonia.Controls/TextBox.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index ee7346b747..c8955f2e1c 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -540,6 +540,11 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { _presenter = e.NameScope.Get("PART_TextPresenter"); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); _imClient.SetPresenter(_presenter, this); @@ -554,8 +559,6 @@ namespace Avalonia.Controls base.OnDetachedFromVisualTree(e); _imClient.SetPresenter(null, null); - - _presenter = null; } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) From ce664fd22484e9c8b4bc4eb88e480c74a85901f7 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 31 Jan 2022 20:31:37 +0100 Subject: [PATCH 16/18] Update _navigationPosition when MoveCaretToTextPosition is used --- src/Avalonia.Controls/Presenters/TextPresenter.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index e6f966fa3d..8bbc7a9d27 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -550,6 +550,8 @@ namespace Avalonia.Controls.Presenters { UpdateCaret(trailingEdge ? characterHit : new CharacterHit(characterHit.FirstCharacterIndex)); } + + _navigationPosition = _caretBounds.Position; } public void MoveCaretToPoint(Point point) From 4bac7fd953fc7a38a5a4663d36d712939b7c4fa3 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 1 Feb 2022 07:42:45 +0100 Subject: [PATCH 17/18] Fix HitTestTextRange bidi --- .../Media/TextFormatting/TextLayout.cs | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 08821c3523..ea0ba4d0df 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -223,31 +223,47 @@ namespace Avalonia.Media.TextFormatting for (var index = 0; index < textLine.TextRuns.Count; index++) { var currentRun = (ShapedTextCharacters)textLine.TextRuns[index]; + ShapedTextCharacters? nextRun = null; if (index + 1 < textLine.TextRuns.Count) { - if (currentRun.ShapedBuffer.IsLeftToRight) - { - if (currentRun.Text.End < start) - { - startX += currentRun.Size.Width; + nextRun = (ShapedTextCharacters)textLine.TextRuns[index + 1]; + } - currentPosition = currentRun.Text.End; + if (nextRun != null) + { + if (nextRun.Text.Start < currentRun.Text.Start && start + length < currentRun.Text.End) + { + goto skip; + } - continue; - } + if (currentRun.Text.Start >= start + length) + { + goto skip; } - else + + if (currentRun.Text.Start > nextRun.Text.Start && currentRun.Text.Start < start) { - if (currentRun.Text.Start < start || currentRun.Text.End >= start + length) - { - startX += currentRun.Size.Width; + goto skip; + } - currentPosition = currentRun.Text.Start; + if (currentRun.Text.End < start) + { + goto skip; + } + + goto noop; + + skip: + { + startX += currentRun.Size.Width; - continue; - } + currentPosition = currentRun.Text.Start; } + + continue; + + noop:{ } } var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit( @@ -270,10 +286,8 @@ namespace Avalonia.Media.TextFormatting currentPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - if(index + 1 < textLine.TextRuns.Count) + if(nextRun != null) { - var nextRun = (ShapedTextCharacters)textLine.TextRuns[index + 1]; - if (currentRun.ShapedBuffer.IsLeftToRight == nextRun.ShapedBuffer.IsLeftToRight) { endOffset = nextRun.GlyphRun.GetDistanceFromCharacterHit( @@ -296,7 +310,7 @@ namespace Avalonia.Media.TextFormatting } } - if (endX < startX) +u if (endX < startX) { (endX, startX) = (startX, endX); } @@ -307,11 +321,22 @@ namespace Avalonia.Media.TextFormatting if (currentRun.ShapedBuffer.IsLeftToRight) { - if (currentPosition >= start + length) + if (nextRun != null) { - break; + if (nextRun.Text.Start > currentRun.Text.Start) + { + break; + } + + currentPosition = nextRun.Text.End; + } + else + { + if (currentPosition >= start + length) + { + break; + } } - } else { From 79298e3375c75ba4ce411736a237ac8219b55df9 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 1 Feb 2022 07:55:20 +0100 Subject: [PATCH 18/18] Fix nit --- src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index ea0ba4d0df..f01ef886f7 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -310,7 +310,7 @@ namespace Avalonia.Media.TextFormatting } } -u if (endX < startX) + if (endX < startX) { (endX, startX) = (startX, endX); }