Browse Source

Initial implementation of ITextProvider.

With backend implementation in win32 (macOS to come).
pull/11519/head
Steven Kirk 3 years ago
committed by Steven Kirk
parent
commit
15bce1f0f4
  1. 7
      src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs
  2. 1
      src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs
  3. 76
      src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs
  4. 112
      src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs
  5. 1
      src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs
  6. 139
      src/Avalonia.Controls/Automation/Provider/ITextProvider.cs
  7. 74
      src/Avalonia.Controls/Automation/Provider/TextRange.cs
  8. 3
      src/Avalonia.Controls/TextBox.cs
  9. 65
      src/Windows/Avalonia.Win32/Automation/AutomationNode.Text.cs
  10. 68
      src/Windows/Avalonia.Win32/Automation/AutomationNode.cs
  11. 474
      src/Windows/Avalonia.Win32/Automation/AutomationTextRange.cs
  12. 10
      src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs
  13. 4
      src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs
  14. 59
      src/Windows/Avalonia.Win32/Interop/Automation/ITextRangeProvider.cs

7
src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs

@ -95,6 +95,12 @@ namespace Avalonia.Automation.Peers
/// </summary>
public string GetClassName() => GetClassNameCore() ?? string.Empty;
/// <summary>
/// Gets text that describes the functionality of the control that is associated with the
/// automation peer.
/// </summary>
public string? GetHelpText() => GetHelpTextCore();
/// <summary>
/// Gets the automation peer for the label that is targeted to the element.
/// </summary>
@ -231,6 +237,7 @@ namespace Avalonia.Automation.Peers
protected abstract Rect GetBoundingRectangleCore();
protected abstract IReadOnlyList<AutomationPeer> GetOrCreateChildrenCore();
protected abstract string GetClassNameCore();
protected abstract string? GetHelpTextCore();
protected abstract AutomationPeer? GetLabeledByCore();
protected abstract string? GetNameCore();
protected abstract AutomationPeer? GetParentCore();

1
src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs

@ -148,6 +148,7 @@ namespace Avalonia.Automation.Peers
protected override string? GetAutomationIdCore() => AutomationProperties.GetAutomationId(Owner) ?? Owner.Name;
protected override Rect GetBoundingRectangleCore() => GetBounds(Owner);
protected override string GetClassNameCore() => Owner.GetType().Name;
protected override string? GetHelpTextCore() => AutomationProperties.GetHelpText(Owner);
protected override bool HasKeyboardFocusCore() => Owner.IsFocused;
protected override bool IsContentElementCore() => true;
protected override bool IsControlElementCore() => true;

76
src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs

@ -1,21 +1,86 @@
using Avalonia.Controls;
using System;
using System.Collections.Generic;
using Avalonia.Automation.Provider;
using Avalonia.Controls;
using Avalonia.Reactive;
using Avalonia.Utilities;
using Avalonia.VisualTree;
namespace Avalonia.Automation.Peers
{
public class TextBlockAutomationPeer : ControlAutomationPeer
public class TextBlockAutomationPeer : ControlAutomationPeer, ITextProvider
{
public TextBlockAutomationPeer(TextBlock owner)
: base(owner)
{
owner.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged);
}
public int CaretIndex => -1;
public new TextBlock Owner => (TextBlock)base.Owner;
public TextRange DocumentRange => new TextRange(0, Owner.Text?.Length ?? 0);
public bool IsReadOnly => true;
public int LineCount => Owner.TextLayout.TextLines.Count;
public string? PlaceholderText => null;
public SupportedTextSelection SupportedTextSelection => SupportedTextSelection.None;
protected override AutomationControlType GetAutomationControlTypeCore()
public event EventHandler? TextChanged;
event EventHandler? ITextProvider.SelectedRangesChanged { add { } remove { } }
public IReadOnlyList<Rect> GetBounds(TextRange range)
{
if (Owner.GetVisualRoot() is Visual root &&
Owner.TransformToVisual(root) is Matrix m)
{
var source = Owner.TextLayout.HitTestTextRange(range.Start, range.Length);
var result = new List<Rect>();
foreach (var rect in source)
result.Add(rect.TransformToAABB(m));
return result;
}
return Array.Empty<Rect>();
}
public int GetLineForIndex(int index)
{
return Owner.TextLayout.GetLineIndexFromCharacterIndex(index, false);
}
public TextRange GetLineRange(int lineIndex)
{
var line = Owner.TextLayout.TextLines[lineIndex];
return new TextRange(line.FirstTextSourceIndex, line.Length);
}
public string GetText(TextRange range)
{
var text = Owner.Text ?? string.Empty;
var start = MathUtilities.Clamp(range.Start, 0, text.Length);
var end = MathUtilities.Clamp(range.End, 0, text.Length);
return text.Substring(start, end - start);
}
public IReadOnlyList<TextRange> GetVisibleRanges() => new[] { DocumentRange };
public TextRange RangeFromPoint(Point p)
{
return AutomationControlType.Text;
var i = 0;
if (Owner.GetVisualRoot() is Visual root &&
root.TransformToVisual(Owner) is Matrix m)
{
i = Owner.TextLayout.HitTestPoint(p.Transform(m)).TextPosition;
}
return new TextRange(i, 1);
}
IReadOnlyList<TextRange> ITextProvider.GetSelection() => Array.Empty<TextRange>();
void ITextProvider.ScrollIntoView(TextRange range) { }
void ITextProvider.Select(TextRange range) { }
protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Text;
protected override string? GetNameCore() => Owner.Text;
protected override bool IsControlElementCore()
@ -23,5 +88,8 @@ namespace Avalonia.Automation.Peers
// Return false if the control is part of a control template.
return Owner.TemplatedParent is null && base.IsControlElementCore();
}
private void OnTextChanged(string? text) => TextChanged?.Invoke(this, EventArgs.Empty);
}
}

