Browse Source

Text API sample app (#19455)

* xml documentatation for paragraph properties

* TextTestApp

* text test files renamed parameter

* glyph ink bounds

* shaped buffer make selected row visible

* text test app force light theme

* text test app distinguish start and end distance marks

---------

Co-authored-by: Jan Kučera <miloush@users.noreply.github.com>
Co-authored-by: Benedikt Stebner <Gillibald@users.noreply.github.com>
Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/19469/head
Jan Kučera 6 months ago
committed by GitHub
parent
commit
21b5812746
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      Avalonia.Desktop.slnf
  2. 7
      Avalonia.sln
  3. 5
      samples/TextTestApp/App.axaml
  4. 21
      samples/TextTestApp/App.axaml.cs
  5. 24
      samples/TextTestApp/GridRow.cs
  6. 705
      samples/TextTestApp/InteractiveLineControl.cs
  7. 105
      samples/TextTestApp/MainWindow.axaml
  8. 340
      samples/TextTestApp/MainWindow.axaml.cs
  9. 25
      samples/TextTestApp/Program.cs
  10. 90
      samples/TextTestApp/SelectionAdorner.cs
  11. 23
      samples/TextTestApp/TextTestApp.csproj
  12. 28
      samples/TextTestApp/app.manifest
  13. 4
      src/Avalonia.Base/Media/CharacterHit.cs
  14. 85
      src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs
  15. 29
      src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs
  16. 24
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  17. 2
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

1
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",

7
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}

5
samples/TextTestApp/App.axaml

@ -0,0 +1,5 @@
<Application xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="TextTestApp.App" RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

21
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();
}
}
}

24
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;
}
}
}
}

705
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
{
/// <summary>
/// Defines the <see cref="Text" /> property.
/// </summary>
public static readonly StyledProperty<string?> TextProperty =
TextBlock.TextProperty.AddOwner<InteractiveLineControl>();
/// <summary>
/// Defines the <see cref="Background"/> property.
/// </summary>
public static readonly StyledProperty<IBrush?> BackgroundProperty =
Border.BackgroundProperty.AddOwner<InteractiveLineControl>();
public static readonly StyledProperty<IBrush?> ExtentStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(nameof(ExtentStroke));
public static readonly StyledProperty<IBrush?> BaselineStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(nameof(BaselineStroke));
public static readonly StyledProperty<IBrush?> TextBoundsStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(nameof(TextBoundsStroke));
public static readonly StyledProperty<IBrush?> RunBoundsStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(nameof(RunBoundsStroke));
public static readonly StyledProperty<IBrush?> NextHitStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(nameof(NextHitStroke));
public static readonly StyledProperty<IBrush?> BackspaceHitStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(nameof(BackspaceHitStroke));
public static readonly StyledProperty<IBrush?> PreviousHitStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(nameof(PreviousHitStroke));
public static readonly StyledProperty<IBrush?> DistanceStrokeProperty =
AvaloniaProperty.Register<InteractiveLineControl, IBrush?>(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);
/// <summary>
/// Gets or sets the text to draw.
/// </summary>
public string? Text
{
get => GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
/// <summary>
/// Gets or sets a brush used to paint the control's background.
/// </summary>
public IBrush? Background
{
get => GetValue(BackgroundProperty);
set => SetValue(BackgroundProperty, value);
}
// TextRunProperties
/// <summary>
/// Defines the <see cref="FontFamily"/> property.
/// </summary>
public static readonly StyledProperty<FontFamily> FontFamilyProperty =
TextElement.FontFamilyProperty.AddOwner<InteractiveLineControl>();
/// <summary>
/// Defines the <see cref="FontFeaturesProperty"/> property.
/// </summary>
public static readonly StyledProperty<FontFeatureCollection?> FontFeaturesProperty =
TextElement.FontFeaturesProperty.AddOwner<InteractiveLineControl>();
/// <summary>
/// Defines the <see cref="FontSize"/> property.
/// </summary>
public static readonly StyledProperty<double> FontSizeProperty =
TextElement.FontSizeProperty.AddOwner<InteractiveLineControl>();
/// <summary>
/// Defines the <see cref="FontStyle"/> property.
/// </summary>
public static readonly StyledProperty<FontStyle> FontStyleProperty =
TextElement.FontStyleProperty.AddOwner<InteractiveLineControl>();
/// <summary>
/// Defines the <see cref="FontWeight"/> property.
/// </summary>
public static readonly StyledProperty<FontWeight> FontWeightProperty =
TextElement.FontWeightProperty.AddOwner<InteractiveLineControl>();
/// <summary>
/// Defines the <see cref="FontWeight"/> property.
/// </summary>
public static readonly StyledProperty<FontStretch> FontStretchProperty =
TextElement.FontStretchProperty.AddOwner<InteractiveLineControl>();
/// <summary>
/// Gets or sets the font family used to draw the control's text.
/// </summary>
public FontFamily FontFamily
{
get => GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
/// <summary>
/// Gets or sets the font features turned on/off.
/// </summary>
public FontFeatureCollection? FontFeatures
{
get => GetValue(FontFeaturesProperty);
set => SetValue(FontFeaturesProperty, value);
}
/// <summary>
/// Gets or sets the size of the control's text in points.
/// </summary>
public double FontSize
{
get => GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
/// <summary>
/// Gets or sets the font style used to draw the control's text.
/// </summary>
public FontStyle FontStyle
{
get => GetValue(FontStyleProperty);
set => SetValue(FontStyleProperty, value);
}
/// <summary>
/// Gets or sets the font weight used to draw the control's text.
/// </summary>
public FontWeight FontWeight
{
get => GetValue(FontWeightProperty);
set => SetValue(FontWeightProperty, value);
}
/// <summary>
/// Gets or sets the font stretch used to draw the control's text.
/// </summary>
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<string, FormattedText> _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> 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<CharacterHit, CharacterHit> 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));
}
}
}

