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);