112
src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs

@ -1,23 +1,131 @@
using Avalonia.Automation.Provider;
using System;
using System.Collections;
using System.Collections.Generic;
using Avalonia.Automation.Provider;
using Avalonia.Controls;
using Avalonia.Reactive;
using Avalonia.Utilities;
using Avalonia.VisualTree;
namespace Avalonia.Automation.Peers
{
public class TextBoxAutomationPeer : ControlAutomationPeer, IValueProvider
public class TextBoxAutomationPeer : ControlAutomationPeer, ITextProvider, IValueProvider
{
public TextBoxAutomationPeer(TextBox owner)
: base(owner)
{
owner.GetObservable(TextBox.SelectionStartProperty).Subscribe(OnSelectionChanged);
owner.GetObservable(TextBox.SelectionEndProperty).Subscribe(OnSelectionChanged);
owner.GetObservable(TextBox.TextProperty).Subscribe(OnTextChanged);
}
public int CaretIndex => Owner.CaretIndex;
public new TextBox Owner => (TextBox)base.Owner;
public bool IsReadOnly => Owner.IsReadOnly;
public int LineCount => Owner.Presenter?.TextLayout.TextLines.Count ?? 0;
public string? Value => Owner.Text;
public TextRange DocumentRange => new TextRange(0, Owner.Text?.Length ?? 0);
public string? PlaceholderText => Owner.Watermark;
public SupportedTextSelection SupportedTextSelection => SupportedTextSelection.Single;
public event EventHandler? SelectedRangesChanged;
public event EventHandler? TextChanged;
public IReadOnlyList<Rect> GetBounds(TextRange range)
{
if (Owner.GetVisualRoot() is Visual root &&
Owner.Presenter?.TransformToVisual(root) is Matrix m)
{
var source = Owner.Presenter?.TextLayout.HitTestTextRange(range.Start, range.Length)
?? Array.Empty<Rect>();
var result = new List<Rect>();
foreach (var rect in source)
result.Add(rect.TransformToAABB(m));
return result;
}
return Array.Empty<Rect>();
}
public int GetLineForIndex(int index)
{
return Owner.Presenter?.TextLayout.GetLineIndexFromCharacterIndex(index, false) ?? -1;
}
public TextRange GetLineRange(int lineIndex)
{
if (Owner.Presenter is null)
return TextRange.Empty;
var line = Owner.Presenter.TextLayout.TextLines[lineIndex];
return new TextRange(line.FirstTextSourceIndex, line.Length);
}
public IReadOnlyList<TextRange> GetSelection()
{
var range = TextRange.FromInclusiveStartEnd(Owner.SelectionStart, Owner.SelectionEnd);
return new[] { range };
}
public string GetText(TextRange range)
{
var text = Owner.Text ?? string.Empty;
var start = MathUtilities.Clamp(range.Start, 0, text.Length);
var end = MathUtilities.Clamp(range.End, 0, text.Length);
return text.Substring(start, end - start);
}
public IReadOnlyList<TextRange> GetVisibleRanges()
{
// Not sure this is necessary, QT just returns the document range too.
return new[] { DocumentRange };
}
public TextRange RangeFromPoint(Point p)
{
if (Owner.Presenter is null)
return TextRange.Empty;
var i = 0;
if (Owner.GetVisualRoot() is Visual root &&
root.TransformToVisual(Owner) is Matrix m)
{
i = Owner.Presenter.TextLayout.HitTestPoint(p.Transform(m)).TextPosition;
}
return new TextRange(i, 1);
}
public void ScrollIntoView(TextRange range)
{
if (Owner.Presenter is null || Owner.Scroll is null)
return;
var rects = Owner.Presenter.TextLayout.HitTestTextRange(range.Start, range.Length);
var rect = default(Rect);
foreach (var r in rects)
rect = rect.Union(r);
Owner.Presenter.BringIntoView(rect);
}
public void Select(TextRange range)
{
Owner.SelectionStart = range.Start;
Owner.SelectionEnd = range.End;
}
public void SetValue(string? value) => Owner.Text = value;
protected override AutomationControlType GetAutomationControlTypeCore()
{
return AutomationControlType.Edit;
}
private void OnSelectionChanged(int obj) => SelectedRangesChanged?.Invoke(this, EventArgs.Empty);
private void OnTextChanged(string? text) => TextChanged?.Invoke(this, EventArgs.Empty);
}
}

1
src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs

@ -11,6 +11,7 @@ namespace Avalonia.Automation.Peers
public void SetParent(AutomationPeer? parent) => TrySetParent(parent);
protected override void BringIntoViewCore() => GetParent()?.BringIntoView();
protected override Rect GetBoundingRectangleCore() => GetParent()?.GetBoundingRectangle() ?? default;
protected override string? GetHelpTextCore() => null;
protected override IReadOnlyList<AutomationPeer> GetOrCreateChildrenCore() => Array.Empty<AutomationPeer>();
protected override bool HasKeyboardFocusCore() => false;
protected override bool IsContentElementCore() => false;