105
samples/TextTestApp/MainWindow.axaml

@ -0,0 +1,105 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:TextTestApp"
x:Class="TextTestApp.MainWindow"
Title="Text Test App" Width="700" Height="700">
<DockPanel>
<Border DockPanel.Dock="Bottom" Background="WhiteSmoke" BorderThickness="0,1,0,0" BorderBrush="Silver" Padding="2">
<DockPanel>
<ToggleSwitch Name="_hitRangeToggle" DockPanel.Dock="Right" OnContent="HitTestTextRange" OffContent="HitTestTextPosition" IsCheckedChanged="OnHitTestMethodChanged" />
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="HitTestPoint:" Margin="5,0" />
<TextBlock Name="_coordinates" MinWidth="120" />
<Border Width="5" BorderThickness="1,0,0,0" BorderBrush="Silver" UseLayoutRounding="True" Margin="5,0,0,0" />
<TextBlock Text="TextPosition:" Margin="5,0" />
<TextBlock Name="_hit" MinWidth="60" />
<Border Width="5" BorderThickness="1,0,0,0" BorderBrush="Silver" UseLayoutRounding="True" Margin="5,0,0,0" />
</StackPanel>
</DockPanel>
</Border>
<DockPanel DockPanel.Dock="Top" Margin="5">
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right">
<Label Content="_Font:" Target="{Binding ElementName=_font}" VerticalAlignment="Center" Margin="5,0,0,0" />
<ComboBox Name="_font" ItemsSource="{Binding SystemFonts, Source={x:Static FontManager.Current}}" />
<Label Content="_Size:" Target="{Binding ElementName=_size}" VerticalAlignment="Center" Margin="5,0,0,0" />
<TextBox Name="_size" VerticalAlignment="Center" Text="64" />
<Button VerticalAlignment="Center" Click="OnNewWindowClick" ToolTip.Tip="New window" Margin="5,0,0,0">+</Button>
</StackPanel>
<Label Content="_Text:" Target="{Binding ElementName=_text}" VerticalAlignment="Center"/>
<TextBox Name="_text" Text="Hello!" VerticalAlignment="Center" />
</DockPanel>
<Grid RowDefinitions="*,5,*">
<local:InteractiveLineControl Name="_rendering" DockPanel.Dock="Top" Margin="16" HorizontalAlignment="Center"
Text="{Binding Text, ElementName=_text}"
FontFamily="{Binding SelectedValue, ElementName=_font}"
FontSize="{Binding Text, ElementName=_size}"
Background="BlanchedAlmond"
ExtentStroke="Black"
BaselineStroke="Blue"
TextBoundsStroke="Goldenrod"
RunBoundsStroke="Gold"
NextHitStroke="Green"
PreviousHitStroke="Blue"
BackspaceHitStroke="Red"
DistanceStroke="Black"
PointerMoved="OnPointerMoved"
/>
<GridSplitter Grid.Row="1" />
<TabControl Grid.Row="2" DockPanel.Dock="Bottom" Background="White" BorderBrush="Whitesmoke" BorderThickness="0,1,0,0">
<TabItem Header="Shaped Buffer">
<ListBox Name="_buffer" Grid.IsSharedSizeScope="True" ScrollViewer.HorizontalScrollBarVisibility="Auto" SelectionMode="Multiple" SelectionChanged="OnBufferSelectionChanged" Background="Transparent">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Background" Value="White" />
</Style>
</ListBox.Styles>
<Border Background="WhiteSmoke" BorderBrush="Silver" BorderThickness="0,1">
<local:GridRow ColumnSpacing="10">
<TextBlock Text="" />
<TextBlock Text="Index" />
<TextBlock Text="Characters" />
<TextBlock Text="Codepoints" />
<TextBlock Text="Glyph" />
<TextBlock Text="Glyph ID" />
<TextBlock Text="Advance" />
<TextBlock Text="Offset" />
<TextBlock Text="Ink Bounds" />
</local:GridRow>
</Border>
</ListBox>
</TabItem>
<TabItem Header="Character Hits">
<ListBox Name="_hits" Grid.IsSharedSizeScope="True" ScrollViewer.HorizontalScrollBarVisibility="Auto" SelectionChanged="OnHitsSelectionChanged" Background="Transparent">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Background" Value="White" />
</Style>
</ListBox.Styles>
<Border Background="WhiteSmoke" BorderBrush="Silver" BorderThickness="0,1">
<local:GridRow ColumnSpacing="10">
<TextBlock Text="" />
<TextBlock Text="Backspace Hit" />
<TextBlock Text="Previous Hit" />
<TextBlock Text="Index" />
<TextBlock Text="Next Hit" />
<TextBlock Text="Codepoint" />
<TextBlock Text="Character" />
<TextBlock Text="Distance" />
</local:GridRow>
</Border>
</ListBox>
</TabItem>
</TabControl>
</Grid>
</DockPanel>
</Window>

