diff --git a/samples/ControlCatalog/Pages/ScreenPage.cs b/samples/ControlCatalog/Pages/ScreenPage.cs index 4edb0f137ad..caad8b0854b 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 @@ public override void Render(DrawingContext context) 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 a4c6299278f..4a8fb819cab 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 2e59d934a14..9c524a79320 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 @@ public void Render(IDrawingContextImpl context) { 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 @@ static int Animate(int d, int from, int to) 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 00000000000..92775bec9e9 --- /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 00000000000..25e29c67a9a --- /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 857358f6b26..7f856069576 100644 --- a/samples/RenderDemo/Pages/GlyphRunPage.xaml.cs +++ b/samples/RenderDemo/Pages/GlyphRunPage.xaml.cs @@ -13,6 +13,7 @@ public class GlyphRunPage : UserControl 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 @@ private void InitializeComponent() 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 @@ private void UpdateGlyphRun() _glyphIndices[0] = _glyphTypeface.GetGlyph(c); + _characters[0] = c; + var scale = (double)_fontSize / _glyphTypeface.DesignEmHeight; var drawingGroup = new DrawingGroup(); @@ -61,7 +64,7 @@ private void UpdateGlyphRun() 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 90d1c52ff53..251dfe43510 100644 --- a/src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs +++ b/src/Avalonia.Base/Utilities/ImmutableReadOnlyListStructEnumerator.cs @@ -3,7 +3,7 @@ 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 a7560c37f24..2c206b53f65 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 441421181cf..35648dd0b62 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 @@ public class Control : InputElement, IControl, INamed, IVisualBrushInitialize, I 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 @@ public object? Tag 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 ff63e5644f6..8bbc7a9d279 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 @@ public class TextPresenter : Control 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 @@ public TextPresenter() _caretTimer.Tick += CaretTimerTick; } + public event EventHandler CaretBoundsChanged; + /// /// Gets or sets a brush used to paint the control's background. /// @@ -189,13 +184,22 @@ public TextAlignment TextAlignment } /// - /// 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 @@ public int CaretIndex { return _caretIndex; } - set { - value = CoerceCaretIndex(value); - SetAndRaise(CaretIndexProperty, ref _caretIndex, value); + if (value != _caretIndex) + { + MoveCaretToTextPosition(value); + } } } @@ -271,37 +276,25 @@ public int SelectionEnd } } - 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 @@ private void RenderInternal(DrawingContext context) 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 @@ public override void Render(DrawingContext context) 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,47 @@ public override void Render(DrawingContext context) 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), 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; + + if (x >= Bounds.Width) + { + x = Math.Floor(_caretBounds.X - 1) + 0.5; + } + return (new Point(x, y), new Point(x, b)); } @@ -412,7 +417,7 @@ public void HideCaret() InvalidateVisual(); } - internal void CaretIndexChanged(int caretIndex) + internal void CaretChanged() { if (this.GetVisualParent() != null) { @@ -432,8 +437,7 @@ internal void CaretIndexChanged(int caretIndex) if (IsMeasureValid) { - var rect = FormattedText.HitTestTextPosition(caretIndex); - this.BringIntoView(rect); + this.BringIntoView(_caretBounds); } else { @@ -443,8 +447,7 @@ internal void CaretIndexChanged(int caretIndex) Dispatcher.UIThread.Post( () => { - var rect = FormattedText.HitTestTextPosition(caretIndex); - this.BringIntoView(rect); + this.BringIntoView(_caretBounds); }, DispatcherPriority.Render); } @@ -452,104 +455,310 @@ internal void CaretIndexChanged(int caretIndex) } /// - /// 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 (!double.IsInfinity(availableSize.Width) && availableSize != _constraint) + { + _constraint = availableSize; + + InvalidateTextLayout(); + } + + 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, trailingEdge); + 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)); + } + + _navigationPosition = _caretBounds.Position; + } + + public void MoveCaretToPoint(Point point) + { + var hit = TextLayout.HitTestPoint(point); + + UpdateCaret(hit.CharacterHit); + + _navigationPosition = _caretBounds.Position; + } + + public void MoveCaretVertical(LogicalDirection direction = LogicalDirection.Forward) { - if (!string.IsNullOrEmpty(Text)) + var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(CaretIndex, _lastCharacterHit.TrailingLength > 0); + + if (lineIndex < 0) + { + return; + } + + var (currentX, currentY) = _navigationPosition; + + if (direction == LogicalDirection.Forward) { - if (TextWrapping == TextWrapping.Wrap) + 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(); + var navigationPosition = _navigationPosition; + + 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, false); - 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 (textLine.NewLineLength > 0 && caretIndex == textLine.TextRange.Start + textLine.TextRange.Length) + { + 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++; + + 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, characterHit.TrailingLength > 0); + 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 14cde774f46..8291433d45e 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -131,21 +131,8 @@ public class TextBlock : Control 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 @@ protected virtual TextLayout CreateTextLayout(Size constraint, string text) TextWrapping, TextTrimming, TextDecorations, + FlowDirection, constraint.Width, constraint.Height, maxLines: MaxLines, @@ -472,6 +460,8 @@ protected virtual TextLayout CreateTextLayout(Size constraint, string text) protected void InvalidateTextLayout() { _textLayout = null; + + InvalidateMeasure(); } /// @@ -507,12 +497,40 @@ protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e 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 20d8a94c1a8..c8955f2e1c1 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -14,6 +14,8 @@ 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 @@ public int CaretIndex { value = CoerceCaretIndex(value); SetAndRaise(CaretIndexProperty, ref _caretIndex, value); + UndoRedoState state; if (IsUndoEnabled && _undoRedoHelper.TryGetLastState(out state) && state.Text == Text) _undoRedoHelper.UpdateLastState(); @@ -301,7 +304,8 @@ public int SelectionStart { UpdateCommandStates(); } - if (SelectionStart == SelectionEnd) + + if (value == SelectionEnd) { CaretIndex = SelectionStart; } @@ -319,13 +323,15 @@ public int SelectionEnd { value = CoerceCaretIndex(value); var changed = SetAndRaise(SelectionEndProperty, ref _selectionEnd, value); + if (changed) { UpdateCommandStates(); } - if (SelectionStart == SelectionEnd) + + if (value == SelectionStart) { - CaretIndex = SelectionEnd; + CaretIndex = value; } } } @@ -345,6 +351,7 @@ public string Text if (!_ignoreTextChanges) { var caretIndex = CaretIndex; + SelectionStart = CoerceCaretIndex(SelectionStart, value); SelectionEnd = CoerceCaretIndex(SelectionEnd, value); CaretIndex = CoerceCaretIndex(caretIndex, value); @@ -533,13 +540,27 @@ public event EventHandler PastingFromClipboard 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); + if (IsFocused) { _presenter?.ShowCaret(); } } + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + _imClient.SetPresenter(null, null); + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); @@ -631,9 +652,8 @@ private void HandleTextInput(string input) _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 +669,11 @@ private void HandleTextInput(string input) 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 +683,8 @@ private void HandleTextInput(string input) { RaisePropertyChanged(TextProperty, oldText, _text); } + + CaretIndex = caretIndex + input.Length; } finally { @@ -684,6 +706,7 @@ public string RemoveInvalidCharacters(string text) public async void Cut() { var text = GetSelection(); + if (string.IsNullOrEmpty(text)) { return; @@ -703,6 +726,7 @@ public async void Cut() public async void Copy() { var text = GetSelection(); + if (string.IsNullOrEmpty(text)) { return; @@ -739,11 +763,16 @@ public async void Paste() protected override void OnKeyDown(KeyEventArgs e) { - string text = Text ?? string.Empty; - int caretIndex = CaretIndex; - bool movement = false; - bool selection = false; - bool handled = false; + if (_presenter == null) + { + return; + } + + 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,46 +913,85 @@ protected override void OnKeyDown(KeyEventArgs e) break; case Key.Up: - movement = MoveVertical(-1); + { selection = DetectSelection(); - break; + + _presenter.MoveCaretVertical(LogicalDirection.Backward); + + if (caretIndex != _presenter.CaretIndex) + { + movement = true; + } + if (selection) + { + SelectionEnd = _presenter.CaretIndex; + } + + break; + } case Key.Down: - movement = MoveVertical(1); + { selection = DetectSelection(); + + _presenter?.MoveCaretVertical(); + + if (caretIndex != _presenter.CaretIndex) + { + movement = true; + } + + if (selection) + { + SelectionEnd = _presenter.CaretIndex; + } + break; - + } case Key.Back: + { SnapshotUndoRedo(); + if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { 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); + } + + if (removedCharacters == 0) + { + return; + } + + var length = Math.Max(0, caretIndex - removedCharacters); - SetTextInternal(text.Substring(0, caretIndex - removedCharacters) + + SetTextInternal(text.Substring(0, length) + text.Substring(caretIndex)); - CaretIndex -= removedCharacters; + + CaretIndex = caretIndex - removedCharacters; + ClearSelection(); } handled = true; break; - + } case Key.Delete: SnapshotUndoRedo(); + if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { SetSelectionForControlDelete(); @@ -931,21 +999,18 @@ protected override void OnKeyDown(KeyEventArgs e) 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)); + + CaretIndex = caretIndex; } + SnapshotUndoRedo(); + handled = true; break; @@ -983,11 +1048,7 @@ protected override void OnKeyDown(KeyEventArgs e) } } - if (movement && selection) - { - SelectionEnd = CaretIndex; - } - else if (movement) + if (movement && !selection) { ClearSelection(); } @@ -1000,19 +1061,28 @@ protected override void OnKeyDown(KeyEventArgs e) 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 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) @@ -1021,13 +1091,14 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) 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 { SelectionStart = SelectionEnd = index; } + break; case 2: if (!StringUtils.IsStartOfWord(text, index)) @@ -1049,8 +1120,13 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) 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); @@ -1058,32 +1134,45 @@ protected override void OnPointerMoved(PointerEventArgs e) 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; } } protected override void OnPointerReleased(PointerReleasedEventArgs e) { - if (_presenter != null && e.Pointer.Captured == _presenter) + if (_presenter == null) { - if (e.InitialPressMouseButton == MouseButton.Right) + return; + } + + if (e.Pointer.Captured != _presenter) + { + return; + } + + 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); - var caretIndex = _presenter.GetCaretIndex(point); - - // 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) @@ -1127,106 +1216,46 @@ public void Clear() 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; - 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); - var index = caretIndex + direction; - - if (index < 0 || index > text.Length) + if (isSelecting) { - 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; + SelectionEnd = _presenter.CaretIndex; } else { - CaretIndex -= (c == '\n' && index > 0 && text[index - 1] == '\r') ? 2 : 1; + SelectionStart = SelectionEnd = _presenter.CaretIndex; } } else { if (direction > 0) { - CaretIndex += StringUtils.NextWord(text, caretIndex) - caretIndex; + var offset = StringUtils.NextWord(text, selectionStart) - selectionStart; + + CaretIndex += offset; } else { - CaretIndex += StringUtils.PreviousWord(text, caretIndex) - caretIndex; + var offset = StringUtils.PreviousWord(text, selectionStart) - selectionStart; + + 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 +1272,17 @@ private void MoveHome(bool document) } 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 +1307,12 @@ private void MoveEnd(bool document) } 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) { @@ -1360,25 +1389,6 @@ private string GetSelection() 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 334db2cafd9..279efa29347 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 @@ internal class TextBoxTextInputMethodClient : ITextInputMethodClient { private InputElement _parent; private TextPresenter _presenter; - private IDisposable _subscription; + public Rect CursorRectangle { get @@ -20,11 +21,15 @@ public Rect CursorRectangle 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 @@ public event EventHandler SurroundingTextChanged { add { } remove { } } 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 8cf2e836bb7..53937003c85 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 @@ public static bool IsStartOfWord(string text, int index) 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 @@ public static bool IsStartOfWord(string text, int index) public static int PreviousWord(string text, int cursor) { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + int begin; int i; int cr; @@ -107,7 +125,12 @@ public static int NextWord(string text, int cursor) 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 48d0ef9da9f..90221bb9223 100644 --- a/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -27,11 +27,6 @@ public static void Initialize() 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 @@ public void Clear(Color color) } - 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 @@ public IDrawingContextImpl CreateDrawingContext(IVisualBrushRenderer visualBrush 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 605659d4643..b619b9d129c 100644 --- a/src/Avalonia.Headless/HeadlessPlatformStubs.cs +++ b/src/Avalonia.Headless/HeadlessPlatformStubs.cs @@ -133,14 +133,10 @@ public ushort[] GetGlyphs(ReadOnlySpan codepoints) 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 68e3673cfef..828ea1f1847 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 00000000000..1c6122e2f1f Binary files /dev/null and b/src/Avalonia.Visuals/Assets/BiDi.trie differ diff --git a/src/Avalonia.Visuals/Assets/UnicodeData.trie b/src/Avalonia.Visuals/Assets/UnicodeData.trie index f96106a5fad..46175ea6446 100644 Binary files a/src/Avalonia.Visuals/Assets/UnicodeData.trie and b/src/Avalonia.Visuals/Assets/UnicodeData.trie differ diff --git a/src/Avalonia.Visuals/Media/DrawingContext.cs b/src/Avalonia.Visuals/Media/DrawingContext.cs index 5584e526559..d0a73ca9a82 100644 --- a/src/Avalonia.Visuals/Media/DrawingContext.cs +++ b/src/Avalonia.Visuals/Media/DrawingContext.cs @@ -226,17 +226,13 @@ public void DrawEllipse(IBrush? brush, IPen? pen, Point center, double radiusX, /// /// 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 f6129eaf6a9..12c40e4d590 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 42859f698ab..00000000000 --- 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 fcb631d1eb4..00000000000 --- 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 53b35fb31b0..ef5ffb8d781 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 @@ public sealed class GlyphRun : IDisposable 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 @@ public sealed class GlyphRun : IDisposable 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 @@ public Point BaselineOrigin /// /// 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 @@ public ReadOnlySlice GlyphIndices /// /// 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 @@ public ReadOnlySlice GlyphAdvances /// /// 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 @@ public ReadOnlySlice Characters /// /// 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 @@ public IGlyphRunImpl GlyphRunImpl /// 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; + } + } - return distance; + for (var i = glyphIndex + 1; i < GlyphIndices.Count; i++) + { + distance += GetGlyphAdvance(i, out _); + } + + return Size.Width - distance; + } } /// @@ -243,50 +283,86 @@ public double GetDistanceFromCharacterHit(CharacterHit characterHit) /// 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 @@ public CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit) { 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 @@ public CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) 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 @@ public CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) /// public int FindGlyphIndex(int characterIndex) { - if (GlyphClusters.IsEmpty) + if (GlyphClusters == null) { return characterIndex; } @@ -353,16 +437,16 @@ public int FindGlyphIndex(int characterIndex) 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 @@ public int FindGlyphIndex(int characterIndex) 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 @@ public int FindGlyphIndex(int characterIndex) { characterIndex--; - start = clusters.BinarySearch((ushort)characterIndex, comparer); + start = clusters.BinarySearch(characterIndex, comparer); } if (start < 0) @@ -403,7 +487,7 @@ public int FindGlyphIndex(int characterIndex) } 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 @@ public CharacterHit FindNearestCharacterHit(int index, out double width) 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 @@ public CharacterHit FindNearestCharacterHit(int index, out double width) 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 @@ public CharacterHit FindNearestCharacterHit(int index, out double width) /// 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 @@ private Point CalculateBaselineOrigin() 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; + } + + var width = widthIncludingTrailingWhitespace; - if (index > _glyphIndices.Length - 1 - trailingWhitespaceLength) + if (IsLeftToRight) + { + for (var index = GlyphIndices.Count - glyphCount; index = 0;) { @@ -562,16 +658,26 @@ private int GetTrailingWhitespaceLength(out int newLineLength) 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; + if (codepointIndex < 0) + { + trailingWhitespaceLength = _characters.Length; + + glyphCount = GlyphClusters.Count; + + break; + } + var codepoint = Codepoint.ReadAt(_characters, codepointIndex, out _); if (!codepoint.IsWhiteSpace) @@ -585,6 +691,8 @@ private int GetTrailingWhitespaceLength(out int newLineLength) } trailingWhitespaceLength++; + + glyphCount++; } } @@ -610,19 +718,19 @@ private void Set(ref T field, T value) /// 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/GlyphTypeface.cs b/src/Avalonia.Visuals/Media/GlyphTypeface.cs index 67dfbb84b6f..45ef04e77f7 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/TextDecoration.cs b/src/Avalonia.Visuals/Media/TextDecoration.cs index 57936426f36..8eeb86c555a 100644 --- a/src/Avalonia.Visuals/Media/TextDecoration.cs +++ b/src/Avalonia.Visuals/Media/TextDecoration.cs @@ -154,11 +154,12 @@ public TextDecorationUnit StrokeOffsetUnit /// 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 @@ internal void Draw(DrawingContext drawingContext, ShapedTextCharacters shapedTex break; case TextDecorationUnit.FontRenderingEmSize: - thickness = fontRenderingEmSize * thickness; + thickness = fontMetrics.FontRenderingEmSize * thickness; break; } @@ -185,32 +186,30 @@ internal void Draw(DrawingContext drawingContext, ShapedTextCharacters shapedTex 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 dd91dc04bd9..e01bba00a4f 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,11 @@ public FontMetrics(Typeface typeface, double fontSize) StrikethroughPosition = glyphTypeface.StrikethroughPosition * scale; } + /// + /// Em size of font used to format and display text + /// + 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 e3a2f6e7668..98344141f16 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 @@ public TextRun GetTextRun(int textSourceIndex) /// 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 @@ private static ValueSpan CreateTextStyleRun(ReadOnlySlice CreateTextStyleRun(ReadOnlySlice CreateTextStyleRun(ReadOnlySlice 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 @@ public ShapeableTextCharacters(ReadOnlySlice text, TextRunProperties prope 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 00000000000..ee38cf39e06 --- /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 72bb7431d7c..96b3857098e 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 @@ public ShapedTextCharacters(GlyphRun glyphRun, TextRunProperties properties) /// 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,97 @@ public override void Draw(DrawingContext drawingContext, Point origin) 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); + + var first = new ShapedTextCharacters(splitBuffer.First, Properties); + + #if DEBUG - Second = second; + if (first.Text.Length != length) + { + throw new InvalidOperationException("Split length mismatch."); } + + #endif + + 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 00000000000..02c71744996 --- /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 cfca8f9ab2a..faa73719c81 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,19 +38,20 @@ public TextCharacters(ReadOnlySlice text, int offsetToFirstCharacter, int /// Gets a list of . /// /// The shapeable text characters. - internal IList GetShapeableCharacters() + internal IList GetShapeableCharacters(ReadOnlySlice runText, sbyte biDiLevel, + ref TextRunProperties? previousProperties) { var shapeableCharacters = new List(2); - var runText = Text; - while (!runText.IsEmpty) { - var shapeableRun = CreateShapeableRun(runText, Properties); + var shapeableRun = CreateShapeableRun(runText, Properties, biDiLevel, ref previousProperties); shapeableCharacters.Add(shapeableRun); runText = runText.Skip(shapeableRun.Text.Length); + + previousProperties = shapeableRun.Properties; } return shapeableCharacters; @@ -60,34 +62,70 @@ internal IList GetShapeableCharacters() /// /// 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 static ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text, + TextRunProperties defaultProperties, sbyte biDiLevel, ref TextRunProperties? previousProperties) { var defaultTypeface = defaultProperties.Typeface; - var currentTypeface = defaultTypeface; + var previousTypeface = previousProperties?.Typeface; - if (TryGetRunProperties(text, currentTypeface, defaultTypeface, out var count)) + if (TryGetShapeableLength(text, currentTypeface, out var count, out var script)) { + 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)); - + 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.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 && TryGetShapeableLength(text, currentTypeface, out count, out _)) { //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,35 +149,30 @@ private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text, Tex return new ShapeableTextCharacters(text.Take(count), new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize, - defaultProperties.TextDecorations, defaultProperties.ForegroundBrush)); + defaultProperties.TextDecorations, defaultProperties.ForegroundBrush), biDiLevel); } /// - /// 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 bool TryGetRunProperties(ReadOnlySlice text, Typeface typeface, Typeface defaultTypeface, - out int count) + protected static bool TryGetShapeableLength(ReadOnlySlice text, Typeface typeface, out int length, + out Script script) { + length = 0; + script = Script.Unknown; + if (text.Length == 0) { - count = 0; return false; } - var isFallback = typeface != defaultTypeface; - - count = 0; - var script = Script.Unknown; - var direction = BiDiClass.LeftToRight; - var font = typeface.GlyphTypeface; - var defaultFont = defaultTypeface.GlyphTypeface; - + var enumerator = new GraphemeEnumerator(text); while (enumerator.MoveNext()) @@ -148,20 +181,10 @@ protected bool TryGetRunProperties(ReadOnlySlice text, Typeface typeface, 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 or Script.Inherited) { script = currentScript; } @@ -174,37 +197,16 @@ protected bool TryGetRunProperties(ReadOnlySlice text, Typeface typeface, } } - //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 _)) - { - break; - } - - if (direction == BiDiClass.RightToLeft && currentDirection == BiDiClass.CommonSeparator) + //Stop at the first missing glyph + if (!currentGrapheme.FirstCodepoint.IsBreakChar && !font.TryGetGlyph(currentGrapheme.FirstCodepoint, out _)) { 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 682fd930f67..4d342d4d58f 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 c97e36d5ff6..101f2737983 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 @@ public override TextLine FormatLine(ITextSource textSource, int firstTextSourceI 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 @@ internal static bool TryMeasureCharacters(ShapedTextCharacters textCharacters, d /// 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 @@ internal static SplitTextRunsResult SplitTextRuns(List tex { 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 @@ internal static SplitTextRunsResult SplitTextRuns(List tex 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 @@ internal static SplitTextRunsResult SplitTextRuns(List tex first.Add(currentRun); - return new SplitTextRunsResult(first, second); + return new SplitResult>(first, second); } else { @@ -243,120 +129,203 @@ internal static SplitTextRunsResult SplitTextRuns(List tex 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); + TextRunProperties? previousProperties = null; + 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, ref previousProperties); - 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, ref previousProperties); + + 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, ref previousProperties); + } + + /// + /// 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 +349,52 @@ private static bool TryGetLineBreak(TextRun textRun, out LineBreak lineBreak) 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 +402,136 @@ private static TextLine PerformTextWrapping(List textRuns, 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 +568,28 @@ public bool MoveNext() 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 0ed06e4e575..f01ef886f73 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; @@ -11,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; @@ -29,6 +28,7 @@ public class TextLayout /// 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 @@ public TextLayout( 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 @@ public TextLayout( _paragraphProperties = CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, - textDecorations, lineHeight); + textDecorations, flowDirection, lineHeight); _textTrimming = textTrimming; @@ -69,7 +70,7 @@ public TextLayout( MaxLines = maxLines; - UpdateLayout(); + TextLines = CreateTextLines(); } /// @@ -190,29 +191,174 @@ public IEnumerable HitTestTextRange(int start, int length) } 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]; + ShapedTextCharacters? nextRun = null; + + if (index + 1 < textLine.TextRuns.Count) + { + nextRun = (ShapedTextCharacters)textLine.TextRuns[index + 1]; + } + + if (nextRun != null) + { + if (nextRun.Text.Start < currentRun.Text.Start && start + length < currentRun.Text.End) + { + goto skip; + } + + if (currentRun.Text.Start >= start + length) + { + goto skip; + } + + if (currentRun.Text.Start > nextRun.Text.Start && currentRun.Text.Start < start) + { + goto skip; + } + + if (currentRun.Text.End < start) + { + goto skip; + } + + goto noop; + + skip: + { + startX += currentRun.Size.Width; + + currentPosition = currentRun.Text.Start; + } + + continue; + + noop:{ } + } + + 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(nextRun != null) + { + 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 _); + + currentPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; + } + } + } - var endX = textLine.GetDistanceFromCharacterHit(new CharacterHit(start + length)); + if (endX < startX) + { + (endX, startX) = (startX, endX); + } + + var width = endX - startX; - result.Add(new Rect(currentX, currentY, endX - currentX, textLine.Height)); + result.Add(new Rect(startX, currentY, width, textLine.Height)); + if (currentRun.ShapedBuffer.IsLeftToRight) + { + if (nextRun != null) + { + if (nextRun.Text.Start > currentRun.Text.Start) + { + break; + } + + currentPosition = nextRun.Text.End; + } + else + { + 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 +402,37 @@ public TextHitTestResult HitTestPoint(in Point point) return GetHitTestResult(currentLine, characterHit, point); } + + public int GetLineIndexFromCharacterIndex(int charIndex, bool trailingEdge) + { + if (charIndex < 0) + { + return 0; + } + + if (charIndex > _text.Length) + { + return TextLines.Count - 1; + } + + for (var index = 0; index < TextLines.Count; index++) + { + var textLine = TextLines[index]; + + if (textLine.TextRange.Start + textLine.TextRange.Length < charIndex) + { + continue; + } + + if (charIndex >= textLine.Start && charIndex <= textLine.TextRange.End + (trailingEdge ? 1 : 0)) + { + return index; + } + } + + return TextLines.Count - 1; + } + private TextHitTestResult GetHitTestResult(TextLine textLine, CharacterHit characterHit, Point point) { var (x, y) = point; @@ -274,7 +451,18 @@ private TextHitTestResult GetHitTestResult(TextLine textLine, CharacterHit chara 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; + } + + if (textLine.NewLineLength > 0 && textPosition + textLine.NewLineLength == + characterHit.FirstCharacterIndex + characterHit.TrailingLength) + { + characterHit = new CharacterHit(characterHit.FirstCharacterIndex); + } + + return new TextHitTestResult(characterHit, textPosition, isInside, isTrailing); } /// @@ -286,15 +474,16 @@ private TextHitTestResult GetHitTestResult(TextLine textLine, CharacterHit chara /// 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 +495,8 @@ private static TextParagraphProperties CreateTextParagraphProperties(Typeface ty /// 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 +511,97 @@ private static void UpdateBounds(TextLine textLine, ref double width, ref double /// 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); - if (textLines.Count > 0) + currentPosition += textLine.TextRange.Length; + + 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)); - - textLines[textLines.Count - 1] = collapsedLine; - } + var collapsedLine = + previousLine.Collapse(GetCollapsingProperties(MaxWidth)); - break; + textLines[textLines.Count - 1] = collapsedLine; } - } - var hasOverflowed = textLine.HasOverflowed; - - if (hasOverflowed && _textTrimming != TextTrimming.None) - { - textLine = textLine.Collapse(GetCollapsingProperties(MaxWidth)); + break; } + } - textLines.Add(textLine); + var hasOverflowed = textLine.HasOverflowed; - UpdateBounds(textLine, ref width, ref height); + if (hasOverflowed && _textTrimming != TextTrimming.None) + { + textLine = textLine.Collapse(GetCollapsingProperties(MaxWidth)); + } - previousLine = textLine; + textLines.Add(textLine); - if (currentPosition == _text.Length && textLine.NewLineLength > 0) - { - var emptyTextLine = CreateEmptyTextLine(currentPosition); + UpdateBounds(textLine, ref width, ref height); - textLines.Add(emptyTextLine); - } + previousLine = textLine; + + if (currentPosition != _text.Length || textLine.NewLineLength <= 0) + { + continue; } - Size = new Size(width, height); + var emptyTextLine = CreateEmptyTextLine(currentPosition); - TextLines = textLines; + textLines.Add(emptyTextLine); + + UpdateBounds(emptyTextLine, ref width, ref height); } + + Size = new Size(width, height); + + return textLines; } /// @@ -431,241 +620,5 @@ private TextCollapsingProperties GetCollapsingProperties(double width) _ => 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 aea42270021..9bbc4a8a9dd 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs @@ -191,27 +191,45 @@ public abstract class TextLine /// /// 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 d2bd58682ae..be9661c2bfe 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 b1397518e4d..53e44de779e 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 @@ public override void Draw(DrawingContext drawingContext, Point lineOrigin) { 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 @@ public override void Draw(DrawingContext drawingContext, Point lineOrigin) /// public override TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList) { - if (collapsingPropertiesList == null || collapsingPropertiesList.Length == 0) + if (collapsingPropertiesList.Length == 0) { return this; } @@ -105,21 +111,22 @@ public override TextLine Collapse(params TextCollapsingProperties[] collapsingPr 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 @@ public override TextLine Collapse(params TextCollapsingProperties[] collapsingPr 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 @@ public override TextLine Collapse(params TextCollapsingProperties[] collapsingPr 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 @@ public override double GetDistanceFromCharacterHit(CharacterHit characterHit) { 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 @@ public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit) 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 @@ public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit character return previousCharacterHit; } - if (characterHit.FirstCharacterIndex < TextRange.Start) + if (characterHit.FirstCharacterIndex <= TextRange.Start) { characterHit = new CharacterHit(TextRange.Start); } @@ -334,6 +349,170 @@ public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characte 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 @@ private bool TryFindNextCharacterHit(CharacterHit characterHit, out CharacterHit var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - if (codepointIndex > TextRange.End) + if (codepointIndex >= TextRange.End) { return false; // Cannot go forward anymore } @@ -356,21 +535,29 @@ private bool TryFindNextCharacterHit(CharacterHit characterHit, out CharacterHit 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) + if (characterIndex < 0 && run.ShapedBuffer.IsLeftToRight) { foundCharacterHit = new CharacterHit(foundCharacterHit.FirstCharacterIndex); } @@ -398,7 +585,9 @@ private bool TryFindNextCharacterHit(CharacterHit characterHit, out CharacterHit /// 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 @@ private bool TryFindPreviousCharacterHit(CharacterHit characterHit, out Characte 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 @@ private bool TryFindPreviousCharacterHit(CharacterHit characterHit, out Characte /// 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 bbff09ad79f..b799567a603 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs @@ -7,7 +7,7 @@ public abstract class TextParagraphProperties { /// /// 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 4bfbb89006b..26c3f8947a4 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs @@ -41,7 +41,7 @@ public string Text { 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 2892e608ab3..c982a435c37 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs @@ -45,10 +45,10 @@ public static TextShaper Current } /// - 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 00000000000..404956d1e11 --- /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 ad3cc9141b3..28c6fee089b 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 00000000000..9d76d56376c --- /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 00000000000..f204497eb1f --- /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 43a95310c6d..39440f6fcf4 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 @@ public Codepoint(int value) 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 @@ public bool IsWhiteSpace 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 388a7d257d2..af5d9ff752d 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/PropertyValueAliasHelper.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/PropertyValueAliasHelper.cs @@ -174,5 +174,336 @@ public static string GetTag(Script script) } 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 4189b24af68..471cb52bea5 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 static class UnicodeData { 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 @@ static UnicodeData() /// /// 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 @@ public static GeneralCategory GetGeneralCategory(int codepoint) /// /// 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 @@ public static BiDiClass GetBiDiClass(int codepoint) /// /// 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 @@ public static LineBreakClass GetLineBreakClass(int codepoint) /// /// 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 537dfed49d3..c8922f06c8e 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 8245b634409..45540a58123 100644 --- a/src/Avalonia.Visuals/Media/Typeface.cs +++ b/src/Avalonia.Visuals/Media/Typeface.cs @@ -48,7 +48,7 @@ public Typeface(string fontFamilyName, /// /// 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 a1f42c171c9..82af6ff0b66 100644 --- a/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs +++ b/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs @@ -83,13 +83,6 @@ void DrawRectangle(IBrush? brush, IPen? pen, RoundedRect rect, /// 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 330fcac50cf..00000000000 --- 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 60ae0b5ef8e..a295a8cdc97 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 73d198d7ef5..aced05c9d87 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 c453181f65b..82be0a1a0f4 100644 --- a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs +++ b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs @@ -588,7 +588,10 @@ private void RenderComposite(Scene scene, ref IDrawingContextImpl? context) 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 f6642102f76..23016de148d 100644 --- a/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs +++ b/src/Avalonia.Visuals/Rendering/ImmediateRenderer.cs @@ -78,7 +78,7 @@ public void Paint(Rect rect) 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 5c9cace4cd4..90ba60c42aa 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 @@ public class RendererBase 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 @@ protected void RenderFps(IDrawingContextImpl context, Rect clientRect, int? laye _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 688cbd83c85..9710ca6c3c4 100644 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs +++ b/src/Avalonia.Visuals/Rendering/SceneGraph/DeferredDrawingContextImpl.cs @@ -202,21 +202,6 @@ public void Custom(ICustomDrawOperation custom) ++_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 4a1587fb900..00000000000 --- 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 00000000000..70486594314 --- /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 00000000000..f5a9d3a98d3 --- /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 00000000000..a4f6ae89c14 --- /dev/null +++ b/src/Avalonia.Visuals/Utilities/BinarySearchExtension.cs @@ -0,0 +1,80 @@ +// 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 + /// + internal 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 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 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 00000000000..3ef74185338 --- /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 00000000000..299c6077313 --- /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 5feaa88e26c..ee85d1e8761 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 @@ public ReadOnlySlice(ReadOnlyMemory buffer, int start, int length) 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 @@ public ReadOnlySlice AsSlice(int start, int length) 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 @@ public ReadOnlySlice Take(int length) 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 @@ public ReadOnlySlice Skip(int length) 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 @@ public ReadOnlySliceDebugView(ReadOnlySlice readOnlySlice) 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 00000000000..7eb9652d9f0 --- /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 2694a61353a..e695a9cb410 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -441,17 +441,7 @@ public void DrawEllipse(IBrush brush, IPen pen, Rect rect) } } } - - /// - 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 625d1c6f520..00000000000 --- 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/GlyphTypefaceImpl.cs b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs index 9601fece25d..5b6e5af60f7 100644 --- a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs +++ b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs @@ -64,6 +64,8 @@ public GlyphTypefaceImpl(SKTypeface typeface, bool isFakeBold = false, bool isFa public SKTypeface Typeface { get; } + public int ReplacementCodepoint { get; } + /// public short DesignEmHeight { get; } diff --git a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs index c3ac5e17744..af3b570fd7c 100644 --- a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs +++ b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs @@ -37,19 +37,6 @@ public PlatformRenderInterface(ISkiaGpu skiaGpu, long? maxResourceBytes = null) _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 @@ public IWriteableBitmapImpl CreateWriteableBitmap(PixelSize size, Vector dpi, Pi /// 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 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) 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 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) { positions[i] = (float)width; - if (glyphRun.GlyphAdvances.IsEmpty) + if (glyphRun.GlyphAdvances == null) { width += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale; } @@ -254,7 +248,12 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) } } - 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 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) 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 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) } } - 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 5cf72e2ce8a..c4d11f46139 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 eef44161013..c32c58605fd 100644 --- a/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs +++ b/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs @@ -115,25 +115,6 @@ public static void Initialize() 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 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) { 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 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) 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 @@ public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun) } } - 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 470157110c0..b62a6fa5a67 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/DrawingContextImpl.cs @@ -376,29 +376,6 @@ public void DrawEllipse(IBrush brush, IPen pen, Rect rect) } } - /// - /// 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 c59067d82df..00000000000 --- 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 20b09a9aac9..62cf031f865 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 549f450ece3..59067e642f1 100644 --- a/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs +++ b/tests/Avalonia.Benchmarks/NullDrawingContextImpl.cs @@ -43,10 +43,6 @@ public void DrawEllipse(IBrush brush, IPen pen, Rect rect) { } - 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 f886d077cc9..00000000000 --- 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 3e11c74e1cb..ccdb0eed2f2 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 8dd8e843ac0..4aed8c60c30 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 1a251a5cef4..af54be61f75 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs @@ -115,7 +115,10 @@ public void CaretIndex_Can_Moved_To_Position_After_The_End_Of_Text_With_Arrow_Ke Text = "1234" }; + target.ApplyTemplate(); target.CaretIndex = 3; + target.Measure(Size.Infinity); + RaiseKeyEvent(target, Key.Right, 0); Assert.Equal(4, target.CaretIndex); @@ -132,6 +135,8 @@ public void Press_Ctrl_A_Select_All_Text() Template = CreateTemplate(), Text = "1234" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.A, KeyModifiers.Control); @@ -209,9 +214,12 @@ public void Control_Backspace_Should_Remove_The_Word_Before_The_Caret_If_There_I { 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); @@ -248,9 +256,12 @@ public void Control_Delete_Should_Remove_The_Word_After_The_Caret_If_There_Is_No { 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); @@ -352,6 +363,8 @@ public void Press_Enter_Add_Default_Newline() Template = CreateTemplate(), AcceptsReturn = true }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.Enter, 0); @@ -467,6 +480,8 @@ public void Press_Enter_Add_Custom_Newline() AcceptsReturn = true, NewLine = "Test" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.Enter, 0); @@ -833,6 +848,8 @@ public void MaxLength_Works_Properly( SelectionStart = selectionStart, SelectionEnd = selectionEnd }; + + target.ApplyTemplate(); if (fromClipboard) { @@ -892,7 +909,9 @@ public void Keys_Allow_Undo(Key key, KeyModifiers modifiers) 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() { @@ -902,11 +921,18 @@ private IControlTemplate CreateTemplate() 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 = nameof(TextPresenter.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 d49ee359010..8cc8e4c16fb 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 @@ public void TextPresenter_Can_Contain_Null_With_Password_Char_Set() PasswordChar = '*' }; - Assert.NotNull(target.FormattedText); + Assert.NotNull(target.TextLayout); } } @@ -28,7 +29,7 @@ public void TextPresenter_Can_Contain_Null_WithOut_Password_Char_Set() var target = new TextPresenter(); - Assert.NotNull(target.FormattedText); + Assert.NotNull(target.TextLayout); } } @@ -40,8 +41,14 @@ public void Text_Presenter_Replaces_Formatted_Text_With_Password_Char() 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 23cae8fd0d4..c0c9e841f48 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -115,6 +115,10 @@ public void CaretIndex_Can_Moved_To_Position_After_The_End_Of_Text_With_Arrow_Ke Text = "1234" }; + target.ApplyTemplate(); + + target.Measure(Size.Infinity); + target.CaretIndex = 3; RaiseKeyEvent(target, Key.Right, 0); @@ -132,6 +136,8 @@ public void Press_Ctrl_A_Select_All_Text() Template = CreateTemplate(), Text = "1234" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.A, KeyModifiers.Control); @@ -209,9 +215,13 @@ public void Control_Backspace_Should_Remove_The_Word_Before_The_Caret_If_There_I { 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 +258,12 @@ public void Control_Delete_Should_Remove_The_Word_After_The_Caret_If_There_Is_No { 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); @@ -293,6 +306,7 @@ public void Setting_SelectionStart_To_SelectionEnd_Sets_CaretPosition_To_Selecti textBox.SelectionStart = 2; textBox.SelectionEnd = 2; + Assert.Equal(2, textBox.CaretIndex); } } @@ -335,6 +349,8 @@ public void Press_Enter_Does_Not_Accept_Return() AcceptsReturn = false, Text = "1234" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.Enter, 0); @@ -352,6 +368,8 @@ public void Press_Enter_Add_Default_Newline() Template = CreateTemplate(), AcceptsReturn = true }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.Enter, 0); @@ -370,6 +388,8 @@ public void Press_Enter_Add_Custom_Newline() AcceptsReturn = true, NewLine = "Test" }; + + target.ApplyTemplate(); RaiseKeyEvent(target, Key.Enter, 0); @@ -409,6 +429,8 @@ public void SelectionEnd_Doesnt_Cause_Exception() Template = CreateTemplate(), Text = "0123456789" }; + + target.ApplyTemplate(); target.SelectionStart = 0; target.SelectionEnd = 9; @@ -431,6 +453,8 @@ public void SelectionStart_Doesnt_Cause_Exception() Template = CreateTemplate(), Text = "0123456789" }; + + target.ApplyTemplate(); target.SelectionStart = 8; target.SelectionEnd = 9; @@ -474,6 +498,8 @@ public void SelectedText_Changes_OnSelectionChange() Template = CreateTemplate(), Text = "0123456789" }; + + target.ApplyTemplate(); Assert.True(target.SelectedText == ""); @@ -494,6 +520,8 @@ public void SelectedText_EditsText() Template = CreateTemplate(), Text = "0123" }; + + target.ApplyTemplate(); target.SelectedText = "AA"; Assert.True(target.Text == "AA0123"); @@ -701,7 +729,9 @@ public void Setting_Bound_Text_To_Null_Works() 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 +767,8 @@ public void MaxLength_Works_Properly( SelectionEnd = selectionEnd }; + target.Measure(Size.Infinity); + if (fromClipboard) { AvaloniaLocator.CurrentMutable.Bind().ToSingleton(); @@ -773,6 +805,7 @@ public void Keys_Allow_Undo(Key key, KeyModifiers modifiers) AcceptsReturn = true, AcceptsTab = true }; + target.ApplyTemplate(); target.SelectionStart = 1; target.SelectionEnd = 3; AvaloniaLocator.CurrentMutable @@ -837,13 +870,15 @@ public void Entering_Text_With_SelectedText_Should_Fire_Single_Text_Changed_Noti 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() { @@ -853,11 +888,18 @@ private IControlTemplate CreateTemplate() 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 = nameof(TextPresenter.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 570b9ee4ead..48cc365029c 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs @@ -86,7 +86,9 @@ public void Setter_Exceptions_Should_Set_DataValidationErrors_HasErrors() } 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 7528424521c..00000000000 --- 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 b1a2d00b003..981ae0d0a49 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 f9c45e7d225..904f0935c40 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 a19f97e74eb..326997328b0 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -163,8 +163,10 @@ public void Should_Split_Run_On_Script() 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 @@ public void Should_Wrap_With_Overflow(string text, int expectedCharactersPerLine 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,28 +403,96 @@ public void Should_Produce_Wrapped_And_Trimmed_Lines(string text, string[] expec } } - [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); } } + + [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() @@ -475,7 +511,57 @@ public void Should_FormatLine_With_Emergency_Breaks() 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 afa1fbf461e..b525931061c 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 @@ public class TextLayoutTests { 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 @@ public void Should_Apply_TextStyleSpan_To_Text_In_Between() 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 @@ public void Should_Apply_TextStyleSpan_To_Text_In_Between() } } - [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); + + for (var j = 0; j < actual.TextLines.Count; j++) + { + var expectedText = expectedLines[j]; + + var actualText = actualLines[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 actualLines = actual.TextLines.Select(x => MultiLineText.Substring(x.TextRange.Start, - x.TextRange.Length)).ToList(); - - Assert.Equal(expectedLines.Count, actualLines.Count); + var outer = new GraphemeEnumerator(text.AsMemory()); + var inner = new GraphemeEnumerator(text.AsMemory()); + var i = 0; + var j = 0; - for (var j = 0; j < actual.TextLines.Count; j++) + 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 @@ public void Should_Apply_TextStyleSpan_To_Text_At_End() 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 @@ public void Should_Apply_TextSpan_To_Unicode_String_In_Between() 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 @@ public void Should_Hit_Test_SurrogatePair() } } - [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 @@ public void Should_Create_Valid_Clusters_For_Text(string text, ushort[] clusters { var shapedRun = (ShapedTextCharacters)textRun; - var glyphRun = shapedRun.GlyphRun; - - var glyphClusters = glyphRun.GlyphClusters; + var glyphClusters = shapedRun.ShapedBuffer.GlyphClusters; - var expected = clusters.Skip(index).Take(glyphClusters.Length).ToArray(); + var expected = clusters.Skip(index).Take(glyphClusters.Count).ToArray(); - Assert.Equal(expected, glyphRun.GlyphClusters); + Assert.Equal(expected, glyphClusters); - index += glyphClusters.Length; + index += glyphClusters.Count; } } } @@ -440,13 +512,13 @@ public void Should_Break_With_BreakChar(string text, int expectedLength) 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,161 @@ public void Should_Wrap_Min_OneCharacter_EveryLine() } } + [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); + } + } + } + } + + [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() { 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 5961806c5c0..8cb010f42b7 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -5,6 +5,7 @@ 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 @@ public void Should_Get_Last_CharacterHit() } } } + + [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 @@ public void Should_Get_Next_Caret_CharacterHit(string text) 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 @@ public void Should_Get_Previous_Caret_CharacterHit(string text) 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 @@ public void Should_Get_Distance_From_CharacterHit() { 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 @@ public void Should_Get_Distance_From_CharacterHit() 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 @@ public void Should_Get_Distance_From_CharacterHit() } } - 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 @@ public void Should_Get_CharacterHit_From_Distance() 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 @@ public void Should_Collapse_Line(string text, int numberOfCharacters, TextCollap } } - [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 @@ public void Should_Ignore_NewLine_Characters() 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 62d2c54ffe6..57676ad581e 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs @@ -16,17 +16,17 @@ public void Should_Form_Clusters_For_BreakPairs() { 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 8b37fa1b418..c3cf6cd8d36 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 687fddd71a5..8ad3284490c 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 1f632034be1..2858a9f2280 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 fe1c34385f5..3018c07819e 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 e5cea4823f4..2c4ae06fb20 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -183,14 +183,6 @@ private static Styles CreateDefaultTheme() 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 f52bdc39c8d..ba334fdb745 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 @@ public GlyphRunTests() .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 @@ public void Should_Get_Distance_From_CharacterHit(double[] advances, ushort[] cl } } - [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 @@ public void Should_Get_CharacterHit_FromDistance(double[] advances, ushort[] clu } } - [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 @@ public void Should_Find_Nearest_CharacterHit(double[] advances, ushort[] cluster } } - [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 @@ public void Should_Get_Next_CharacterHit(double[] advances, ushort[] clusters, } } - [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 @@ public void Should_Get_Previous_CharacterHit(double[] advances, ushort[] cluster } } - [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 @@ public void Should_Find_Glyph_Index(double[] advances, ushort[] clusters, int bi } } - 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 00000000000..f8a2abc7168 --- /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 00000000000..2f1064be21c --- /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 00000000000..1ed33e61320 --- /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/BiDiTestDataGenerator.cs b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/BiDiTestDataGenerator.cs new file mode 100644 index 00000000000..4102de10ec4 --- /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 cbe8edefb66..da9e494405e 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.Net.Http; using System.Text.RegularExpressions; using Avalonia.Media.TextFormatting.Unicode; +using Xunit; namespace Avalonia.Visuals.UnitTests.Media.TextFormatting { @@ -11,93 +12,145 @@ internal static class UnicodeDataGenerator { 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 @@ private static Dictionary CreateTagToIndexMappings(List return mappings; } - private static Dictionary CreateNameToIndexMappings(List entries) + private static Dictionary CreateNameToIndexMappings(IReadOnlyList entries) { var mappings = new Dictionary(); @@ -153,14 +206,14 @@ private static void AddScriptRange(Dictionary codepoints, } } - 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 @@ private static void AddBiDiClassRange(Dictionary codepoint } } + 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 @@ private static void AddLineBreakClassRange(Dictionary code { 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 @@ private static void AddLineBreakClassRange(Dictionary code continue; } - var match = rx.Match(line); + var match = regex.Match(line); if (!match.Success) { @@ -271,6 +397,32 @@ internal class UnicodeDataItem 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 47aef845336..f122876322a 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 @@ public class UnicodeDataGeneratorTests [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 3a936ff3b0a..e4837284231 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeEnumsGenerator.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/UnicodeEnumsGenerator.cs @@ -270,9 +270,36 @@ public static List CreateBiDiClassEnum() 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 @@ public static void CreatePropertyValueAliasHelper(List scriptEntries, 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 a6eaea334c3..00000000000 --- 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 229bb8aef3c..6100f2cb748 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();