139
src/Avalonia.Controls/Automation/Provider/ITextProvider.cs

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using Avalonia.Automation.Peers;
#nullable enable
namespace Avalonia.Automation.Provider
{
/// <summary>
/// Describes the type of text selection supported by an element.
/// </summary>
public enum SupportedTextSelection
{
/// <summary>
/// The element does not support text selection.
/// </summary>
None,
/// <summary>
/// The element supports a single, continuous text selection.
/// </summary>
Single,
/// <summary>
/// The element supports multiple, disjoint text selections.
/// </summary>
Multiple,
}
/// <summary>
/// Exposes methods and properties to support access by a UI Automation client to controls
/// that holds editable text.
/// </summary>
public interface ITextProvider
{
/// <summary>
/// Gets the current position of the caret, or -1 if there is no caret.
/// </summary>
int CaretIndex { get; }
/// <summary>
/// Gets a text range that encloses the main text of the document.
/// </summary>
TextRange DocumentRange { get; }
/// <summary>
/// Gets a value that indicates whether the text in the control is read-only.
/// </summary>
bool IsReadOnly { get; }
/// <summary>
/// Gets the total number of lines in the main text of the document.
/// </summary>
int LineCount { get; }
/// <summary>
/// Gets the placeholder text.
/// </summary>
string? PlaceholderText { get; }
/// <summary>
/// Gets a value that specifies the type of text selection that is supported by the control.
/// </summary>
SupportedTextSelection SupportedTextSelection { get; }
/// <summary>
/// Occurs when the control's selection changes.
/// </summary>
event EventHandler? SelectedRangesChanged;
/// <summary>
/// Occurs when the control's text changes.
/// </summary>
event EventHandler? TextChanged;
/// <summary>
/// Retrieves a collection of bounding rectangles for each fully or partially visible line
/// of text in a text range.
/// </summary>
/// <param name="range">The text range.</param>
/// <returns>A collection of <see cref="Rect"/>s in top-level coordinates.</returns>
IReadOnlyList<Rect> GetBounds(TextRange range);
/// <summary>
/// Retrieves the line number for the line that contains the specified character index.
/// </summary>
/// <param name="index">The character index.</param>
/// <returns>The line number, or -1 if the character index is invalid.</returns>
int GetLineForIndex(int index);
/// <summary>
/// Retrieves a text range that encloses the specified line.
/// </summary>
/// <param name="lineIndex">The index of the line.</param>
TextRange GetLineRange(int lineIndex);
/// <summary>
/// Retrieves a collection of text ranges that represents the currently selected text in a
/// text-based control.
/// </summary>
/// <remarks>
/// If the control contains a text insertion point but no text is selected, the result
/// should contain a degenerate (empty) text range at the position of the text insertion
/// point.
/// </remarks>
IReadOnlyList<TextRange> GetSelection();
/// <summary>
/// Retrieves the text for a specified range.
/// </summary>
/// <param name="range">The text range.</param>
/// <returns>The text.</returns>
string GetText(TextRange range);
/// <summary>
/// Retrieves a collection of disjoint text ranges from a text-based control where each
/// text range represents a contiguous span of visible text.
/// </summary>
IReadOnlyList<TextRange> GetVisibleRanges();
/// <summary>
/// Returns the degenerate (empty) text range nearest to the specified coordinates.
/// </summary>
/// <param name="p">The point in top-level coordinates.</param>
TextRange RangeFromPoint(Point p);
/// <summary>
/// Scrolls the specified range of text into view.
/// </summary>
/// <param name="range">The text range.</param>
void ScrollIntoView(TextRange range);
/// <summary>
/// Selects the specified range of text, replacing any previous selection.
/// </summary>
/// <param name="range"></param>
void Select(TextRange range);
}
}

74
src/Avalonia.Controls/Automation/Provider/TextRange.cs

@ -0,0 +1,74 @@
using System;
namespace Avalonia.Automation.Provider
{
/// <summary>
/// Represents a range of text returned by an <see cref="ITextProvider"/>.
/// </summary>
public readonly struct TextRange : IEquatable<TextRange>
{
/// <summary>
/// Instantiates a new <see cref="TextRange"/> instance with the specified start index and
/// length.
/// </summary>
/// <param name="start">The inclusive start index of the range.</param>
/// <param name="length">The length of the range.</param>
/// <exception cref="ArgumentException">
/// <paramref name="length"/> was negative.
/// </exception>
public TextRange(int start, int length)
{
if (length < 0)
throw new ArgumentException("Length may not be negative", nameof(length));
Start = start;
Length = length;
}
/// <summary>
/// Gets the inclusive start index of the range.
/// </summary>
public int Start { get; }
/// <summary>
/// Gets the exclusive end index of the range.
/// </summary>
public int End => Start + Length;
/// <summary>
/// Gets the length of the range.
/// </summary>
public int Length { get; }
/// <summary>
/// Gets an empty <see cref="TextRange"/>.
/// </summary>
public static TextRange Empty => new(0, 0);
/// <summary>
/// Creates a new <see cref="TextRange"/> from an inclusive start and end index.
/// </summary>
/// <param name="start">The inclusive start index of the range.</param>
/// <param name="end">The inclusive end index of the range.</param>
/// <returns></returns>
public static TextRange FromInclusiveStartEnd(int start, int end)
{
var s = Math.Min(start, end);
var e = Math.Max(start, end);
return new(s, e - s);
}
public override bool Equals(object? obj) => obj is TextRange range && Equals(range);
public bool Equals(TextRange other) => Start == other.Start && Length == other.Length;
public override int GetHashCode()
{
var hashCode = -1730557556;
hashCode = hashCode * -1521134295 + Start.GetHashCode();
hashCode = hashCode * -1521134295 + Length.GetHashCode();
return hashCode;
}
public static bool operator ==(TextRange left, TextRange right) => left.Equals(right);
public static bool operator !=(TextRange left, TextRange right) => !(left == right);
}
}