340
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<GlyphInfo> 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<Rect> rectangles = new List<Rect>();
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<Rect> rectangles = new List<Rect>(_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")));
}
}
}

25
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<App>()
.UsePlatformDetect()
.LogToTrace();
}
}
}

90
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<IBrush?> FillProperty =
AvaloniaProperty.Register<SelectionAdorner, IBrush?>(nameof(Fill));
public static readonly StyledProperty<IBrush?> StrokeProperty =
AvaloniaProperty.Register<SelectionAdorner, IBrush?>(nameof(Stroke));
public static readonly StyledProperty<Matrix> TransformProperty =
AvaloniaProperty.Register<SelectionAdorner, Matrix>(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<Rect>? _rectangles;
public IList<Rect>? Rectangles
{
get => _rectangles;
set
{
_rectangles = value;
InvalidateVisual();
}
}
public SelectionAdorner()
{
AffectsRender<SelectionAdorner>(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));
}
}
}

23
samples/TextTestApp/TextTestApp.csproj

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>$(AvsCurrentTargetFramework)</TargetFramework>
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
<ApplicationManifest>app.manifest</ApplicationManifest>
<IncludeAvaloniaGenerators>true</IncludeAvaloniaGenerators>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Fonts.Inter\Avalonia.Fonts.Inter.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj" />
</ItemGroup>
<Import Project="..\..\build\SampleApp.props" />
<Import Project="..\..\build\ReferenceCoreLibraries.props" />
<Import Project="..\..\build\BuildTargets.targets" />
<Import Project="..\..\build\SourceGenerators.props" />
</Project>

