diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index 0cddbdf9fd..b021c9f4a5 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -8,6 +8,7 @@ "samples\\ControlCatalog\\ControlCatalog.csproj", "samples\\GpuInterop\\GpuInterop.csproj", "samples\\IntegrationTestApp\\IntegrationTestApp.csproj", + "samples\\TextTestApp\\TextTestApp.csproj", "samples\\MiniMvvm\\MiniMvvm.csproj", "samples\\ReactiveUIDemo\\ReactiveUIDemo.csproj", "samples\\RenderDemo\\RenderDemo.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index 8ee6f65989..3103ddeb16 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -191,6 +191,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniMvvm", "samples\MiniMvv EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestApp", "samples\IntegrationTestApp\IntegrationTestApp.csproj", "{676D6BFD-029D-4E43-BFC7-3892265CE251}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TextTestApp", "samples\TextTestApp\TextTestApp.csproj", "{CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.IntegrationTests.Appium", "tests\Avalonia.IntegrationTests.Appium\Avalonia.IntegrationTests.Appium.csproj", "{F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Browser", "Browser", "{86A3F706-DC3C-43C6-BE1B-B98F5BAAA268}" @@ -538,6 +540,10 @@ Global {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|Any CPU.Build.0 = Debug|Any CPU {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.ActiveCfg = Release|Any CPU {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.Build.0 = Release|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Release|Any CPU.Build.0 = Release|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -761,6 +767,7 @@ Global {11BE52AF-E2DD-4CF0-B19A-05285ACAF571} = {9B9E3891-2366-4253-A952-D08BCEB71098} {BC594FD5-4AF2-409E-A1E6-04123F54D7C5} = {9B9E3891-2366-4253-A952-D08BCEB71098} {676D6BFD-029D-4E43-BFC7-3892265CE251} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F} = {9B9E3891-2366-4253-A952-D08BCEB71098} {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {26A98DA1-D89D-4A95-8152-349F404DA2E2} = {A0CC0258-D18C-4AB3-854F-7101680FC3F9} {A0D0A6A4-5C72-4ADA-9B27-621C7D94F270} = {9B9E3891-2366-4253-A952-D08BCEB71098} diff --git a/samples/TextTestApp/App.axaml b/samples/TextTestApp/App.axaml new file mode 100644 index 0000000000..ff984071c1 --- /dev/null +++ b/samples/TextTestApp/App.axaml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/TextTestApp/App.axaml.cs b/samples/TextTestApp/App.axaml.cs new file mode 100644 index 0000000000..ef6cc15ddf --- /dev/null +++ b/samples/TextTestApp/App.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace TextTestApp +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/samples/TextTestApp/GridRow.cs b/samples/TextTestApp/GridRow.cs new file mode 100644 index 0000000000..d486e186db --- /dev/null +++ b/samples/TextTestApp/GridRow.cs @@ -0,0 +1,24 @@ +using System.Collections.Specialized; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace TextTestApp +{ + public class GridRow : Grid + { + protected override void ChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + base.ChildrenChanged(sender, e); + + while (Children.Count > ColumnDefinitions.Count) + ColumnDefinitions.Add(new ColumnDefinition { SharedSizeGroup = "c" + ColumnDefinitions.Count }); + + for (int i = 0; i < Children.Count; i++) + { + SetColumn(Children[i], i); + if (Children[i] is Layoutable l) + l.VerticalAlignment = VerticalAlignment.Center; + } + } + } +} diff --git a/samples/TextTestApp/InteractiveLineControl.cs b/samples/TextTestApp/InteractiveLineControl.cs new file mode 100644 index 0000000000..67db521ef4 --- /dev/null +++ b/samples/TextTestApp/InteractiveLineControl.cs @@ -0,0 +1,705 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; + +namespace TextTestApp +{ + public class InteractiveLineControl : Control + { + /// + /// Defines the property. + /// + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty BackgroundProperty = + Border.BackgroundProperty.AddOwner(); + + public static readonly StyledProperty ExtentStrokeProperty = + AvaloniaProperty.Register(nameof(ExtentStroke)); + + public static readonly StyledProperty BaselineStrokeProperty = + AvaloniaProperty.Register(nameof(BaselineStroke)); + + public static readonly StyledProperty TextBoundsStrokeProperty = + AvaloniaProperty.Register(nameof(TextBoundsStroke)); + + public static readonly StyledProperty RunBoundsStrokeProperty = + AvaloniaProperty.Register(nameof(RunBoundsStroke)); + + public static readonly StyledProperty NextHitStrokeProperty = + AvaloniaProperty.Register(nameof(NextHitStroke)); + + public static readonly StyledProperty BackspaceHitStrokeProperty = + AvaloniaProperty.Register(nameof(BackspaceHitStroke)); + + public static readonly StyledProperty PreviousHitStrokeProperty = + AvaloniaProperty.Register(nameof(PreviousHitStroke)); + + public static readonly StyledProperty DistanceStrokeProperty = + AvaloniaProperty.Register(nameof(DistanceStroke)); + + public IBrush? ExtentStroke + { + get => GetValue(ExtentStrokeProperty); + set => SetValue(ExtentStrokeProperty, value); + } + public IBrush? BaselineStroke + { + get => GetValue(BaselineStrokeProperty); + set => SetValue(BaselineStrokeProperty, value); + } + + public IBrush? TextBoundsStroke + { + get => GetValue(TextBoundsStrokeProperty); + set => SetValue(TextBoundsStrokeProperty, value); + } + + public IBrush? RunBoundsStroke + { + get => GetValue(RunBoundsStrokeProperty); + set => SetValue(RunBoundsStrokeProperty, value); + } + + public IBrush? NextHitStroke + { + get => GetValue(NextHitStrokeProperty); + set => SetValue(NextHitStrokeProperty, value); + } + + public IBrush? BackspaceHitStroke + { + get => GetValue(BackspaceHitStrokeProperty); + set => SetValue(BackspaceHitStrokeProperty, value); + } + + public IBrush? PreviousHitStroke + { + get => GetValue(PreviousHitStrokeProperty); + set => SetValue(PreviousHitStrokeProperty, value); + } + + public IBrush? DistanceStroke + { + get => GetValue(DistanceStrokeProperty); + set => SetValue(DistanceStrokeProperty, value); + } + + private IPen? _extentPen; + protected IPen ExtentPen => _extentPen ??= new Pen(ExtentStroke, dashStyle: DashStyle.Dash); + + private IPen? _baselinePen; + protected IPen BaselinePen => _baselinePen ??= new Pen(BaselineStroke); + + private IPen? _textBoundsPen; + protected IPen TextBoundsPen => _textBoundsPen ??= new Pen(TextBoundsStroke); + + private IPen? _runBoundsPen; + protected IPen RunBoundsPen => _runBoundsPen ??= new Pen(RunBoundsStroke, dashStyle: DashStyle.Dash); + + private IPen? _nextHitPen; + protected IPen NextHitPen => _nextHitPen ??= new Pen(NextHitStroke); + + private IPen? _previousHitPen; + protected IPen PreviousHitPen => _previousHitPen ??= new Pen(PreviousHitStroke); + + private IPen? _backspaceHitPen; + protected IPen BackspaceHitPen => _backspaceHitPen ??= new Pen(BackspaceHitStroke); + + private IPen? _distancePen; + protected IPen DistancePen => _distancePen ??= new Pen(DistanceStroke); + + /// + /// Gets or sets the text to draw. + /// + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + /// + /// Gets or sets a brush used to paint the control's background. + /// + public IBrush? Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + // TextRunProperties + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontFamilyProperty = + TextElement.FontFamilyProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontFeaturesProperty = + TextElement.FontFeaturesProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontSizeProperty = + TextElement.FontSizeProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontStyleProperty = + TextElement.FontStyleProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontWeightProperty = + TextElement.FontWeightProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontStretchProperty = + TextElement.FontStretchProperty.AddOwner(); + + /// + /// Gets or sets the font family used to draw the control's text. + /// + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + /// + /// Gets or sets the font features turned on/off. + /// + public FontFeatureCollection? FontFeatures + { + get => GetValue(FontFeaturesProperty); + set => SetValue(FontFeaturesProperty, value); + } + + /// + /// Gets or sets the size of the control's text in points. + /// + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + /// + /// Gets or sets the font style used to draw the control's text. + /// + public FontStyle FontStyle + { + get => GetValue(FontStyleProperty); + set => SetValue(FontStyleProperty, value); + } + + /// + /// Gets or sets the font weight used to draw the control's text. + /// + public FontWeight FontWeight + { + get => GetValue(FontWeightProperty); + set => SetValue(FontWeightProperty, value); + } + + /// + /// Gets or sets the font stretch used to draw the control's text. + /// + public FontStretch FontStretch + { + get => GetValue(FontStretchProperty); + set => SetValue(FontStretchProperty, value); + } + + private GenericTextRunProperties? _textRunProperties; + public GenericTextRunProperties TextRunProperties + { + get + { + return _textRunProperties ??= CreateTextRunProperties(); + } + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + _textRunProperties = value; + SetCurrentValue(FontFamilyProperty, value.Typeface.FontFamily); + SetCurrentValue(FontFeaturesProperty, value.FontFeatures); + SetCurrentValue(FontSizeProperty, value.FontRenderingEmSize); + SetCurrentValue(FontStyleProperty, value.Typeface.Style); + SetCurrentValue(FontWeightProperty, value.Typeface.Weight); + SetCurrentValue(FontStretchProperty, value.Typeface.Stretch); + } + } + + private GenericTextRunProperties CreateTextRunProperties() + { + Typeface typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch); + return new GenericTextRunProperties(typeface, FontFeatures, FontSize, + textDecorations: null, + foregroundBrush: Brushes.Black, + backgroundBrush: null, + baselineAlignment: BaselineAlignment.Baseline, + cultureInfo: null); + } + + // TextParagraphProperties + + private GenericTextParagraphProperties? _textParagraphProperties; + public GenericTextParagraphProperties TextParagraphProperties + { + get + { + return _textParagraphProperties ??= CreateTextParagraphProperties(); + } + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + _textParagraphProperties = null; + SetCurrentValue(FlowDirectionProperty, value.FlowDirection); + } + } + + private GenericTextParagraphProperties CreateTextParagraphProperties() + { + return new GenericTextParagraphProperties( + FlowDirection, + TextAlignment.Start, + firstLineInParagraph: false, + alwaysCollapsible: false, + TextRunProperties, + textWrapping: TextWrapping.NoWrap, + lineHeight: 0, + indent: 0, + letterSpacing: 0); + } + + private readonly ITextSource _textSource; + private class TextSource : ITextSource + { + private readonly InteractiveLineControl _owner; + + public TextSource(InteractiveLineControl owner) + { + _owner = owner; + } + + public TextRun? GetTextRun(int textSourceIndex) + { + string text = _owner.Text ?? string.Empty; + + if (textSourceIndex < 0 || textSourceIndex >= text.Length) + return null; + + return new TextCharacters(text, _owner.TextRunProperties); + } + } + + private TextLine? _textLine; + public TextLine? TextLine => _textLine ??= TextFormatter.Current.FormatLine(_textSource, 0, Bounds.Size.Width, TextParagraphProperties); + + private TextLayout? _textLayout; + public TextLayout TextLayout => _textLayout ??= new TextLayout(_textSource, TextParagraphProperties); + + private Size? _textLineSize; + protected Size TextLineSize => _textLineSize ??= TextLine is { } textLine ? new Size(textLine.WidthIncludingTrailingWhitespace, textLine.Height) : default; + + private Size? _inkSize; + protected Size InkSize => _inkSize ??= TextLine is { } textLine ? new Size(textLine.OverhangLeading + textLine.WidthIncludingTrailingWhitespace + textLine.OverhangTrailing, textLine.Extent) : default; + + public event EventHandler? TextLineChanged; + + public InteractiveLineControl() + { + _textSource = new TextSource(this); + + RenderOptions.SetEdgeMode(this, EdgeMode.Aliased); + RenderOptions.SetTextRenderingMode(this, TextRenderingMode.SubpixelAntialias); + } + + private void InvalidateTextRunProperties() + { + _textRunProperties = null; + InvalidateTextParagraphProperties(); + } + + private void InvalidateTextParagraphProperties() + { + _textParagraphProperties = null; + InvalidateTextLine(); + } + + private void InvalidateTextLine() + { + _textLayout = null; + _textLine = null; + _textLineSize = null; + _inkSize = null; + InvalidateMeasure(); + InvalidateVisual(); + + TextLineChanged?.Invoke(this, EventArgs.Empty); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + switch (change.Property.Name) + { + case nameof(FontFamily): + case nameof(FontSize): + InvalidateTextRunProperties(); + break; + + case nameof(FontStyle): + case nameof(FontWeight): + case nameof(FontStretch): + InvalidateTextRunProperties(); + break; + + case nameof(FlowDirection): + InvalidateTextParagraphProperties(); + break; + + case nameof(Text): + InvalidateTextLine(); + break; + + case nameof(BaselineStroke): + _baselinePen = null; + InvalidateVisual(); + break; + + case nameof(TextBoundsStroke): + _textBoundsPen = null; + InvalidateVisual(); + break; + + case nameof(RunBoundsStroke): + _runBoundsPen = null; + InvalidateVisual(); + break; + + case nameof(NextHitStroke): + _nextHitPen = null; + InvalidateVisual(); + break; + + case nameof(PreviousHitStroke): + _previousHitPen = null; + InvalidateVisual(); + break; + + case nameof(BackspaceHitStroke): + _backspaceHitPen = null; + InvalidateVisual(); + break; + } + + base.OnPropertyChanged(change); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (TextLine == null) + return default; + + return new Size(Math.Max(TextLineSize.Width, InkSize.Width), Math.Max(TextLineSize.Height, InkSize.Height)); + } + + private const double VerticalSpacing = 5; + private const double HorizontalSpacing = 5; + private const double ArrowSize = 5; + + private Dictionary _labelsCache = new(); + protected FormattedText GetOrCreateLabel(string label, IBrush brush, bool disableCache = false) + { + if (_labelsCache.TryGetValue(label, out var text)) + return text; + + text = new FormattedText(label, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, Typeface.Default, 8, brush); + + if (!disableCache) + _labelsCache[label] = text; + + return text; + } + + private Rect _inkRenderBounds; + private Rect _lineRenderBounds; + + public Rect InkRenderBounds => _inkRenderBounds; + public Rect LineRenderBounds => _lineRenderBounds; + + public override void Render(DrawingContext context) + { + TextLine? textLine = TextLine; + if (textLine == null) + return; + + // overhang leading should be negative when extending (e.g. for j) WPF: "When the leading alignment point comes before the leading drawn pixel, the value is negative." - docs wrong but values correct + // overhang trailing should be negative when extending (e.g. for f) WPF: "The OverhangTrailing value will be positive when the trailing drawn pixel comes before the trailing alignment point." + // overhang after should be negative when inside (e.g. for x) WPF: "The value is positive if the bottommost drawn pixel goes below the line bottom, and is negative if it is within (on or above) the line." + // => we want overhang before to be negative when inside (e.g. for x) + + double overhangBefore = textLine.Extent - textLine.OverhangAfter - textLine.Height; + Rect inkBounds = new Rect(new Point(textLine.OverhangLeading, -overhangBefore), InkSize); + Rect lineBounds = new Rect(new Point(0, 0), TextLineSize); + + if (inkBounds.Left < 0) + lineBounds = lineBounds.Translate(new Vector(-inkBounds.Left, 0)); + + if (inkBounds.Top < 0) + lineBounds = lineBounds.Translate(new Vector(0, -inkBounds.Top)); + + _inkRenderBounds = inkBounds; + _lineRenderBounds = lineBounds; + + Rect bounds = new Rect(0, 0, Math.Max(inkBounds.Right, lineBounds.Right), Math.Max(inkBounds.Bottom, lineBounds.Bottom)); + double labelX = bounds.Right + HorizontalSpacing; + + if (Background is IBrush background) + context.FillRectangle(background, lineBounds); + + if (ExtentStroke != null) + { + context.DrawRectangle(ExtentPen, inkBounds); + RenderLabel(context, nameof(textLine.Extent), ExtentStroke, labelX, inkBounds.Top); + } + + using (context.PushTransform(Matrix.CreateTranslation(lineBounds.Left, lineBounds.Top))) + { + labelX -= lineBounds.Left; // labels to ignore horizontal transform + + if (BaselineStroke != null) + { + RenderFontLine(context, textLine.Baseline, lineBounds.Width, BaselinePen); // no other lines currently available in Avalonia + RenderLabel(context, nameof(textLine.Baseline), BaselineStroke, labelX, textLine.Baseline); + } + + textLine.Draw(context, lineOrigin: default); + + var runBoundsStroke = RunBoundsStroke; + if (TextBoundsStroke != null || runBoundsStroke != null) + { + IReadOnlyList textBounds = textLine.GetTextBounds(textLine.FirstTextSourceIndex, textLine.Length); + foreach (var textBound in textBounds) + { + if (runBoundsStroke != null) + { + var runBounds = textBound.TextRunBounds; + foreach (var runBound in runBounds) + context.DrawRectangle(RunBoundsPen, runBound.Rectangle); + } + + context.DrawRectangle(TextBoundsPen, textBound.Rectangle); + } + } + + double y = inkBounds.Bottom - lineBounds.Top + VerticalSpacing * 2; + + if (NextHitStroke != null) + { + RenderHits(context, NextHitPen, textLine, textLine.GetNextCaretCharacterHit, new CharacterHit(0), ref y); + RenderLabel(context, nameof(textLine.GetNextCaretCharacterHit), NextHitStroke, labelX, y); + y += VerticalSpacing * 2; + } + + if (PreviousHitStroke != null) + { + RenderLabel(context, nameof(textLine.GetPreviousCaretCharacterHit), PreviousHitStroke, labelX, y); + RenderHits(context, PreviousHitPen, textLine, textLine.GetPreviousCaretCharacterHit, new CharacterHit(textLine.Length), ref y); + y += VerticalSpacing * 2; + } + + if (BackspaceHitStroke != null) + { + RenderLabel(context, nameof(textLine.GetBackspaceCaretCharacterHit), BackspaceHitStroke, labelX, y); + RenderHits(context, BackspaceHitPen, textLine, textLine.GetBackspaceCaretCharacterHit, new CharacterHit(textLine.Length), ref y); + y += VerticalSpacing * 2; + } + + if (DistanceStroke != null) + { + y += VerticalSpacing; + + var label = RenderLabel(context, nameof(textLine.GetDistanceFromCharacterHit), DistanceStroke, 0, y); + y += label.Height; + + for (int i = 0; i < textLine.Length; i++) + { + var hit = new CharacterHit(i); + CharacterHit prevHit = default, nextHit = default; + + double leftLabelX = -HorizontalSpacing; + + // we want z-order to be previous, next, distance + // but labels need to be ordered next, distance, previous + if (NextHitStroke != null) + { + nextHit = textLine.GetNextCaretCharacterHit(hit); + var nextLabel = RenderLabel(context, $" > {nextHit.FirstCharacterIndex}+{nextHit.TrailingLength}", NextHitStroke, leftLabelX, y, TextAlignment.Right, disableCache: true); + leftLabelX -= nextLabel.WidthIncludingTrailingWhitespace; + } + + if (PreviousHitStroke != null) + { + prevHit = textLine.GetPreviousCaretCharacterHit(hit); + var x1 = textLine.GetDistanceFromCharacterHit(new CharacterHit(prevHit.FirstCharacterIndex, 0)); + var x2 = textLine.GetDistanceFromCharacterHit(new CharacterHit(prevHit.FirstCharacterIndex + prevHit.TrailingLength, 0)); + RenderHorizontalPoint(context, x1, x2, y, PreviousHitPen, ArrowSize); + } + + if (NextHitStroke != null) + { + var x1 = textLine.GetDistanceFromCharacterHit(new CharacterHit(nextHit.FirstCharacterIndex, 0)); + var x2 = textLine.GetDistanceFromCharacterHit(new CharacterHit(nextHit.FirstCharacterIndex + nextHit.TrailingLength, 0)); + RenderHorizontalPoint(context, x1, x2, y, NextHitPen, ArrowSize); + } + + label = RenderLabel(context, $"[{i}]", DistanceStroke, leftLabelX, y, TextAlignment.Right); + leftLabelX -= label.WidthIncludingTrailingWhitespace; + + if (PreviousHitStroke != null) + RenderLabel(context, $"{prevHit.FirstCharacterIndex}+{prevHit.TrailingLength} < ", PreviousHitStroke, leftLabelX, y, TextAlignment.Right, disableCache: true); + + double distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(i)); + RenderHorizontalBar(context, 0, distance, y, DistancePen, ArrowSize); + //RenderLabel(context, distance.ToString("F2"), DistanceStroke, distance + HorizontalSpacing, y, disableCache: true); + + y += label.Height; + } + } + } + } + + [return: NotNullIfNotNull("brush")] + private FormattedText? RenderLabel(DrawingContext context, string label, IBrush? brush, double x, double y, TextAlignment alignment = TextAlignment.Left, bool disableCache = false) + { + if (brush == null) + return null; + + var text = GetOrCreateLabel(label, brush, disableCache); + + if (alignment == TextAlignment.Right) + context.DrawText(text, new Point(x - text.WidthIncludingTrailingWhitespace, y - text.Height / 2)); + else + context.DrawText(text, new Point(x, y - text.Height / 2)); + + return text; + } + + private void RenderHits(DrawingContext context, IPen hitPen, TextLine textLine, Func nextHit, CharacterHit startingHit, ref double y) + { + CharacterHit lastHit = startingHit; + double lastX = textLine.GetDistanceFromCharacterHit(lastHit); + double lastDirection = 0; + y -= VerticalSpacing; // we always start with adding one below + + while (true) + { + CharacterHit hit = nextHit(lastHit); + if (hit == lastHit) + break; + + double x = textLine.GetDistanceFromCharacterHit(hit); + double direction = Math.Sign(x - lastX); + + if (direction == 0 || lastDirection != direction) + y += VerticalSpacing; + + if (direction == 0) + RenderPoint(context, x, y, hitPen, ArrowSize); + else + RenderHorizontalArrow(context, lastX, x, y, hitPen, ArrowSize); + + lastX = x; + lastHit = hit; + lastDirection = direction; + } + } + + private void RenderPoint(DrawingContext context, double x, double y, IPen pen, double arrowHeight) + { + context.DrawEllipse(pen.Brush, pen, new Point(x, y), ArrowSize / 2, ArrowSize / 2); + } + + private void RenderHorizontalPoint(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + PathGeometry startCap = new PathGeometry(); + PathFigure startFigure = new PathFigure(); + startFigure.StartPoint = new Point(xStart, y - size / 2); + startFigure.IsClosed = true; + startFigure.IsFilled = true; + startFigure.Segments!.Add(new ArcSegment { Size = new Size(size / 2, size / 2), Point = new Point(xStart, y + size / 2), SweepDirection = SweepDirection.CounterClockwise }); + startCap.Figures!.Add(startFigure); + + context.DrawGeometry(pen.Brush, pen, startCap); + + PathGeometry endCap = new PathGeometry(); + PathFigure endFigure = new PathFigure(); + endFigure.StartPoint = new Point(xEnd, y - size / 2); + endFigure.IsClosed = true; + endFigure.IsFilled = false; + endFigure.Segments!.Add(new ArcSegment { Size = new Size(size / 2, size / 2), Point = new Point(xEnd, y + size / 2), SweepDirection = SweepDirection.Clockwise }); + endCap.Figures!.Add(endFigure); + + context.DrawGeometry(pen.Brush, pen, endCap); + } + + private void RenderHorizontalArrow(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + context.DrawLine(pen, new Point(xStart, y), new Point(xEnd, y)); + context.DrawLine(pen, new Point(xStart, y - size / 2), new Point(xStart, y + size / 2)); // start cap + + if (xEnd >= xStart) + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(xEnd - size, y - size / 2), + new Point(xEnd - size, y + size/2), + new Point(xEnd, y) + ], isFilled: true)); + else + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(xEnd + size, y - size / 2), + new Point(xEnd + size, y + size/2), + new Point(xEnd, y) + ], isFilled: true)); + } + private void RenderHorizontalBar(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + context.DrawLine(pen, new Point(xStart, y), new Point(xEnd, y)); + context.DrawLine(pen, new Point(xStart, y - size / 2), new Point(xStart, y + size / 2)); // start cap + context.DrawLine(pen, new Point(xEnd, y - size / 2), new Point(xEnd, y + size / 2)); // end cap + } + + private void RenderFontLine(DrawingContext context, double y, double width, IPen pen) + { + context.DrawLine(pen, new Point(0, y), new Point(width, y)); + } + } +} diff --git a/samples/TextTestApp/MainWindow.axaml b/samples/TextTestApp/MainWindow.axaml new file mode 100644 index 0000000000..6dd6670124 --- /dev/null +++ b/samples/TextTestApp/MainWindow.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/TextTestApp/MainWindow.axaml.cs b/samples/TextTestApp/MainWindow.axaml.cs new file mode 100644 index 0000000000..493bc3e9d4 --- /dev/null +++ b/samples/TextTestApp/MainWindow.axaml.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; + +namespace TextTestApp +{ + public partial class MainWindow : Window + { + private SelectionAdorner? _selectionAdorner; + + public MainWindow() + { + InitializeComponent(); + + _selectionAdorner = new(); + _selectionAdorner.Stroke = Brushes.Red; + _selectionAdorner.Fill = new SolidColorBrush(Colors.LightSkyBlue, 0.25); + _selectionAdorner.IsHitTestVisible = false; + AdornerLayer.SetIsClipEnabled(_selectionAdorner, false); + AdornerLayer.SetAdorner(_rendering, _selectionAdorner); + + _rendering.TextLineChanged += OnShapeBufferChanged; + OnShapeBufferChanged(); + } + + private void OnNewWindowClick(object? sender, RoutedEventArgs e) + { + MainWindow win = new MainWindow(); + win.Show(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.F5) + { + _rendering.InvalidateVisual(); + OnShapeBufferChanged(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + if (_hits.IsKeyboardFocusWithin && _hits.SelectedIndex != -1) + { + _hits.SelectedIndex = -1; + e.Handled = true; + } + else if (_buffer.IsKeyboardFocusWithin && _buffer.SelectedIndex != -1) + { + _buffer.SelectedIndex = -1; + e.Handled = true; + } + } + + base.OnKeyDown(e); + } + + private void OnShapeBufferChanged(object? sender, EventArgs e) => OnShapeBufferChanged(); + private void OnShapeBufferChanged() + { + if (_selectionAdorner == null) + return; + + ListBuffers(); + ListHits(); + + Rect bounds = _rendering.LineRenderBounds; + _selectionAdorner!.Transform = Matrix.CreateTranslation(bounds.X, bounds.Y); + } + + private void ListBuffers() + { + for (int i = _buffer.ItemCount - 1; i >= 1; i--) + _buffer.Items.RemoveAt(i); + + TextLine? textLine = _rendering.TextLine; + if (textLine == null) + return; + + double currentX = _rendering.LineRenderBounds.Left; + foreach (TextRun run in textLine.TextRuns) + { + if (run is ShapedTextRun shapedRun) + { + _buffer.Items.Add(new TextBlock + { + Text = $"{run.GetType().Name}: Bidi = {shapedRun.BidiLevel}, Font = {shapedRun.ShapedBuffer.GlyphTypeface.FamilyName}", + FontWeight = FontWeight.Bold, + Padding = new Thickness(10, 0), + Tag = run, + }); + + ListBuffer(textLine, shapedRun, ref currentX); + } + else + _buffer.Items.Add(new TextBlock + { + Text = run.GetType().Name, + FontWeight = FontWeight.Bold, + Padding = new Thickness(10, 0), + Tag = run + }); + } + } + + private void ListHits() + { + for (int i = _hits.ItemCount - 1; i >= 1; i--) + _hits.Items.RemoveAt(i); + + TextLine? textLine = _rendering.TextLine; + if (textLine == null) + return; + + for (int i = 0; i < textLine.Length; i++) + { + string? clusterText = _rendering.Text!.Substring(i, 1); + string? clusterHex = ToHex(clusterText); + + var hit = new CharacterHit(i); + var prevHit = textLine.GetPreviousCaretCharacterHit(hit); + var nextHit = textLine.GetNextCaretCharacterHit(hit); + var bkspHit = textLine.GetBackspaceCaretCharacterHit(hit); + + GridRow row = new GridRow { ColumnSpacing = 10 }; + row.Children.Add(new Control()); + row.Children.Add(new TextBlock { Text = $"{bkspHit.FirstCharacterIndex}+{bkspHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = $"{prevHit.FirstCharacterIndex}+{prevHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = i.ToString(), FontWeight = FontWeight.Bold }); + row.Children.Add(new TextBlock { Text = $"{nextHit.FirstCharacterIndex}+{nextHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = clusterHex }); + row.Children.Add(new TextBlock { Text = clusterText }); + row.Children.Add(new TextBlock { Text = textLine.GetDistanceFromCharacterHit(hit).ToString() }); + row.Tag = i; + + _hits.Items.Add(row); + } + } + + private static readonly IBrush TransparentAliceBlue = new SolidColorBrush(0x0F0188FF); + private static readonly IBrush TransparentAntiqueWhite = new SolidColorBrush(0x28DF8000); + private void ListBuffer(TextLine textLine, ShapedTextRun shapedRun, ref double currentX) + { + ShapedBuffer buffer = shapedRun.ShapedBuffer; + + int lastClusterStart = -1; + bool oddCluster = false; + + IReadOnlyList glyphInfos = buffer; + + currentX += shapedRun.GlyphRun.BaselineOrigin.X; + for (var i = 0; i < glyphInfos.Count; i++) + { + GlyphInfo info = glyphInfos[i]; + int clusterStart = info.GlyphCluster; + int clusterLength = FindClusterLenghtAt(i); + string? clusterText = _rendering.Text!.Substring(clusterStart, clusterLength); + string? clusterHex = ToHex(clusterText); + + Border border = new Border(); + if (clusterStart == lastClusterStart) + { + clusterText = clusterHex = null; + } + else + { + oddCluster = !oddCluster; + lastClusterStart = clusterStart; + } + border.Background = oddCluster ? TransparentAliceBlue : TransparentAntiqueWhite; + + + GridRow row = new GridRow { ColumnSpacing = 10 }; + row.Children.Add(new Control()); + row.Children.Add(new TextBlock { Text = clusterStart.ToString() }); + row.Children.Add(new TextBlock { Text = clusterText }); + row.Children.Add(new TextBlock { Text = clusterHex, TextWrapping = TextWrapping.Wrap }); + row.Children.Add(new Image { Source = CreateGlyphDrawing(shapedRun.GlyphRun.GlyphTypeface, FontSize, info), Margin = new Thickness(2) }); + row.Children.Add(new TextBlock { Text = info.GlyphIndex.ToString() }); + row.Children.Add(new TextBlock { Text = info.GlyphAdvance.ToString() }); + row.Children.Add(new TextBlock { Text = info.GlyphOffset.ToString() }); + + Geometry glyph = GetGlyphOutline(shapedRun.GlyphRun.GlyphTypeface, shapedRun.GlyphRun.FontRenderingEmSize, info); + Rect glyphBounds = glyph.Bounds; + Rect offsetBounds = glyphBounds.Translate(new Vector(currentX + info.GlyphOffset.X, info.GlyphOffset.Y)); + + TextBlock boundsBlock = new TextBlock { Text = offsetBounds.ToString() }; + ToolTip.SetTip(boundsBlock, "Origin bounds: " + glyphBounds); + row.Children.Add(boundsBlock); + + border.Child = row; + border.Tag = offsetBounds; + _buffer.Items.Add(border); + + currentX += glyphInfos[i].GlyphAdvance; + } + + int FindClusterLenghtAt(int index) + { + int cluster = glyphInfos[index].GlyphCluster; + if (shapedRun.BidiLevel % 2 == 0) + { + while (++index < glyphInfos.Count) + if (glyphInfos[index].GlyphCluster != cluster) + return glyphInfos[index].GlyphCluster - cluster; + + return shapedRun.Length + glyphInfos[0].GlyphCluster - cluster; + } + else + { + while (--index >= 0) + if (glyphInfos[index].GlyphCluster != cluster) + return glyphInfos[index].GlyphCluster - cluster; + + return shapedRun.Length + glyphInfos[glyphInfos.Count - 1].GlyphCluster - cluster; + } + } + } + + private IImage CreateGlyphDrawing(IGlyphTypeface glyphTypeface, double emSize, GlyphInfo info) + { + return new DrawingImage { Drawing = new GeometryDrawing { Brush = Brushes.Black, Geometry = GetGlyphOutline(glyphTypeface, emSize, info) } }; + } + + private Geometry GetGlyphOutline(IGlyphTypeface typeface, double emSize, GlyphInfo info) + { + // substitute for GlyphTypeface.GetGlyphOutline + return new GlyphRun(typeface, emSize, new[] { '\0' }, [info]).BuildGeometry(); + } + + private void OnPointerMoved(object sender, PointerEventArgs e) + { + InteractiveLineControl lineControl = (InteractiveLineControl)sender; + TextLayout textLayout = lineControl.TextLayout; + Rect lineBounds = lineControl.LineRenderBounds; + + PointerPoint pointerPoint = e.GetCurrentPoint(lineControl); + Point point = new Point(pointerPoint.Position.X - lineBounds.Left, pointerPoint.Position.Y - lineBounds.Top); + _coordinates.Text = $"{pointerPoint.Position.X:F4}, {pointerPoint.Position.Y:F4}"; + + TextHitTestResult textHit = textLayout.HitTestPoint(point); + _hit.Text = $"{textHit.TextPosition} ({textHit.CharacterHit.FirstCharacterIndex}+{textHit.CharacterHit.TrailingLength})"; + if (textHit.IsTrailing) + _hit.Text += " T"; + + if (textHit.IsInside) + { + _hits.SelectedIndex = textHit.TextPosition + 1; // header + } + else + _hits.SelectedIndex = -1; + } + + private void OnHitTestMethodChanged(object? sender, RoutedEventArgs e) + { + _hits.SelectionMode = _hitRangeToggle.IsChecked == true ? SelectionMode.Multiple : SelectionMode.Single; + } + + private void OnHitsSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_selectionAdorner == null) + return; + + List rectangles = new List(); + TextLayout textLayout = _rendering.TextLayout; + + if (_hitRangeToggle.IsChecked == true) + { + // collect continuous selected indices + List<(int start, int length)> selections = new(1); + + int[] indices = _hits.Selection.SelectedIndexes.ToArray(); + Array.Sort(indices); + + int currentIndex = -1; + int currentLength = 0; + for (int i = 0; i < indices.Length; i++) + if (_hits.Items[indices[i]] is Control { Tag: int index }) + { + if (index == currentIndex + currentLength) + { + currentLength++; + } + else + { + if (currentLength > 0) + selections.Add((currentIndex, currentLength)); + + currentIndex = index; + currentLength = 1; + } + } + + if (currentLength > 0) + selections.Add((currentIndex, currentLength)); + + foreach (var selection in selections) + { + var selectionRectangles = textLayout.HitTestTextRange(selection.start, selection.length); + rectangles.AddRange(selectionRectangles); + } + } + else + { + if (_hits.SelectedItem is Control { Tag: int index }) + { + Rect rect = textLayout.HitTestTextPosition(index); + rectangles.Add(rect); + } + } + + _selectionAdorner.Rectangles = rectangles; + } + + private void OnBufferSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + List rectangles = new List(_buffer.Selection.Count); + + foreach (var row in _buffer.SelectedItems) + if (row is Control { Tag: Rect rect }) + rectangles.Add(rect); + + _selectionAdorner.Rectangles = rectangles; + } + + private static string ToHex(string s) + { + if (string.IsNullOrEmpty(s)) + return s; + + return string.Join(" ", s.Select(c => ((int)c).ToString("X4"))); + } + } +} diff --git a/samples/TextTestApp/Program.cs b/samples/TextTestApp/Program.cs new file mode 100644 index 0000000000..cb953f8ba5 --- /dev/null +++ b/samples/TextTestApp/Program.cs @@ -0,0 +1,25 @@ +using System; +using Avalonia; + +namespace TextTestApp +{ + static class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } + } +} diff --git a/samples/TextTestApp/SelectionAdorner.cs b/samples/TextTestApp/SelectionAdorner.cs new file mode 100644 index 0000000000..bfaa030fc8 --- /dev/null +++ b/samples/TextTestApp/SelectionAdorner.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace TextTestApp +{ + public class SelectionAdorner : Control + { + public static readonly StyledProperty FillProperty = + AvaloniaProperty.Register(nameof(Fill)); + + public static readonly StyledProperty StrokeProperty = + AvaloniaProperty.Register(nameof(Stroke)); + + public static readonly StyledProperty TransformProperty = + AvaloniaProperty.Register(nameof(Transform), Matrix.Identity); + + public Matrix Transform + { + get => this.GetValue(TransformProperty); + set => SetValue(TransformProperty, value); + } + + public IBrush? Stroke + { + get => GetValue(StrokeProperty); + set => SetValue(StrokeProperty, value); + } + + public IBrush? Fill + { + get => GetValue(FillProperty); + set => SetValue(FillProperty, value); + } + + private IList? _rectangles; + public IList? Rectangles + { + get => _rectangles; + set + { + _rectangles = value; + InvalidateVisual(); + } + } + + public SelectionAdorner() + { + AffectsRender(FillProperty, StrokeProperty, TransformProperty); + } + + public override void Render(DrawingContext context) + { + var rectangles = Rectangles; + if (rectangles == null) + return; + + using (context.PushTransform(Transform)) + { + Pen pen = new Pen(Stroke, 1); + for (int i = 0; i < rectangles.Count; i++) + { + Rect rectangle = rectangles[i]; + Rect normalized = rectangle.Width < 0 ? new Rect(rectangle.TopRight, rectangle.BottomLeft) : rectangle; + + if (rectangles[i].Width == 0) + context.DrawLine(pen, rectangle.TopLeft, rectangle.BottomRight); + else + context.DrawRectangle(Fill, pen, normalized); + + RenderCue(context, pen, rectangle.TopLeft, 5, isFilled: true); + RenderCue(context, pen, rectangle.TopRight, 5, isFilled: false); + } + } + } + + private void RenderCue(DrawingContext context, IPen pen, Point p, double size, bool isFilled) + { + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(p.X - size / 2, p.Y - size), + new Point(p.X + size / 2, p.Y - size), + new Point(p.X, p.Y), + new Point(p.X - size / 2, p.Y - size), + ], isFilled)); + } + } +} diff --git a/samples/TextTestApp/TextTestApp.csproj b/samples/TextTestApp/TextTestApp.csproj new file mode 100644 index 0000000000..50dc52c768 --- /dev/null +++ b/samples/TextTestApp/TextTestApp.csproj @@ -0,0 +1,23 @@ + + + + WinExe + $(AvsCurrentTargetFramework) + true + app.manifest + true + enable + + + + + + + + + + + + + + diff --git a/samples/TextTestApp/app.manifest b/samples/TextTestApp/app.manifest new file mode 100644 index 0000000000..db90057191 --- /dev/null +++ b/samples/TextTestApp/app.manifest @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Base/Media/CharacterHit.cs b/src/Avalonia.Base/Media/CharacterHit.cs index 27cf3a42dc..48b89c0543 100644 --- a/src/Avalonia.Base/Media/CharacterHit.cs +++ b/src/Avalonia.Base/Media/CharacterHit.cs @@ -35,6 +35,10 @@ namespace Avalonia.Media /// /// Gets the trailing length value for the character that got hit. /// + /// + /// In the case of a leading edge, this value is 0. In the case of a trailing edge, + /// this value is the number of code points until the next valid caret position. + /// public int TrailingLength { get; } public bool Equals(CharacterHit other) diff --git a/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs b/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs index b9ed31523e..15b3d5a9b4 100644 --- a/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs @@ -1,7 +1,7 @@ namespace Avalonia.Media.TextFormatting { /// - /// Generic implementation of TextParagraphProperties + /// Generic implementation of . /// public sealed class GenericTextParagraphProperties : TextParagraphProperties { @@ -11,45 +11,45 @@ private double _lineHeight; /// - /// Constructing TextParagraphProperties + /// Initializes a new instance of the . /// - /// default paragraph's default run properties - /// logical horizontal alignment - /// text wrap option - /// Paragraph line height - /// letter spacing + /// Default text run properties, such as typeface or foreground brush. + /// The alignment of inline content in a block. + /// A value that controls whether text wraps when it reaches the flow edge of its containing block box. + /// Paragraph's line spacing. + /// The amount of letter spacing. public GenericTextParagraphProperties(TextRunProperties defaultTextRunProperties, TextAlignment textAlignment = TextAlignment.Left, - TextWrapping textWrap = TextWrapping.NoWrap, + TextWrapping textWrapping = TextWrapping.NoWrap, double lineHeight = 0, double letterSpacing = 0) { DefaultTextRunProperties = defaultTextRunProperties; _textAlignment = textAlignment; - _textWrap = textWrap; + _textWrap = textWrapping; _lineHeight = lineHeight; LetterSpacing = letterSpacing; } /// - /// Constructing TextParagraphProperties + /// Initializes a new instance of the . /// - /// text flow direction - /// logical horizontal alignment - /// true if the paragraph is the first line in the paragraph - /// true if the line is always collapsible - /// default paragraph's default run properties - /// text wrap option - /// Paragraph line height - /// line indentation - /// letter spacing + /// The primary text advance direction. + /// The alignment of inline content in a block. + /// if the paragraph is the first line in the paragraph + /// if the formatted line may always be collapsed. If (the default), only lines that overflow the paragraph width are collapsed. + /// Default text run properties, such as typeface or foreground brush. + /// A value that controls whether text wraps when it reaches the flow edge of its containing block box. + /// Paragraph's line spacing. + /// The amount of line indentation. + /// The amount of letter spacing. public GenericTextParagraphProperties( FlowDirection flowDirection, TextAlignment textAlignment, bool firstLineInParagraph, bool alwaysCollapsible, TextRunProperties defaultTextRunProperties, - TextWrapping textWrap, + TextWrapping textWrapping, double lineHeight, double indent, double letterSpacing) @@ -59,16 +59,16 @@ FirstLineInParagraph = firstLineInParagraph; AlwaysCollapsible = alwaysCollapsible; DefaultTextRunProperties = defaultTextRunProperties; - _textWrap = textWrap; + _textWrap = textWrapping; _lineHeight = lineHeight; LetterSpacing = letterSpacing; Indent = indent; } /// - /// Constructing TextParagraphProperties from another one + /// Initializes a new instance of the with values copied from the specified . /// - /// source line props + /// The to copy values from. public GenericTextParagraphProperties(TextParagraphProperties textParagraphProperties) : this(textParagraphProperties.FlowDirection, textParagraphProperties.TextAlignment, @@ -82,64 +82,43 @@ { } - /// - /// This property specifies whether the primary text advance - /// direction shall be left-to-right, right-to-left, or top-to-bottom. - /// + /// public override FlowDirection FlowDirection { get { return _flowDirection; } } - /// - /// This property describes how inline content of a block is aligned. - /// + /// public override TextAlignment TextAlignment { get { return _textAlignment; } } - /// - /// Paragraph's line height - /// + /// public override double LineHeight { get { return _lineHeight; } } - /// - /// Indicates the first line of the paragraph. - /// + /// public override bool FirstLineInParagraph { get; } - /// - /// If true, the formatted line may always be collapsed. If false (the default), - /// only lines that overflow the paragraph width are collapsed. - /// + /// public override bool AlwaysCollapsible { get; } - /// - /// Paragraph's default run properties - /// + /// public override TextRunProperties DefaultTextRunProperties { get; } - /// - /// This property controls whether or not text wraps when it reaches the flow edge - /// of its containing block box - /// + /// public override TextWrapping TextWrapping { get { return _textWrap; } } - /// - /// Line indentation - /// + /// public override double Indent { get; } - /// - /// The letter spacing - /// + /// public override double LetterSpacing { get; } /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs b/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs index c41d9552ca..cde63c02a6 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs @@ -6,63 +6,68 @@ public abstract class TextParagraphProperties { /// - /// This property specifies whether the primary text advance - /// direction shall be left-to-right, right-to-left. + /// Gets a value that specifies whether the primary text advance direction shall be left-to-right, or right-to-left. /// public abstract FlowDirection FlowDirection { get; } /// - /// Gets the text alignment. + /// Gets a value that describes how an inline content of a block is aligned. /// public abstract TextAlignment TextAlignment { get; } /// - /// Paragraph's line height + /// Gets the height of a line of text. /// public abstract double LineHeight { get; } /// - /// Paragraph's line spacing + /// Gets or sets paragraph's line spacing. /// internal double LineSpacing { get; set; } /// - /// Indicates the first line of the paragraph. + /// Gets a value that indicates whether the text run is the first line of the paragraph. /// public abstract bool FirstLineInParagraph { get; } /// + /// Gets a value that indicates whether a formatted line can always be collapsed. + /// + /// /// If true, the formatted line may always be collapsed. If false (the default), /// only lines that overflow the paragraph width are collapsed. - /// + /// public virtual bool AlwaysCollapsible { get { return false; } } /// - /// Gets the default text style. + /// Gets the default text run properties, such as typeface or foreground brush. /// public abstract TextRunProperties DefaultTextRunProperties { get; } /// + /// Gets the collection of TextDecoration objects. + /// + /// /// If not null, text decorations to apply to all runs in the line. This is in addition /// to any text decorations specified by the TextRunProperties for individual text runs. /// public virtual TextDecorationCollection? TextDecorations => null; /// - /// Gets the text wrapping. + /// Gets a value that controls whether text wraps when it reaches the flow edge of its containing block box. /// public abstract TextWrapping TextWrapping { get; } /// - /// Line indentation + /// Gets the amount of line indentation. /// public abstract double Indent { get; } /// - /// Get the paragraph indentation. + /// Gets the paragraph indentation. /// public virtual double ParagraphIndent { @@ -75,7 +80,7 @@ public virtual double DefaultIncrementalTab => 0; /// - /// Gets the letter spacing. + /// Gets the amount of letter spacing. /// public virtual double LetterSpacing { get; } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 39bf60a42c..be4c35b353 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -374,7 +374,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.WrapWithOverflow); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.WrapWithOverflow); var textSource = new SingleBufferTextSource("ABCDEFHFFHFJHKHFK", defaultProperties, true); @@ -488,7 +488,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var textLine = formatter.FormatLine(textSource, currentPosition, paragraphWidth, - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap)); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap)); Assert.NotNull(textLine); @@ -544,7 +544,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource(text, defaultProperties); @@ -574,7 +574,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting const string text = "012345"; var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource(text, defaultProperties); var formatter = new TextFormatterImpl(); @@ -632,7 +632,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var textLine = formatter.FormatLine(textSource, currentPosition, 300, - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.WrapWithOverflow)); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.WrapWithOverflow)); Assert.NotNull(textLine); @@ -712,7 +712,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default); var paragraphProperties = - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource(text, defaultProperties); var formatter = new TextFormatterImpl(); @@ -742,7 +742,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textSource = new SingleBufferTextSource("0123456789_0123456789_0123456789_0123456789", defaultProperties); var formatter = new TextFormatterImpl(); @@ -773,7 +773,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default); var paragraphProperties = - new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.NoWrap); + new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.NoWrap); var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); @@ -878,7 +878,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrapping: TextWrapping.Wrap); var text = "Hello World"; @@ -975,7 +975,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black); var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, - textWrap: wrapping); + textWrapping: wrapping); using (Start()) { @@ -993,7 +993,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black); var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, - textWrap: wrapping); + textWrapping: wrapping); using (Start()) { @@ -1077,7 +1077,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var defaultRunProperties = new GenericTextRunProperties(Typeface.Default); - var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrapping: TextWrapping.Wrap); var text = "一二三四 TEXT 一二三四五六七八九十零"; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index e78f7ba1b9..5e1f6ea017 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -1168,7 +1168,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(Typeface.Default, 72, foregroundBrush: Brushes.Black); - var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrap: TextWrapping.Wrap); + var paragraphProperties = new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap); var textLayout = new TextLayout(new SingleBufferTextSource("01", defaultProperties, true), paragraphProperties, maxWidth: 36);