3
src/Avalonia.Controls/TextBox.cs

@ -798,6 +798,9 @@ namespace Avalonia.Controls
remove => RemoveHandler(TextChangingEvent, value);
}
internal TextPresenter? Presenter => _presenter;
internal IScrollable? Scroll { get; private set; }
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
_presenter = e.NameScope.Get<TextPresenter>("PART_TextPresenter");

65
src/Windows/Avalonia.Win32/Automation/AutomationNode.Text.cs

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Automation.Provider;
using UIA = Avalonia.Win32.Interop.Automation;
#nullable enable
namespace Avalonia.Win32.Automation
{
internal partial class AutomationNode : UIA.ITextProvider
{
public UIA.ITextRangeProvider DocumentRange
{
get
{
var range = InvokeSync<ITextProvider, TextRange>(x => x.DocumentRange);
return new AutomationTextRange(this, range);
}
}
public UIA.SupportedTextSelection SupportedTextSelection
{
get => (UIA.SupportedTextSelection)InvokeSync<ITextProvider, SupportedTextSelection>(x => x.SupportedTextSelection);
}
public UIA.ITextRangeProvider[] GetVisibleRanges() => GetRanges(x => x.GetVisibleRanges());
public UIA.ITextRangeProvider? RangeFromChild(UIA.IRawElementProviderSimple childElement) => null;
public UIA.ITextRangeProvider? RangeFromPoint(Point screenLocation)
{
var p = ToClient(screenLocation);
var range = InvokeSync<ITextProvider, TextRange>(x => x.RangeFromPoint(screenLocation));
return new AutomationTextRange(this, range);
}
UIA.ITextRangeProvider[] UIA.ITextProvider.GetSelection() => GetRanges(x => x.GetSelection());
private UIA.ITextRangeProvider[] GetRanges(Func<ITextProvider, IReadOnlyList<TextRange>> selector)
{
var source = InvokeSync(selector);
return source?.Select(x => new AutomationTextRange(this, x)).ToArray() ?? Array.Empty<AutomationTextRange>();
}
private void InitializeTextProvider()
{
if (Peer is ITextProvider provider)
{
provider.SelectedRangesChanged += PeerSelectedTextRangesChanged;
provider.TextChanged += PeerTextChanged;
}
}
private void PeerSelectedTextRangesChanged(object? sender, EventArgs e)
{
RaiseEvent(UIA.UiaEventId.Text_TextSelectionChanged);
}
private void PeerTextChanged(object? sender, EventArgs e)
{
RaiseEvent(UIA.UiaEventId.Text_TextChanged);
}
}
}

68
src/Windows/Avalonia.Win32/Automation/AutomationNode.cs