28
samples/TextTestApp/app.manifest

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ControlCatalog.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows Vista -->
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
<!-- Windows 7 -->
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
<!-- Windows 8 -->
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
<!-- Windows 8.1 -->
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

4
src/Avalonia.Base/Media/CharacterHit.cs

@ -35,6 +35,10 @@ namespace Avalonia.Media
/// <summary>
/// Gets the trailing length value for the character that got hit.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public int TrailingLength { get; }
public bool Equals(CharacterHit other)

85
src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs

@ -1,7 +1,7 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Generic implementation of TextParagraphProperties
/// Generic implementation of <see cref="TextParagraphProperties"/>.
/// </summary>
public sealed class GenericTextParagraphProperties : TextParagraphProperties
{
@ -11,45 +11,45 @@
private double _lineHeight;
/// <summary>
/// Constructing TextParagraphProperties
/// Initializes a new instance of the <see cref="GenericTextParagraphProperties"/>.
/// </summary>
/// <param name="defaultTextRunProperties">default paragraph's default run properties</param>
/// <param name="textAlignment">logical horizontal alignment</param>
/// <param name="textWrap">text wrap option</param>
/// <param name="lineHeight">Paragraph line height</param>
/// <param name="letterSpacing">letter spacing</param>
/// <param name="defaultTextRunProperties">Default text run properties, such as typeface or foreground brush.</param>
/// <param name="textAlignment">The alignment of inline content in a block.</param>
/// <param name="textWrapping">A value that controls whether text wraps when it reaches the flow edge of its containing block box.</param>
/// <param name="lineHeight">Paragraph's line spacing.</param>
/// <param name="letterSpacing">The amount of letter spacing.</param>
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;
}
/// <summary>
/// Constructing TextParagraphProperties
/// Initializes a new instance of the <see cref="GenericTextParagraphProperties"/>.
/// </summary>
/// <param name="flowDirection">text flow direction</param>
/// <param name="textAlignment">logical horizontal alignment</param>
/// <param name="firstLineInParagraph">true if the paragraph is the first line in the paragraph</param>
/// <param name="alwaysCollapsible">true if the line is always collapsible</param>
/// <param name="defaultTextRunProperties">default paragraph's default run properties</param>
/// <param name="textWrap">text wrap option</param>
/// <param name="lineHeight">Paragraph line height</param>
/// <param name="indent">line indentation</param>
/// <param name="letterSpacing">letter spacing</param>
/// <param name="flowDirection">The primary text advance direction.</param>
/// <param name="textAlignment">The alignment of inline content in a block.</param>
/// <param name="firstLineInParagraph"><see langword="true"/> if the paragraph is the first line in the paragraph</param>
/// <param name="alwaysCollapsible"><see langword="true"/> if the formatted line may always be collapsed. If <see langword="false"/> (the default), only lines that overflow the paragraph width are collapsed.</param>
/// <param name="defaultTextRunProperties">Default text run properties, such as typeface or foreground brush.</param>
/// <param name="textWrapping">A value that controls whether text wraps when it reaches the flow edge of its containing block box.</param>
/// <param name="lineHeight">Paragraph's line spacing.</param>
/// <param name="indent">The amount of line indentation.</param>
/// <param name="letterSpacing">The amount of letter spacing.</param>
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;
}
/// <summary>
/// Constructing TextParagraphProperties from another one
/// Initializes a new instance of the <see cref="GenericTextParagraphProperties"/> with values copied from the specified <see cref="TextParagraphProperties"/>.
/// </summary>
/// <param name="textParagraphProperties">source line props</param>
/// <param name="textParagraphProperties">The <see cref="TextParagraphProperties"/> to copy values from.</param>
public GenericTextParagraphProperties(TextParagraphProperties textParagraphProperties)
: this(textParagraphProperties.FlowDirection,
textParagraphProperties.TextAlignment,
@ -82,64 +82,43 @@
{
}
/// <summary>
/// This property specifies whether the primary text advance
/// direction shall be left-to-right, right-to-left, or top-to-bottom.
/// </summary>
/// <inheritdoc/>
public override FlowDirection FlowDirection
{
get { return _flowDirection; }
}
/// <summary>
/// This property describes how inline content of a block is aligned.
/// </summary>
/// <inheritdoc/>
public override TextAlignment TextAlignment
{
get { return _textAlignment; }
}
/// <summary>
/// Paragraph's line height
/// </summary>
/// <inheritdoc/>
public override double LineHeight
{
get { return _lineHeight; }
}
/// <summary>
/// Indicates the first line of the paragraph.
/// </summary>
/// <inheritdoc/>
public override bool FirstLineInParagraph { get; }
/// <summary>
/// If true, the formatted line may always be collapsed. If false (the default),
/// only lines that overflow the paragraph width are collapsed.
/// </summary>
/// <inheritdoc/>
public override bool AlwaysCollapsible { get; }
/// <summary>
/// Paragraph's default run properties
/// </summary>
/// <inheritdoc/>
public override TextRunProperties DefaultTextRunProperties { get; }
/// <summary>
/// This property controls whether or not text wraps when it reaches the flow edge
/// of its containing block box
/// </summary>
/// <inheritdoc/>
public override TextWrapping TextWrapping
{
get { return _textWrap; }
}
/// <summary>
/// Line indentation
/// </summary>
/// <inheritdoc/>
public override double Indent { get; }
/// <summary>
/// The letter spacing
/// </summary>
/// <inheritdoc/>
public override double LetterSpacing { get; }
/// <summary>

29
src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs

@ -6,63 +6,68 @@
public abstract class TextParagraphProperties
{
/// <summary>
/// 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.
/// </summary>
public abstract FlowDirection FlowDirection { get; }
/// <summary>
/// Gets the text alignment.
/// Gets a value that describes how an inline content of a block is aligned.
/// </summary>
public abstract TextAlignment TextAlignment { get; }
/// <summary>
/// Paragraph's line height
/// Gets the height of a line of text.
/// </summary>
public abstract double LineHeight { get; }
/// <summary>
/// Paragraph's line spacing
/// Gets or sets paragraph's line spacing.
/// </summary>
internal double LineSpacing { get; set; }
/// <summary>
/// Indicates the first line of the paragraph.
/// Gets a value that indicates whether the text run is the first line of the paragraph.
/// </summary>
public abstract bool FirstLineInParagraph { get; }
/// <summary>
/// Gets a value that indicates whether a formatted line can always be collapsed.
/// </summary>
/// <remarks>
/// If true, the formatted line may always be collapsed. If false (the default),
/// only lines that overflow the paragraph width are collapsed.
/// </summary>
/// </remarks>
public virtual bool AlwaysCollapsible
{
get { return false; }
}
/// <summary>
/// Gets the default text style.
/// Gets the default text run properties, such as typeface or foreground brush.
/// </summary>
public abstract TextRunProperties DefaultTextRunProperties { get; }
/// <summary>
/// Gets the collection of TextDecoration objects.
/// </summary>
/// <remarks>
/// 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.
/// </summary>
public virtual TextDecorationCollection? TextDecorations => null;
/// <summary>
/// Gets the text wrapping.
/// Gets a value that controls whether text wraps when it reaches the flow edge of its containing block box.
/// </summary>
public abstract TextWrapping TextWrapping { get; }
/// <summary>
/// Line indentation
/// Gets the amount of line indentation.
/// </summary>
public abstract double Indent { get; }
/// <summary>
/// Get the paragraph indentation.
/// Gets the paragraph indentation.
/// </summary>
public virtual double ParagraphIndent
{
@ -75,7 +80,7 @@
public virtual double DefaultIncrementalTab => 0;
/// <summary>
/// Gets the letter spacing.
/// Gets the amount of letter spacing.
/// </summary>
public virtual double LetterSpacing { get; }
}

24
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 一二三四五六七八九十零";

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

Loading…
Cancel
Save