From 15bce1f0f4a331d4100f353460cc860a28f5e348 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 26 Jan 2023 22:36:08 +0100 Subject: [PATCH] Initial implementation of ITextProvider. With backend implementation in win32 (macOS to come). --- .../Automation/Peers/AutomationPeer.cs | 7 + .../Automation/Peers/ControlAutomationPeer.cs | 1 + .../Peers/TextBlockAutomationPeer.cs | 76 ++- .../Automation/Peers/TextBoxAutomationPeer.cs | 112 ++++- .../Peers/UnrealizedElementAutomationPeer.cs | 1 + .../Automation/Provider/ITextProvider.cs | 139 +++++ .../Automation/Provider/TextRange.cs | 74 +++ src/Avalonia.Controls/TextBox.cs | 3 + .../Automation/AutomationNode.Text.cs | 65 +++ .../Automation/AutomationNode.cs | 68 ++- .../Automation/AutomationTextRange.cs | 474 ++++++++++++++++++ .../Automation/RootAutomationNode.cs | 10 +- .../Interop/Automation/ITextProvider.cs | 4 +- .../Interop/Automation/ITextRangeProvider.cs | 59 ++- 14 files changed, 1061 insertions(+), 32 deletions(-) create mode 100644 src/Avalonia.Controls/Automation/Provider/ITextProvider.cs create mode 100644 src/Avalonia.Controls/Automation/Provider/TextRange.cs create mode 100644 src/Windows/Avalonia.Win32/Automation/AutomationNode.Text.cs create mode 100644 src/Windows/Avalonia.Win32/Automation/AutomationTextRange.cs diff --git a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs index 3d3fe35d29..6421557152 100644 --- a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs @@ -95,6 +95,12 @@ namespace Avalonia.Automation.Peers /// public string GetClassName() => GetClassNameCore() ?? string.Empty; + /// + /// Gets text that describes the functionality of the control that is associated with the + /// automation peer. + /// + public string? GetHelpText() => GetHelpTextCore(); + /// /// Gets the automation peer for the label that is targeted to the element. /// @@ -231,6 +237,7 @@ namespace Avalonia.Automation.Peers protected abstract Rect GetBoundingRectangleCore(); protected abstract IReadOnlyList GetOrCreateChildrenCore(); protected abstract string GetClassNameCore(); + protected abstract string? GetHelpTextCore(); protected abstract AutomationPeer? GetLabeledByCore(); protected abstract string? GetNameCore(); protected abstract AutomationPeer? GetParentCore(); diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index c55bd0f3e5..b7adffdf93 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/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; diff --git a/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs index 8a89e38f62..06b20bf7a2 100644 --- a/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs +++ b/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 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(); + foreach (var rect in source) + result.Add(rect.TransformToAABB(m)); + return result; + } + + return Array.Empty(); + } + + 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 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 ITextProvider.GetSelection() => Array.Empty(); + 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); + } } diff --git a/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs index 9be17afa8c..44daa0643b 100644 --- a/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs +++ b/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 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(); + var result = new List(); + foreach (var rect in source) + result.Add(rect.TransformToAABB(m)); + return result; + } + + return Array.Empty(); + } + + 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 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 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); } } diff --git a/src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs index 56d5aa79ae..1a468ca78d 100644 --- a/src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs +++ b/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 GetOrCreateChildrenCore() => Array.Empty(); protected override bool HasKeyboardFocusCore() => false; protected override bool IsContentElementCore() => false; diff --git a/src/Avalonia.Controls/Automation/Provider/ITextProvider.cs b/src/Avalonia.Controls/Automation/Provider/ITextProvider.cs new file mode 100644 index 0000000000..39b7fa0970 --- /dev/null +++ b/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 +{ + /// + /// Describes the type of text selection supported by an element. + /// + public enum SupportedTextSelection + { + /// + /// The element does not support text selection. + /// + None, + + /// + /// The element supports a single, continuous text selection. + /// + Single, + + /// + /// The element supports multiple, disjoint text selections. + /// + Multiple, + } + + /// + /// Exposes methods and properties to support access by a UI Automation client to controls + /// that holds editable text. + /// + public interface ITextProvider + { + /// + /// Gets the current position of the caret, or -1 if there is no caret. + /// + int CaretIndex { get; } + + /// + /// Gets a text range that encloses the main text of the document. + /// + TextRange DocumentRange { get; } + + /// + /// Gets a value that indicates whether the text in the control is read-only. + /// + bool IsReadOnly { get; } + + /// + /// Gets the total number of lines in the main text of the document. + /// + int LineCount { get; } + + /// + /// Gets the placeholder text. + /// + string? PlaceholderText { get; } + + /// + /// Gets a value that specifies the type of text selection that is supported by the control. + /// + SupportedTextSelection SupportedTextSelection { get; } + + /// + /// Occurs when the control's selection changes. + /// + event EventHandler? SelectedRangesChanged; + + /// + /// Occurs when the control's text changes. + /// + event EventHandler? TextChanged; + + /// + /// Retrieves a collection of bounding rectangles for each fully or partially visible line + /// of text in a text range. + /// + /// The text range. + /// A collection of s in top-level coordinates. + IReadOnlyList GetBounds(TextRange range); + + /// + /// Retrieves the line number for the line that contains the specified character index. + /// + /// The character index. + /// The line number, or -1 if the character index is invalid. + int GetLineForIndex(int index); + + /// + /// Retrieves a text range that encloses the specified line. + /// + /// The index of the line. + TextRange GetLineRange(int lineIndex); + + /// + /// Retrieves a collection of text ranges that represents the currently selected text in a + /// text-based control. + /// + /// + /// 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. + /// + IReadOnlyList GetSelection(); + + /// + /// Retrieves the text for a specified range. + /// + /// The text range. + /// The text. + string GetText(TextRange range); + + /// + /// Retrieves a collection of disjoint text ranges from a text-based control where each + /// text range represents a contiguous span of visible text. + /// + IReadOnlyList GetVisibleRanges(); + + /// + /// Returns the degenerate (empty) text range nearest to the specified coordinates. + /// + /// The point in top-level coordinates. + TextRange RangeFromPoint(Point p); + + /// + /// Scrolls the specified range of text into view. + /// + /// The text range. + void ScrollIntoView(TextRange range); + + /// + /// Selects the specified range of text, replacing any previous selection. + /// + /// + void Select(TextRange range); + } +} diff --git a/src/Avalonia.Controls/Automation/Provider/TextRange.cs b/src/Avalonia.Controls/Automation/Provider/TextRange.cs new file mode 100644 index 0000000000..dd732fb691 --- /dev/null +++ b/src/Avalonia.Controls/Automation/Provider/TextRange.cs @@ -0,0 +1,74 @@ +using System; + +namespace Avalonia.Automation.Provider +{ + /// + /// Represents a range of text returned by an . + /// + public readonly struct TextRange : IEquatable + { + /// + /// Instantiates a new instance with the specified start index and + /// length. + /// + /// The inclusive start index of the range. + /// The length of the range. + /// + /// was negative. + /// + public TextRange(int start, int length) + { + if (length < 0) + throw new ArgumentException("Length may not be negative", nameof(length)); + Start = start; + Length = length; + } + + /// + /// Gets the inclusive start index of the range. + /// + public int Start { get; } + + /// + /// Gets the exclusive end index of the range. + /// + public int End => Start + Length; + + /// + /// Gets the length of the range. + /// + public int Length { get; } + + /// + /// Gets an empty . + /// + public static TextRange Empty => new(0, 0); + + /// + /// Creates a new from an inclusive start and end index. + /// + /// The inclusive start index of the range. + /// The inclusive end index of the range. + /// + 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); + } +} diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 7bc26bf3b0..ff391e038f 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/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("PART_TextPresenter"); diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.Text.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Text.cs new file mode 100644 index 0000000000..17468c3c5b --- /dev/null +++ b/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(x => x.DocumentRange); + return new AutomationTextRange(this, range); + } + } + + public UIA.SupportedTextSelection SupportedTextSelection + { + get => (UIA.SupportedTextSelection)InvokeSync(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(x => x.RangeFromPoint(screenLocation)); + return new AutomationTextRange(this, range); + } + + UIA.ITextRangeProvider[] UIA.ITextProvider.GetSelection() => GetRanges(x => x.GetSelection()); + + private UIA.ITextRangeProvider[] GetRanges(Func> selector) + { + var source = InvokeSync(selector); + return source?.Select(x => new AutomationTextRange(this, x)).ToArray() ?? Array.Empty(); + } + + 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); + } + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs index 3eeedc4b5d..308acd7d22 100644 --- a/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs +++ b/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(), UiaPatternId.SelectionItem => ThisIfPeerImplementsProvider(), + UiaPatternId.Text => ThisIfPeerImplementsProvider(), UiaPatternId.Toggle => ThisIfPeerImplementsProvider(), UiaPatternId.Value => ThisIfPeerImplementsProvider(), _ => 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() 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(Func func) + public T InvokeSync(Func func) { if (Dispatcher.UIThread.CheckAccess()) return func(); @@ -214,7 +227,7 @@ namespace Avalonia.Win32.Automation return Dispatcher.UIThread.InvokeAsync(func).Result; } - protected void InvokeSync(Action action) + public void InvokeSync(Action action) { if (Peer.GetProvider() is TInterface i) { @@ -233,7 +246,7 @@ namespace Avalonia.Win32.Automation } } - protected TResult InvokeSync(Func func) + public TResult InvokeSync(Func func) { if (Peer.GetProvider() 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() 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()?.PlaceholderText ?? Peer.GetHelpText(); + }); } private static AutomationNode Create(AutomationPeer peer) diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationTextRange.cs b/src/Windows/Avalonia.Win32/Automation/AutomationTextRange.cs new file mode 100644 index 0000000000..66dffc3cec --- /dev/null +++ b/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(); + 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'; + } + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs b/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs index ff8ff69d5e..9f0a4bfa61 100644 --- a/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs +++ b/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) diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs index 3f8fbc80c7..9700da3cea 100644 --- a/src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs +++ b/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; } } diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ITextRangeProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ITextRangeProvider.cs index 9ebb4c9f49..7a875a747e 100644 --- a/src/Windows/Avalonia.Win32/Interop/Automation/ITextRangeProvider.cs +++ b/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);