@ -54,6 +54,7 @@ namespace Avalonia.Win32.Automation
_runtimeId = new int[] { 3, GetHashCode() };
Peer = peer;
s_nodes.Add(peer, this);
InitializeTextProvider();
peer.ChildrenChanged += Peer_ChildrenChanged;
peer.PropertyChanged += Peer_PropertyChanged;
}
@ -118,6 +119,7 @@ namespace Avalonia.Win32.Automation
UiaPatternId.ScrollItem => this,
UiaPatternId.Selection => ThisIfPeerImplementsProvider<AAP.ISelectionProvider>(),
UiaPatternId.SelectionItem => ThisIfPeerImplementsProvider<AAP.ISelectionItemProvider>(),
UiaPatternId.Text => ThisIfPeerImplementsProvider<AAP.ITextProvider>(),
UiaPatternId.Toggle => ThisIfPeerImplementsProvider<AAP.IToggleProvider>(),
UiaPatternId.Value => ThisIfPeerImplementsProvider<AAP.IValueProvider>(),
_ => null,
@ -137,6 +139,7 @@ namespace Avalonia.Win32.Automation
UiaPropertyId.Culture => CultureInfo.CurrentCulture.LCID,
UiaPropertyId.FrameworkId => "Avalonia",
UiaPropertyId.HasKeyboardFocus => InvokeSync(() => Peer.HasKeyboardFocus()),
UiaPropertyId.HelpText => GetHelpText(),
UiaPropertyId.IsContentElement => InvokeSync(() => Peer.IsContentElement()),
UiaPropertyId.IsControlElement => InvokeSync(() => Peer.IsControlElement()),
UiaPropertyId.IsEnabled => InvokeSync(() => Peer.IsEnabled()),
@ -192,13 +195,23 @@ namespace Avalonia.Win32.Automation
return peer is null ? null : s_nodes.GetValue(peer, Create);
}
public static void Release(AutomationPeer peer) => s_nodes.Remove(peer);
public AutomationNode? GetRoot()
{
Dispatcher.UIThread.VerifyAccess();
IRawElementProviderSimple[]? IRawElementProviderFragment.GetEmbeddedFragmentRoots() => null;
void IRawElementProviderSimple2.ShowContextMenu() => InvokeSync(() => Peer.ShowContextMenu());
void IInvokeProvider.Invoke() => InvokeSync((AAP.IInvokeProvider x) => x.Invoke());
var peer = Peer;
var parent = peer.GetParent();
while (peer.GetProvider<AAP.IRootProvider>() is null && parent is object)
{
peer = parent;
parent = peer.GetParent();
}
return peer is object ? GetOrCreate(peer) : null;
}
protected void InvokeSync(Action action)
public void InvokeSync(Action action)
{
if (Dispatcher.UIThread.CheckAccess())
action();
@ -206,7 +219,7 @@ namespace Avalonia.Win32.Automation
Dispatcher.UIThread.InvokeAsync(action).Wait();
}
protected T InvokeSync<T>(Func<T> func)
public T InvokeSync<T>(Func<T> func)
{
if (Dispatcher.UIThread.CheckAccess())
return func();
@ -214,7 +227,7 @@ namespace Avalonia.Win32.Automation
return Dispatcher.UIThread.InvokeAsync(func).Result;
}
protected void InvokeSync<TInterface>(Action<TInterface> action)
public void InvokeSync<TInterface>(Action<TInterface> action)
{
if (Peer.GetProvider<TInterface>() is TInterface i)
{
@ -233,7 +246,7 @@ namespace Avalonia.Win32.Automation
}
}
protected TResult InvokeSync<TInterface, TResult>(Func<TInterface, TResult> func)
public TResult InvokeSync<TInterface, TResult>(Func<TInterface, TResult> func)
{
if (Peer.GetProvider<TInterface>() is TInterface i)
{
@ -250,21 +263,40 @@ namespace Avalonia.Win32.Automation
throw new NotSupportedException();
}
public Point ToClient(Point p)
{
return ToClient(PixelPoint.FromPoint(p, 1));
}
private AutomationNode? GetRoot()
public Point ToClient(PixelPoint p)
{
Dispatcher.UIThread.VerifyAccess();
return (GetRoot() as RootAutomationNode)?.ToClient(p) ?? default;
}
var peer = Peer;
var parent = peer.GetParent();
public static void Release(AutomationPeer peer) => s_nodes.Remove(peer);
while (peer.GetProvider<AAP.IRootProvider>() is null && parent is object)
{
peer = parent;
parent = peer.GetParent();
}
IRawElementProviderSimple[]? IRawElementProviderFragment.GetEmbeddedFragmentRoots() => null;
void IRawElementProviderSimple2.ShowContextMenu() => InvokeSync(() => Peer.ShowContextMenu());
void IInvokeProvider.Invoke() => InvokeSync((AAP.IInvokeProvider x) => x.Invoke());
return peer is object ? GetOrCreate(peer) : null;
protected void RaiseEvent(UiaEventId eventId)
{
UiaCoreProviderApi.UiaRaiseAutomationEvent(this, (int)eventId);
}
protected void RaiseEvent(AutomationNode node, UiaEventId eventId)
{
UiaCoreProviderApi.UiaRaiseAutomationEvent(node, (int)eventId);
}
private string? GetHelpText()
{
return InvokeSync(() =>
{
// Placeholder is exposed via HelpText on UIA but help text and placeholder are two
// separate properties on macOS. Try both of them here.
return Peer.GetProvider<AAP.ITextProvider>()?.PlaceholderText ?? Peer.GetHelpText();
});
}
private static AutomationNode Create(AutomationPeer peer)

474
src/Windows/Avalonia.Win32/Automation/AutomationTextRange.cs

@ -0,0 +1,474 @@
using System;
using System.Runtime.InteropServices;
using Avalonia.Automation.Provider;
using Avalonia.Utilities;
using Avalonia.Win32.Interop.Automation;
using AAP = Avalonia.Automation.Provider;
#nullable enable
namespace Avalonia.Win32.Automation
{
internal class AutomationTextRange : ITextRangeProvider
{
private readonly AutomationNode _owner;
public AutomationTextRange(AutomationNode owner, TextRange range)
: this(owner, range.Start, range.End)
{
}
public AutomationTextRange(AutomationNode owner, int start, int end)
{
_owner = owner;
Start = start;
End = end;
}
public int Start { get; private set; }
public int End { get; private set; }
public TextRange Range => TextRange.FromInclusiveStartEnd(Start, End);
private AAP.ITextProvider InnerProvider => (AAP.ITextProvider)_owner.Peer;
public ITextRangeProvider Clone() => new AutomationTextRange(_owner, Range);
[return: MarshalAs(UnmanagedType.Bool)]
public bool Compare(ITextRangeProvider range)
{
return range is AutomationTextRange other && other.Start == Start && other.End == End;
}
public int CompareEndpoints(
TextPatternRangeEndpoint endpoint,
ITextRangeProvider targetRange,
TextPatternRangeEndpoint targetEndpoint)
{
var other = targetRange as AutomationTextRange ?? throw new InvalidOperationException("Invalid text range");
var e1 = (endpoint == TextPatternRangeEndpoint.Start) ? Start : End;
var e2 = (targetEndpoint == TextPatternRangeEndpoint.Start) ? other.Start : other.End;
return e1 - e2;
}
public void ExpandToEnclosingUnit(TextUnit unit)
{
_owner.InvokeSync(() =>
{
var text = InnerProvider.GetText(InnerProvider.DocumentRange);
switch (unit)
{
case TextUnit.Character:
if (Start == End)
End = MoveEndpointForward(text, End, TextUnit.Character, 1, out _);
break;
case TextUnit.Word:
// Move start left until we reach a word boundary.
for (; !AtWordBoundary(text, Start); Start--)
;
// Move end right until we reach word boundary (different from Start).
End = Math.Min(Math.Max(End, Start + 1), text.Length);
for (; !AtWordBoundary(text, End); End++)
;
break;
case TextUnit.Line:
if (InnerProvider.LineCount != 1)
{
int startLine = InnerProvider.GetLineForIndex(Start);
int endLine = InnerProvider.GetLineForIndex(End);
var start = InnerProvider.GetLineRange(startLine).Start;
var end = InnerProvider.GetLineRange(endLine).End;
MoveTo(start, end);
}
else
{
MoveTo(0, text.Length);
}
break;
case TextUnit.Paragraph:
// Move start left until we reach a paragraph boundary.
for (; !AtParagraphBoundary(text, Start); Start--)
;
// Move end right until we reach a paragraph boundary (different from Start).
End = Math.Min(Math.Max(End, Start + 1), text.Length);
for (; !AtParagraphBoundary(text, End); End++)
;
break;
case TextUnit.Format:
case TextUnit.Page:
case TextUnit.Document:
MoveTo(0, text.Length);
break;
default:
throw new ArgumentException("Invalid TextUnit.", nameof(unit));
}
});
}
public ITextRangeProvider? FindAttribute(
TextPatternAttribute attribute,
object? value,
[MarshalAs(UnmanagedType.Bool)] bool backward)
{
// TODO: Implement.
return null;
}
public ITextRangeProvider? FindText(
string text,
[MarshalAs(UnmanagedType.Bool)] bool backward,
[MarshalAs(UnmanagedType.Bool)] bool ignoreCase)
{
return _owner.InvokeSync(() =>
{
var rangeText = InnerProvider.GetText(Range);
if (ignoreCase)
{
rangeText = rangeText.ToLowerInvariant();
text = text.ToLowerInvariant();
}
var i = backward ?
rangeText.LastIndexOf(text, StringComparison.Ordinal) :
rangeText.IndexOf(text, StringComparison.Ordinal);
return i >= 0 ? new AutomationTextRange(_owner, Start + i, Start + i + text.Length) : null;
});
}
public object? GetAttributeValue(TextPatternAttribute attribute)
{
return attribute switch
{
TextPatternAttribute.IsReadOnlyAttributeId => _owner.InvokeSync(() => InnerProvider.IsReadOnly),
_ => null
};
}
public double[] GetBoundingRectangles()
{
return _owner.InvokeSync(() =>
{
var rects = InnerProvider.GetBounds(Range);
var result = new double[rects.Count * 4];
var root = _owner.GetRoot() as RootAutomationNode;
if (root is object)
{
for (var i = 0; i < rects.Count; i++)
{
var screenRect = root.ToScreen(rects[i]);
result[4 * i] = screenRect.X;
result[4 * i + 1] = screenRect.Y;
result[4 * i + 2] = screenRect.Width;
result[4 * i + 3] = screenRect.Height;
}
}
return result;
});
}
public IRawElementProviderSimple[] GetChildren() => Array.Empty<IRawElementProviderSimple>();
public IRawElementProviderSimple GetEnclosingElement() => _owner;
public string GetText(int maxLength)
{
if (maxLength < 0)
maxLength = int.MaxValue;
maxLength = Math.Min(maxLength, End - Start);
return _owner.InvokeSync(() => InnerProvider.GetText(new TextRange(Start, maxLength)));
}
public int Move(TextUnit unit, int count)
{
return _owner.InvokeSync(() =>
{
if (count == 0)
return 0;
var text = InnerProvider.GetText(new(0, int.MaxValue));
// Save the start and end in case the move fails.
var oldStart = Start;
var oldEnd = End;
var wasDegenerate = Start == End;
// Move the start of the text range forward or backward in the document by the
// requested number of text unit boundaries.
var moved = MoveEndpointByUnit(TextPatternRangeEndpoint.Start, unit, count);
var succeeded = moved != 0;
if (succeeded)
{
// Make the range degenerate at the new start point.
End = Start;
// If we previously had a non-degenerate range then expand the range.
if (!wasDegenerate)
{
var forwards = count > 0;
if (forwards && Start == text.Length - 1)
{
// The start is at the end of the document, so move the start backward by
// one text unit to expand the text range from the degenerate range state.
Start = MoveEndpointBackward(text, Start, unit, -1, out var expandMoved);
--moved;
succeeded = expandMoved == -1 && moved > 0;
}
else
{
// The start is not at the end of the document, so move the endpoint
// forward by one text unit to expand the text range from the degenerate
// state.
End = MoveEndpointForward(text, End, unit, 1, out var expandMoved);
succeeded = expandMoved > 0;
}
}
}
if (!succeeded)
{
Start = oldStart;
End = oldEnd;
moved = 0;
}
return moved;
});
}
public void MoveEndpointByRange(TextPatternRangeEndpoint endpoint, ITextRangeProvider targetRange, TextPatternRangeEndpoint targetEndpoint)
{
var editRange = targetRange as AutomationTextRange ?? throw new InvalidOperationException("Invalid text range");
var e = (targetEndpoint == TextPatternRangeEndpoint.Start) ? editRange.Start : editRange.End;
if (endpoint == TextPatternRangeEndpoint.Start)
Start = e;
else
End = e;
}
public int MoveEndpointByUnit(TextPatternRangeEndpoint endpoint, TextUnit unit, int count)
{
if (count == 0)
return 0;
return _owner.InvokeSync(() =>
{
var text = InnerProvider.GetText(InnerProvider.DocumentRange);
var moved = 0;
var moveStart = endpoint == TextPatternRangeEndpoint.Start;
if (count > 0)
{
if (moveStart)
Start = MoveEndpointForward(text, Start, unit, count, out moved);
else
End = MoveEndpointForward(text, End, unit, count, out moved);
}
else if (count < 0)
{
if (moveStart)
Start = MoveEndpointBackward(text, Start, unit, count, out moved);
else
End = MoveEndpointBackward(text, End, unit, count, out moved);
}
return moved;
});
}
void ITextRangeProvider.AddToSelection() => throw new NotSupportedException();
void ITextRangeProvider.RemoveFromSelection() => throw new NotSupportedException();
public void ScrollIntoView([MarshalAs(UnmanagedType.Bool)] bool alignToTop)
{
_owner.InvokeSync(() => InnerProvider.ScrollIntoView(Range));
}
public void Select()
{
_owner.InvokeSync(() => InnerProvider.Select(Range));
}
private static bool AtParagraphBoundary(string text, int index)
{
return index <= 0 || index >= text.Length || (text[index] == '\r') && (text[index] != '\n');
}
// returns true iff index identifies a word boundary within text.
// following richedit & word precedent the boundaries are at the leading edge of the word
// so the span of a word includes trailing whitespace.
private static bool AtWordBoundary(string text, int index)
{
// we are at a word boundary if we are at the beginning or end of the text
if (index <= 0 || index >= text.Length)
{
return true;
}
if (AtParagraphBoundary(text, index))
{
return true;
}
var ch1 = text[index - 1];
var ch2 = text[index];
// an apostrophe does *not* break a word if it follows or precedes characters
if ((char.IsLetterOrDigit(ch1) && IsApostrophe(ch2))
|| (IsApostrophe(ch1) && char.IsLetterOrDigit(ch2) && index >= 2 && char.IsLetterOrDigit(text[index - 2])))
{
return false;
}
// the following transitions mark boundaries.
// note: these are constructed to include trailing whitespace.
return (char.IsWhiteSpace(ch1) && !char.IsWhiteSpace(ch2))
|| (char.IsLetterOrDigit(ch1) && !char.IsLetterOrDigit(ch2))
|| (!char.IsLetterOrDigit(ch1) && char.IsLetterOrDigit(ch2))
|| (char.IsPunctuation(ch1) && char.IsWhiteSpace(ch2));
}
private static bool IsApostrophe(char ch)
{
return ch == '\'' ||
ch == (char)0x2019; // Unicode Right Single Quote Mark
}
private static bool IsWordBreak(char ch)
{
return char.IsWhiteSpace(ch) || char.IsPunctuation(ch);
}
private int MoveEndpointForward(string text, int index, TextUnit unit, int count, out int moved)
{
var limit = text.Length;
switch (unit)
{
case TextUnit.Character:
moved = Math.Min(count, limit - index);
index = index + moved;
index = index > limit ? limit : index;
break;
case TextUnit.Word:
// TODO: This will need to implement the Unicode word boundaries spec.
for (moved = 0; moved < count && index < text.Length; moved++)
{
for (index++; !AtWordBoundary(text, index); index++)
;
for (; index < text.Length && IsWordBreak(text[index]); index++)
;
}
break;
case TextUnit.Line:
{
var line = InnerProvider.GetLineForIndex(index);
var newLine = MathUtilities.Clamp(line + count, 0, InnerProvider.LineCount - 1);
index = InnerProvider.GetLineRange(newLine).Start;
moved = newLine - line;
}
break;
case TextUnit.Paragraph:
for (moved = 0; moved < count && index < text.Length; moved++)
{
for (index++; !AtParagraphBoundary(text, index); index++)
;
}
break;
case TextUnit.Format:
case TextUnit.Page:
case TextUnit.Document:
moved = index < limit ? 1 : 0;
index = limit;
break;
default:
throw new ArgumentException("Invalid TextUnit.", nameof(unit));
}
return index;
}
// moves an endpoint backward a certain number of units.
// the endpoint is just an index into the text so it could represent either
// the endpoint.
private int MoveEndpointBackward(string text, int index, TextUnit unit, int count, out int moved)
{
if (index == 0)
{
moved = 0;
return 0;
}
switch (unit)
{
case TextUnit.Character:
int oneBasedIndex = index + 1;
moved = Math.Max(count, -oneBasedIndex);
index += moved;
index = index < 0 ? 0 : index;
break;
case TextUnit.Word:
for (moved = 0; moved > count && index > 0; moved--)
{
for (index--; index < text.Length && IsWordBreak(text[index]); index--)
;
for (index--; !AtWordBoundary(text, index); index--)
;
}
break;
case TextUnit.Line:
{
var line = InnerProvider.GetLineForIndex(index);
// If a line other than the first consists of only a newline, then you can
// move backwards past this line and the position changes, hence this is
// counted. The first line is special, though: if it is empty, and you move
// say from the second line back up to the first, you cannot move further.
// However if the first line is nonempty, you can move from the end of the
// first line to its beginning! This latter move is counted, but if the
// first line is empty, it is not counted.
if (line == 0)
{
index = 0;
moved = !IsEol(text[0]) ? -1 : 0;
}
else
{
var newLine = MathUtilities.Clamp(line + count, 0, InnerProvider.LineCount - 1);
index = InnerProvider.GetLineRange(newLine).Start;
moved = newLine - line;
}
}
break;
case TextUnit.Paragraph:
for (moved = 0; moved > count && index > 0; moved--)
{
for (index--; !AtParagraphBoundary(text, index); index--)
;
}
break;
case TextUnit.Format:
case TextUnit.Page:
case TextUnit.Document:
moved = index > 0 ? -1 : 0;
index = 0;
break;
default:
throw new ArgumentException("Invalid TextUnit.", nameof(unit));
}
return index;
}
private void MoveTo(int start, int end)
{
if (start < 0 || end < start)
throw new InvalidOperationException();
Start = start;
End = end;
}
public static bool IsEol(char c)
{
return c == '\r' || c == '\n';
}
}
}

10
src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs

@ -77,7 +77,15 @@ namespace Avalonia.Win32.Automation
public void FocusChanged(object? sender, EventArgs e)
{
RaiseFocusChanged(GetOrCreate(Peer.GetFocus()));
if (GetOrCreate(Peer.GetFocus()) is AutomationNode node)
RaiseEvent(node, UiaEventId.AutomationFocusChanged);
}
public new Point ToClient(PixelPoint p)
{
if (WindowImpl is null)
return default;
return WindowImpl.PointToClient(p);
}
public Rect ToScreen(Rect rect)

4
src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs

@ -20,8 +20,8 @@ namespace Avalonia.Win32.Interop.Automation
{
ITextRangeProvider [] GetSelection();
ITextRangeProvider [] GetVisibleRanges();
ITextRangeProvider RangeFromChild(IRawElementProviderSimple childElement);
ITextRangeProvider RangeFromPoint(Point screenLocation);
ITextRangeProvider? RangeFromChild(IRawElementProviderSimple childElement);
ITextRangeProvider? RangeFromPoint(Point screenLocation);
ITextRangeProvider DocumentRange { get; }
SupportedTextSelection SupportedTextSelection { get; }
}

59
src/Windows/Avalonia.Win32/Interop/Automation/ITextRangeProvider.cs

@ -1,5 +1,7 @@
using System.Runtime.InteropServices;
#nullable enable
namespace Avalonia.Win32.Interop.Automation
{
public enum TextPatternRangeEndpoint
@ -19,21 +21,68 @@ namespace Avalonia.Win32.Interop.Automation
Document = 6,
}
public enum TextPatternAttribute
{
AnimationStyleAttributeId = 40000,
BackgroundColorAttributeId = 40001,
BulletStyleAttributeId = 40002,
CapStyleAttributeId = 40003,
CultureAttributeId = 40004,
FontNameAttributeId = 40005,
FontSizeAttributeId = 40006,
FontWeightAttributeId = 40007,
ForegroundColorAttributeId = 40008,
HorizontalTextAlignmentAttributeId = 40009,
IndentationFirstLineAttributeId = 40010,
IndentationLeadingAttributeId = 40011,
IndentationTrailingAttributeId = 40012,
IsHiddenAttributeId = 40013,
IsItalicAttributeId = 40014,
IsReadOnlyAttributeId = 40015,
IsSubscriptAttributeId = 40016,
IsSuperscriptAttributeId = 40017,
MarginBottomAttributeId = 40018,
MarginLeadingAttributeId = 40019,
MarginTopAttributeId = 40020,
MarginTrailingAttributeId = 40021,
OutlineStylesAttributeId = 40022,
OverlineColorAttributeId = 40023,
OverlineStyleAttributeId = 40024,
StrikethroughColorAttributeId = 40025,
StrikethroughStyleAttributeId = 40026,
TabsAttributeId = 40027,
TextFlowDirectionsAttributeId = 40028,
UnderlineColorAttributeId = 40029,
UnderlineStyleAttributeId = 40030,
AnnotationTypesAttributeId = 40031,
AnnotationObjectsAttributeId = 40032,
StyleNameAttributeId = 40033,
StyleIdAttributeId = 40034,
LinkAttributeId = 40035,
IsActiveAttributeId = 40036,
SelectionActiveEndAttributeId = 40037,
CaretPositionAttributeId = 40038,
CaretBidiModeAttributeId = 40039,
LineSpacingAttributeId = 40040,
BeforeParagraphSpacingAttributeId = 40041,
AfterParagraphSpacingAttributeId = 40042,
SayAsInterpretAsAttributeId = 40043,
}
[ComVisible(true)]
[Guid("5347ad7b-c355-46f8-aff5-909033582f63")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface ITextRangeProvider
{
ITextRangeProvider Clone();
[return: MarshalAs(UnmanagedType.Bool)]
bool Compare(ITextRangeProvider range);
int CompareEndpoints(TextPatternRangeEndpoint endpoint, ITextRangeProvider targetRange, TextPatternRangeEndpoint targetEndpoint);
void ExpandToEnclosingUnit(TextUnit unit);
ITextRangeProvider FindAttribute(int attribute, object value, [MarshalAs(UnmanagedType.Bool)] bool backward);
ITextRangeProvider FindText(string text, [MarshalAs(UnmanagedType.Bool)] bool backward, [MarshalAs(UnmanagedType.Bool)] bool ignoreCase);
object GetAttributeValue(int attribute);
double [] GetBoundingRectangles();
ITextRangeProvider? FindAttribute(TextPatternAttribute attribute, object? value, [MarshalAs(UnmanagedType.Bool)] bool backward);
ITextRangeProvider? FindText(string text, [MarshalAs(UnmanagedType.Bool)] bool backward, [MarshalAs(UnmanagedType.Bool)] bool ignoreCase);
object? GetAttributeValue(TextPatternAttribute attribute);
double[] GetBoundingRectangles();
IRawElementProviderSimple GetEnclosingElement();
string GetText(int maxLength);
int Move(TextUnit unit, int count);

Loading…
Cancel
Save