using System; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; 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(FontFeatures): if (change.OldValue is FontFeatureCollection oc) oc.CollectionChanged -= OnFeatureCollectionChanged; if (change.NewValue is FontFeatureCollection nc) nc.CollectionChanged += OnFeatureCollectionChanged; OnFeatureCollectionChanged(null, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); 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); } private void OnFeatureCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { InvalidateTextRunProperties(); } 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 const double LabelFontSize = 9; 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, LabelFontSize, 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 = Math.Max(inkBounds.Bottom, lineBounds.Bottom) + 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 (BackspaceHitStroke != null) { CharacterHit backHit = textLine.GetBackspaceCaretCharacterHit(hit); var x1 = textLine.GetDistanceFromCharacterHit(new CharacterHit(backHit.FirstCharacterIndex, 0)); var x2 = textLine.GetDistanceFromCharacterHit(new CharacterHit(backHit.FirstCharacterIndex + backHit.TrailingLength, 0)); RenderHorizontalPoint(context, x1, x2, y, BackspaceHitPen, ArrowSize); } 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)); } } }