diff --git a/src/Android/Avalonia.Android/Automation/ExpandCollapseNodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/ExpandCollapseNodeInfoProvider.cs new file mode 100644 index 00000000000..77338c2453c --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/ExpandCollapseNodeInfoProvider.cs @@ -0,0 +1,38 @@ +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.Android.Automation +{ + internal class ExpandCollapseNodeInfoProvider : NodeInfoProvider + { + public ExpandCollapseNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) : + base(owner, peer, virtualViewId) + { + } + + public override bool PerformNodeAction(int action, Bundle? arguments) + { + IExpandCollapseProvider provider = GetProvider(); + switch (action) + { + case AccessibilityNodeInfoCompat.ActionExpand: + provider.Expand(); + return true; + case AccessibilityNodeInfoCompat.ActionCollapse: + provider.Collapse(); + return true; + default: + return false; + } + } + + public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo) + { + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionExpand); + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionCollapse); + } + } +} diff --git a/src/Android/Avalonia.Android/Automation/INodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/INodeInfoProvider.cs new file mode 100644 index 00000000000..63cd9edef44 --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/INodeInfoProvider.cs @@ -0,0 +1,14 @@ +using Android.OS; +using AndroidX.Core.View.Accessibility; + +namespace Avalonia.Android.Automation +{ + public interface INodeInfoProvider + { + int VirtualViewId { get; } + + bool PerformNodeAction(int action, Bundle? arguments); + + void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo); + } +} diff --git a/src/Android/Avalonia.Android/Automation/InvokeNodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/InvokeNodeInfoProvider.cs new file mode 100644 index 00000000000..2d463db9492 --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/InvokeNodeInfoProvider.cs @@ -0,0 +1,35 @@ +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.Android.Automation +{ + internal class InvokeNodeInfoProvider : NodeInfoProvider + { + public InvokeNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) : + base(owner, peer, virtualViewId) + { + } + + public override bool PerformNodeAction(int action, Bundle? arguments) + { + IInvokeProvider provider = GetProvider(); + switch (action) + { + case AccessibilityNodeInfoCompat.ActionClick: + provider.Invoke(); + return true; + default: + return false; + } + } + + public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo) + { + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionClick); + nodeInfo.Clickable = true; + } + } +} diff --git a/src/Android/Avalonia.Android/Automation/NodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/NodeInfoProvider.cs new file mode 100644 index 00000000000..e61b6dfb773 --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/NodeInfoProvider.cs @@ -0,0 +1,48 @@ +using System; +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation; +using Avalonia.Automation.Peers; + +namespace Avalonia.Android.Automation +{ + internal delegate INodeInfoProvider NodeInfoProviderInitializer(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId); + + internal abstract class NodeInfoProvider : INodeInfoProvider + { + private readonly ExploreByTouchHelper _owner; + + private readonly AutomationPeer _peer; + + public int VirtualViewId { get; } + + public NodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) + { + _owner = owner; + _peer = peer; + VirtualViewId = virtualViewId; + + _peer.PropertyChanged += PeerPropertyChanged; + } + + protected void InvalidateSelf() + { + _owner.InvalidateVirtualView(VirtualViewId); + } + + protected void InvalidateSelf(int changeTypes) + { + _owner.InvalidateVirtualView(VirtualViewId, changeTypes); + } + + protected virtual void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) { } + + public T GetProvider() => _peer.GetProvider() ?? + throw new InvalidOperationException($"Peer instance does not implement {nameof(T)}."); + + public abstract bool PerformNodeAction(int action, Bundle? arguments); + + public abstract void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo); + } +} diff --git a/src/Android/Avalonia.Android/Automation/RangeValueNodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/RangeValueNodeInfoProvider.cs new file mode 100644 index 00000000000..3920d063631 --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/RangeValueNodeInfoProvider.cs @@ -0,0 +1,31 @@ +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.Android.Automation +{ + internal class RangeValueNodeInfoProvider : NodeInfoProvider + { + public RangeValueNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) : + base(owner, peer, virtualViewId) + { + } + + public override bool PerformNodeAction(int action, Bundle? arguments) + { + return false; + } + + public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo) + { + IRangeValueProvider provider = GetProvider(); + nodeInfo.RangeInfo = new AccessibilityNodeInfoCompat.RangeInfoCompat( + AccessibilityNodeInfoCompat.RangeInfoCompat.RangeTypeFloat, + (float)provider.Minimum, (float)provider.Maximum, + (float)provider.Value + ); + } + } +} diff --git a/src/Android/Avalonia.Android/Automation/ScrollNodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/ScrollNodeInfoProvider.cs new file mode 100644 index 00000000000..b9e93e53530 --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/ScrollNodeInfoProvider.cs @@ -0,0 +1,53 @@ +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.Android.Automation +{ + internal class ScrollNodeInfoProvider : NodeInfoProvider + { + public ScrollNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) : + base(owner, peer, virtualViewId) + { + } + + public override bool PerformNodeAction(int action, Bundle? arguments) + { + IScrollProvider provider = GetProvider(); + switch (action) + { + case AccessibilityNodeInfoCompat.ActionScrollForward: + if (provider.VerticallyScrollable) + { + provider.Scroll(ScrollAmount.NoAmount, ScrollAmount.SmallIncrement); + } + else if(provider.HorizontallyScrollable) + { + provider.Scroll(ScrollAmount.SmallIncrement, ScrollAmount.NoAmount); + } + return true; + case AccessibilityNodeInfoCompat.ActionScrollBackward: + if (provider.VerticallyScrollable) + { + provider.Scroll(ScrollAmount.NoAmount, ScrollAmount.SmallDecrement); + } + else if (provider.HorizontallyScrollable) + { + provider.Scroll(ScrollAmount.SmallDecrement, ScrollAmount.NoAmount); + } + return true; + default: + return false; + } + } + + public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo) + { + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionScrollForward); + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionScrollBackward); + nodeInfo.Scrollable = true; + } + } +} diff --git a/src/Android/Avalonia.Android/Automation/SelectionItemNodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/SelectionItemNodeInfoProvider.cs new file mode 100644 index 00000000000..f683b7b103b --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/SelectionItemNodeInfoProvider.cs @@ -0,0 +1,37 @@ +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.Android.Automation +{ + internal class SelectionItemNodeInfoProvider : NodeInfoProvider + { + public SelectionItemNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) : + base(owner, peer, virtualViewId) + { + } + + public override bool PerformNodeAction(int action, Bundle? arguments) + { + ISelectionItemProvider provider = GetProvider(); + switch (action) + { + case AccessibilityNodeInfoCompat.ActionSelect: + provider.Select(); + return true; + default: + return false; + } + } + + public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo) + { + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionSelect); + + ISelectionItemProvider provider = GetProvider(); + nodeInfo.Selected = provider.IsSelected; + } + } +} diff --git a/src/Android/Avalonia.Android/Automation/ToggleNodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/ToggleNodeInfoProvider.cs new file mode 100644 index 00000000000..569714c88a6 --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/ToggleNodeInfoProvider.cs @@ -0,0 +1,39 @@ +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.Android.Automation +{ + internal class ToggleNodeInfoProvider : NodeInfoProvider + { + public ToggleNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) : + base(owner, peer, virtualViewId) + { + } + + public override bool PerformNodeAction(int action, Bundle? arguments) + { + IToggleProvider provider = GetProvider(); + switch (action) + { + case AccessibilityNodeInfoCompat.ActionClick: + provider.Toggle(); + return true; + default: + return false; + } + } + + public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo) + { + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionClick); + nodeInfo.Clickable = true; + + IToggleProvider provider = GetProvider(); + nodeInfo.Checked = provider.ToggleState == ToggleState.On; + nodeInfo.Checkable = true; + } + } +} diff --git a/src/Android/Avalonia.Android/Automation/ValueNodeInfoProvider.cs b/src/Android/Avalonia.Android/Automation/ValueNodeInfoProvider.cs new file mode 100644 index 00000000000..54c0332cbf3 --- /dev/null +++ b/src/Android/Avalonia.Android/Automation/ValueNodeInfoProvider.cs @@ -0,0 +1,59 @@ +using Android.OS; +using AndroidX.Core.View; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.Android.Automation +{ + internal class ValueNodeInfoProvider : NodeInfoProvider + { + public ValueNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) : + base(owner, peer, virtualViewId) + { + } + + protected override void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) + { + base.PeerPropertyChanged(sender, e); + if (e.Property == ValuePatternIdentifiers.ValueProperty) + { + InvalidateSelf(AccessibilityEventCompat.ContentChangeTypeText); + } + } + + public override bool PerformNodeAction(int action, Bundle? arguments) + { + IValueProvider provider = GetProvider(); + switch (action) + { + case AccessibilityNodeInfoCompat.ActionSetText: + string? text = arguments?.GetCharSequence( + AccessibilityNodeInfoCompat.ActionArgumentSetTextCharsequence + ); + provider.SetValue(provider.Value + text); + return true; + + default: + return false; + } + } + + public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo) + { + nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionSetText); + + IValueProvider provider = GetProvider(); + nodeInfo.Text = provider.Value; + nodeInfo.Editable = !provider.IsReadOnly; + + nodeInfo.SetTextSelection( + provider.Value?.Length ?? 0, + provider.Value?.Length ?? 0 + ); + nodeInfo.LiveRegion = ViewCompat.AccessibilityLiveRegionPolite; + } + } +} diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs new file mode 100644 index 00000000000..0b5a7e03c41 --- /dev/null +++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Android.Content.PM; +using Android.OS; +using AndroidX.Core.View.Accessibility; +using AndroidX.CustomView.Widget; +using Avalonia.Android.Automation; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Java.Lang; + +namespace Avalonia.Android +{ + internal class AvaloniaAccessHelper : ExploreByTouchHelper + { + private const string AUTOMATION_PROVIDER_NAMESPACE = "Avalonia.Automation.Provider"; + + private static readonly IReadOnlyDictionary + s_providerTypeInitializers = new Dictionary() + { + { typeof(IExpandCollapseProvider).FullName!, (owner, peer, id) => new ExpandCollapseNodeInfoProvider(owner, peer, id) }, + { typeof(IInvokeProvider).FullName!, (owner, peer, id) => new InvokeNodeInfoProvider(owner, peer, id) }, + { typeof(IRangeValueProvider).FullName!, (owner, peer, id) => new RangeValueNodeInfoProvider(owner, peer, id) }, + { typeof(IScrollProvider).FullName!, (owner, peer, id) => new ScrollNodeInfoProvider(owner, peer, id) }, + { typeof(ISelectionItemProvider).FullName!, (owner, peer, id) => new SelectionItemNodeInfoProvider(owner, peer, id) }, + { typeof(IToggleProvider).FullName!, (owner, peer, id) => new ToggleNodeInfoProvider(owner, peer, id) }, + { typeof(IValueProvider).FullName!, (owner, peer, id) => new ValueNodeInfoProvider(owner, peer, id) }, + }; + + private readonly Dictionary _peers; + private readonly Dictionary _peerIds; + + private readonly Dictionary> _peerNodeInfoProviders; + + private readonly AvaloniaView _view; + + public AvaloniaAccessHelper(AvaloniaView view) : base(view) + { + _peers = []; + _peerIds = []; + _peerNodeInfoProviders = []; + + AutomationPeer rootPeer = ControlAutomationPeer.CreatePeerForElement(view.TopLevel!); + GetOrCreateNodeInfoProvidersFromPeer(rootPeer, out int _); + + _view = view; + } + + private HashSet? GetNodeInfoProvidersFromVirtualViewId(int virtualViewId) + { + if (_peers.TryGetValue(virtualViewId, out AutomationPeer? peer) && + _peerNodeInfoProviders.TryGetValue(peer, out HashSet? nodeInfoProviders)) + { + return nodeInfoProviders; + } + else + { + return null; + } + } + + private HashSet GetOrCreateNodeInfoProvidersFromPeer(AutomationPeer peer, out int virtualViewId) + { + int peerViewId; + if (_peerNodeInfoProviders.TryGetValue(peer, out HashSet? nodeInfoProviders)) + { + peerViewId = _peerIds[peer]; + } + else + { + peerViewId = _peerNodeInfoProviders.Count; + _peers.Add(peerViewId, peer); + _peerIds.Add(peer, peerViewId); + + nodeInfoProviders = new(); + _peerNodeInfoProviders.Add(peer, nodeInfoProviders); + + peer.ChildrenChanged += (s, ev) => InvalidateVirtualView(peerViewId, + AccessibilityEventCompat.ContentChangeTypeSubtree); + peer.PropertyChanged += (s, ev) => + { + if (ev.Property == AutomationElementIdentifiers.NameProperty) + { + InvalidateVirtualView(peerViewId, AccessibilityEventCompat.ContentChangeTypeText); + } + else if (ev.Property == AutomationElementIdentifiers.HelpTextProperty) + { + InvalidateVirtualView(peerViewId, AccessibilityEventCompat.ContentChangeTypeContentDescription); + } + else if (ev.Property == AutomationElementIdentifiers.BoundingRectangleProperty || + ev.Property == AutomationElementIdentifiers.ClassNameProperty) + { + InvalidateVirtualView(peerViewId); + } + }; + + Type peerType = peer.GetType(); + IEnumerable providerTypes = peerType.GetInterfaces() + .Where(x => x.Namespace!.StartsWith(AUTOMATION_PROVIDER_NAMESPACE)); + foreach (Type providerType in providerTypes) + { + if (s_providerTypeInitializers.TryGetValue(providerType.FullName!, out NodeInfoProviderInitializer? ctor)) + { + INodeInfoProvider nodeInfoProvider = ctor(this, peer, peerViewId); + nodeInfoProviders.Add(nodeInfoProvider); + } + } + } + + virtualViewId = peerViewId; + return nodeInfoProviders; + } + + protected override int GetVirtualViewAt(float x, float y) + { + Point p = _view.TopLevelImpl.PointToClient(new PixelPoint((int)x, (int)y)); + IEmbeddedRootProvider? embeddedRootProvider = _peers[0].GetProvider(); + AutomationPeer? peer = embeddedRootProvider?.GetPeerFromPoint(p); + if (peer is not null) + { + GetOrCreateNodeInfoProvidersFromPeer(peer, out int virtualViewId); + return virtualViewId == 0 ? InvalidId : virtualViewId; + } + else + { + peer = embeddedRootProvider?.GetFocus(); + return peer is null ? InvalidId : _peerIds[peer]; + } + } + + protected override void GetVisibleVirtualViews(IList? virtualViewIds) + { + if (virtualViewIds is null) + { + return; + } + + foreach (AutomationPeer peer in _peers[0].GetChildren()) + { + GetOrCreateNodeInfoProvidersFromPeer(peer, out int virtualViewId); + virtualViewIds.Add(Integer.ValueOf(virtualViewId)); + } + } + + protected override bool OnPerformActionForVirtualView(int virtualViewId, int action, Bundle? arguments) + { + return (GetNodeInfoProvidersFromVirtualViewId(virtualViewId) ?? []) + .Select(x => x.PerformNodeAction(action, arguments)) + .Aggregate(false, (a, b) => a | b); + } + + protected override void OnPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat nodeInfo) + { + if (!_peers.TryGetValue(virtualViewId, out AutomationPeer? peer)) + { + return; // BAIL!! No work to be done + } + + // UI logical structure + foreach (AutomationPeer child in peer.GetChildren()) + { + GetOrCreateNodeInfoProvidersFromPeer(child, out int childId); + nodeInfo.AddChild(_view, childId); + } + + // UI labels + AutomationPeer? labeledBy = peer.GetLabeledBy(); + if (labeledBy is not null) + { + GetOrCreateNodeInfoProvidersFromPeer(labeledBy, out int labeledById); + nodeInfo.SetLabeledBy(_view, labeledById); + } + + // UI debug metadata + nodeInfo.ClassName = peer.GetClassName(); + nodeInfo.UniqueId = peer.GetAutomationId(); + + // Common control state + nodeInfo.Enabled = peer.IsEnabled(); + + // Control focus state + bool canFocusAtAll = peer.IsContentElement() && !peer.IsOffscreen(); + nodeInfo.ScreenReaderFocusable = canFocusAtAll; + nodeInfo.Focusable = canFocusAtAll && peer.IsKeyboardFocusable(); + + nodeInfo.AccessibilityFocused = peer.HasKeyboardFocus(); + nodeInfo.Focused = peer.HasKeyboardFocus(); + + // On-screen bounds + Rect bounds = peer.GetBoundingRectangle(); + PixelRect screenRect = new PixelRect( + _view.TopLevelImpl.PointToScreen(bounds.TopLeft), + _view.TopLevelImpl.PointToScreen(bounds.BottomRight) + ); + nodeInfo.SetBoundsInParent(new( + screenRect.X, screenRect.Y, + screenRect.Right, screenRect.Bottom + )); + + // UI provider specifics + foreach (INodeInfoProvider nodeInfoProvider in _peerNodeInfoProviders[peer]) + { + nodeInfoProvider.PopulateNodeInfo(nodeInfo); + } + + // Control text contents + nodeInfo.Text ??= peer.GetName(); + nodeInfo.ContentDescription ??= peer.GetHelpText(); + } + } +} diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs index 92a6a818ed7..ced2f110770 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.cs @@ -5,6 +5,8 @@ using Android.Runtime; using Android.Views; using Android.Widget; +using AndroidX.Core.View; +using AndroidX.CustomView.Widget; using Avalonia.Android.Platform; using Avalonia.Android.Platform.SkiaPlatform; using Avalonia.Controls; @@ -19,6 +21,7 @@ public class AvaloniaView : FrameLayout { private EmbeddableControlRoot _root; private readonly ViewImpl _view; + private readonly ExploreByTouchHelper _accessHelper; private IDisposable? _timerSubscription; private bool _surfaceCreated; @@ -26,6 +29,7 @@ public class AvaloniaView : FrameLayout public AvaloniaView(Context context) : base(context) { _view = new ViewImpl(this); + AddView(_view.View); _root = new EmbeddableControlRoot(_view); @@ -35,6 +39,9 @@ public AvaloniaView(Context context) : base(context) OnConfigurationChanged(); _view.InternalView.SurfaceWindowCreated += InternalView_SurfaceWindowCreated; + + _accessHelper = new AvaloniaAccessHelper(this); + ViewCompat.SetAccessibilityDelegate(this, _accessHelper); } private void InternalView_SurfaceWindowCreated(object? sender, EventArgs e) @@ -64,10 +71,21 @@ public object? Content _root = null!; } + protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect) + { + base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect); + _accessHelper.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect); + } + + protected override bool DispatchHoverEvent(MotionEvent? e) + { + return _accessHelper.DispatchHoverEvent(e!) || base.DispatchHoverEvent(e); + } + public override bool DispatchKeyEvent(KeyEvent? e) { if (!_view.View.DispatchKeyEvent(e)) - return base.DispatchKeyEvent(e); + return _accessHelper.DispatchKeyEvent(e!) || base.DispatchKeyEvent(e); return true; } diff --git a/src/Avalonia.Controls/Automation/Peers/EmbeddableControlRootAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/EmbeddableControlRootAutomationPeer.cs new file mode 100644 index 00000000000..cbc45d113e0 --- /dev/null +++ b/src/Avalonia.Controls/Automation/Peers/EmbeddableControlRootAutomationPeer.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Controls.Embedding; +using Avalonia.Input; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Automation.Peers +{ + public class EmbeddableControlRootAutomationPeer : ContentControlAutomationPeer, IEmbeddedRootProvider + { + private Control? _focus; + + public EmbeddableControlRootAutomationPeer(EmbeddableControlRoot owner) : base(owner) + { + if (owner.IsVisible) + StartTrackingFocus(); + else + owner.Opened += OnOpened; + owner.Closed += OnClosed; + } + + public new EmbeddableControlRoot Owner => (EmbeddableControlRoot)base.Owner; + + public event EventHandler? FocusChanged; + + public AutomationPeer? GetFocus() => _focus is object ? GetOrCreate(_focus) : null; + + public AutomationPeer? GetPeerFromPoint(Point p) + { + var hit = Owner.GetVisualAt(p)?.FindAncestorOfType(); + + if (hit is null) + return null; + + var peer = GetOrCreate(hit); + return peer; + } + + private void StartTrackingFocus() + { + if (KeyboardDevice.Instance is not null) + { + KeyboardDevice.Instance.PropertyChanged += KeyboardDevicePropertyChanged; + OnFocusChanged(KeyboardDevice.Instance.FocusedElement); + } + } + + private void StopTrackingFocus() + { + if (KeyboardDevice.Instance is not null) + KeyboardDevice.Instance.PropertyChanged -= KeyboardDevicePropertyChanged; + } + + private void OnFocusChanged(IInputElement? focus) + { + var oldFocus = _focus; + var c = focus as Control; + + _focus = c?.VisualRoot == Owner ? c : null; + + if (_focus != oldFocus) + { + var peer = _focus is object ? + _focus == Owner ? this : + GetOrCreate(_focus) : null; + FocusChanged?.Invoke(this, EventArgs.Empty); + } + } + + private void KeyboardDevicePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(KeyboardDevice.FocusedElement)) + { + OnFocusChanged(KeyboardDevice.Instance!.FocusedElement); + } + } + + private void OnOpened(object? sender, EventArgs e) + { + Owner.Opened -= OnOpened; + StartTrackingFocus(); + } + + private void OnClosed(object? sender, EventArgs e) + { + Owner.Closed -= OnClosed; + StopTrackingFocus(); + } + } +} diff --git a/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs index 9be17afa8c7..dacec48d369 100644 --- a/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs @@ -8,6 +8,7 @@ public class TextBoxAutomationPeer : ControlAutomationPeer, IValueProvider public TextBoxAutomationPeer(TextBox owner) : base(owner) { + Owner.PropertyChanged += OwnerPropertyChanged; } public new TextBox Owner => (TextBox)base.Owner; @@ -19,5 +20,13 @@ protected override AutomationControlType GetAutomationControlTypeCore() { return AutomationControlType.Edit; } + + protected virtual void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if(e.Property == TextBox.TextProperty) + { + RaisePropertyChangedEvent(ValuePatternIdentifiers.ValueProperty, e.OldValue, e.NewValue); + } + } } } diff --git a/src/Avalonia.Controls/Automation/ValuePatternIdentifiers.cs b/src/Avalonia.Controls/Automation/ValuePatternIdentifiers.cs new file mode 100644 index 00000000000..017b0729d28 --- /dev/null +++ b/src/Avalonia.Controls/Automation/ValuePatternIdentifiers.cs @@ -0,0 +1,20 @@ +using Avalonia.Automation.Provider; + +namespace Avalonia.Automation +{ + /// + /// Contains values used as identifiers by . + /// + public static class ValuePatternIdentifiers + { + /// + /// Identifies automation property. + /// + public static AutomationProperty IsReadOnlyProperty { get; } = new AutomationProperty(); + + /// + /// Identifies automation property. + /// + public static AutomationProperty ValueProperty { get; } = new AutomationProperty(); + } +} diff --git a/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs b/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs index 091098f2b01..d4e5488019a 100644 --- a/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs +++ b/src/Avalonia.Controls/Embedding/EmbeddableControlRoot.cs @@ -1,9 +1,11 @@ using System; using System.ComponentModel; +using Avalonia.Automation.Peers; +using Avalonia.Controls.Automation; +using Avalonia.Controls.Automation.Peers; using Avalonia.Controls.Platform; using Avalonia.Input; using Avalonia.Platform; -using Avalonia.Styling; namespace Avalonia.Controls.Embedding { @@ -52,6 +54,11 @@ protected override Size MeasureOverride(Size availableSize) protected override Type StyleKeyOverride => typeof(EmbeddableControlRoot); + protected override AutomationPeer OnCreateAutomationPeer() + { + return new EmbeddableControlRootAutomationPeer(this); + } + public void Dispose() { PlatformImpl?.Dispose();