From b775804b61fa44f28b92fe58664bfe6420a80551 Mon Sep 17 00:00:00 2001 From: Melissa Date: Mon, 26 Jan 2026 14:48:21 +0100 Subject: [PATCH] Implemented AutomationProperties.LiveSetting (#20473) * Implemented LiveSetting property * Make sure PropertyChanged callback is called on AvnAutomationPeer * Remove `optional` from `AvnAccessibility`: we were never checking whether they respond to the selector or not anyway * Formatting * Use ARIA and Live Region Changed constants from mac APIs * Fixed Mac build * Reverted constants that don't exist on integration test XCode version --------- Co-authored-by: Julien Lebosquain --- .../src/OSX/AvnAccessibility.h | 1 - native/Avalonia.Native/src/OSX/AvnWindow.mm | 4 +++ native/Avalonia.Native/src/OSX/automation.mm | 36 ++++++++++++++++++- .../Pages/AutomationPage.axaml | 3 ++ .../Pages/AutomationPage.axaml.cs | 6 ++++ .../Automation/AutomationProperties.cs | 2 +- .../Automation/Peers/AutomationPeer.cs | 7 ++++ .../Automation/Peers/ControlAutomationPeer.cs | 2 ++ .../Peers/TextBlockAutomationPeer.cs | 10 ++++++ src/Avalonia.Native/AvnAutomationPeer.cs | 16 +++++++++ src/Avalonia.Native/avn.idl | 9 +++++ .../AutomationNode.cs | 15 ++++++++ .../Interop/IRawElementProviderSimple.cs | 7 ++++ 13 files changed, 115 insertions(+), 3 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/AvnAccessibility.h b/native/Avalonia.Native/src/OSX/AvnAccessibility.h index 6658d8523e..4f8b50ecc5 100644 --- a/native/Avalonia.Native/src/OSX/AvnAccessibility.h +++ b/native/Avalonia.Native/src/OSX/AvnAccessibility.h @@ -7,7 +7,6 @@ @protocol AvnAccessibility @required - (void) raiseChildrenChanged; -@optional - (void) raiseFocusChanged; - (void) raisePropertyChanged:(AvnAutomationProperty)property; @end diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 6d49fc85e0..1e7c98cb3a 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -656,5 +656,9 @@ NSAccessibilityPostNotification(focused, NSAccessibilityFocusedUIElementChangedNotification); } +- (void)raisePropertyChanged:(AvnAutomationProperty)property +{ +} + @end diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index f138a9386f..5cd4edaa54 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -11,6 +11,7 @@ IAvnAutomationPeer* _peer; AvnAutomationNode* _node; NSMutableArray* _children; + NSArray* _attributeNames; } + (NSAccessibilityElement *)acquire:(IAvnAutomationPeer *)peer @@ -166,6 +167,32 @@ return NSAccessibilityRoleDescription([self accessibilityRole], [self accessibilitySubrole]); } +// Note: Apple has deprecated this API, but it's still used to set attributes not supported by NSAccessibility +- (NSArray *)accessibilityAttributeNames +{ + if (_attributeNames == nil) + { + _attributeNames = @[ + @"AXARIALive", // kAXARIALiveAttribute + ]; + } + return _attributeNames; +} + +- (id)accessibilityAttributeValue:(NSAccessibilityAttributeName)attribute +{ + if ([attribute isEqualToString:@"AXARIALive" /* kAXARIALiveAttribute */]) + { + switch (_peer->GetLiveSetting()) + { + case LiveSettingPolite: return @"polite"; + case LiveSettingAssertive: return @"assertive"; + } + return nil; + } + return nil; +} + - (NSString *)accessibilityIdentifier { return GetNSStringAndRelease(_peer->GetAutomationId()); @@ -421,8 +448,15 @@ @{ NSAccessibilityUIElementsKey: [changed allObjects]}); } -- (void)raisePropertyChanged +- (void)raisePropertyChanged:(AvnAutomationProperty)property +{ + if (property == AutomationPeer_Name && _peer->GetLiveSetting() != LiveSettingOff) + [self raiseLiveRegionChanged]; +} + +- (void)raiseLiveRegionChanged { + NSAccessibilityPostNotification(self, @"AXLiveRegionChanged" /* kAXLiveRegionChangedNotification */); } - (void)setAccessibilityFocused:(BOOL)accessibilityFocused diff --git a/samples/IntegrationTestApp/Pages/AutomationPage.axaml b/samples/IntegrationTestApp/Pages/AutomationPage.axaml index dcc1ee479c..c02bc1baa6 100644 --- a/samples/IntegrationTestApp/Pages/AutomationPage.axaml +++ b/samples/IntegrationTestApp/Pages/AutomationPage.axaml @@ -22,5 +22,8 @@ Header None + + + This is an assertive live region. diff --git a/samples/IntegrationTestApp/Pages/AutomationPage.axaml.cs b/samples/IntegrationTestApp/Pages/AutomationPage.axaml.cs index f79000126c..7bf37c175f 100644 --- a/samples/IntegrationTestApp/Pages/AutomationPage.axaml.cs +++ b/samples/IntegrationTestApp/Pages/AutomationPage.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Interactivity; namespace IntegrationTestApp.Pages; @@ -8,4 +9,9 @@ public partial class AutomationPage : UserControl { InitializeComponent(); } + + private void OnButtonAddSomeText(object? sender, RoutedEventArgs? e) + { + textLiveRegion.Text += " Lorem ipsum."; + } } diff --git a/src/Avalonia.Controls/Automation/AutomationProperties.cs b/src/Avalonia.Controls/Automation/AutomationProperties.cs index 4e11715c56..e46dcb0eb2 100644 --- a/src/Avalonia.Controls/Automation/AutomationProperties.cs +++ b/src/Avalonia.Controls/Automation/AutomationProperties.cs @@ -211,7 +211,7 @@ namespace Avalonia.Automation /// Defines the AutomationProperties.LiveSetting attached property. /// /// - /// This property currently has no effect. + /// This property affects the default value for and controls whether live region changed events are emitted. /// public static readonly AttachedProperty LiveSettingProperty = AvaloniaProperty.RegisterAttached( diff --git a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs index f71d57c963..56111bdc83 100644 --- a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs @@ -476,6 +476,12 @@ namespace Avalonia.Automation.Peers /// true if a context menu is present for the element; otherwise false. public bool ShowContextMenu() => ShowContextMenuCore(); + /// + /// Gets the current live setting that is associated with this this automation peer. + /// + /// The live setting to use for automation. + public AutomationLiveSetting GetLiveSetting() => GetLiveSettingCore(); + /// /// Tries to get a provider of the specified type from the peer. /// @@ -565,6 +571,7 @@ namespace Avalonia.Automation.Peers protected virtual bool IsOffscreenCore() => false; protected abstract void SetFocusCore(); protected abstract bool ShowContextMenuCore(); + protected virtual AutomationLiveSetting GetLiveSettingCore() => AutomationLiveSetting.Off; protected virtual AutomationControlType GetControlTypeOverrideCore() { diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index ef14be0169..c59ba6b148 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs @@ -185,6 +185,8 @@ namespace Avalonia.Automation.Peers return false; } + protected override AutomationLiveSetting GetLiveSettingCore() => AutomationProperties.GetLiveSetting(Owner); + protected internal override bool TrySetParent(AutomationPeer? parent) { _parent = parent; diff --git a/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs index bc550b6937..e1218384d3 100644 --- a/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs @@ -7,6 +7,16 @@ namespace Avalonia.Automation.Peers public TextBlockAutomationPeer(TextBlock owner) : base(owner) { + Owner.PropertyChanged += (a, e) => + { + if (e.Property == TextBlock.TextProperty) + { + RaisePropertyChangedEvent( + AutomationElementIdentifiers.NameProperty, + e.OldValue, + e.NewValue); + } + }; } public new TextBlock Owner => (TextBlock)base.Owner; diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs index e178febd42..86e0c5453d 100644 --- a/src/Avalonia.Native/AvnAutomationPeer.cs +++ b/src/Avalonia.Native/AvnAutomationPeer.cs @@ -13,6 +13,14 @@ namespace Avalonia.Native { internal class AvnAutomationPeer : NativeCallbackBase, IAvnAutomationPeer { + private static readonly Dictionary s_propertyMap = new() + { + { AutomationElementIdentifiers.BoundingRectangleProperty, AvnAutomationProperty.AutomationPeer_BoundingRectangle }, + { AutomationElementIdentifiers.ClassNameProperty, AvnAutomationProperty.AutomationPeer_ClassName }, + { AutomationElementIdentifiers.NameProperty, AvnAutomationProperty.AutomationPeer_Name }, + { RangeValuePatternIdentifiers.ValueProperty, AvnAutomationProperty.RangeValueProvider_Value }, + }; + private static readonly ConditionalWeakTable s_wrappers = new(); private readonly AutomationPeer _inner; @@ -20,6 +28,7 @@ namespace Avalonia.Native { _inner = inner; _inner.ChildrenChanged += (_, _) => Node?.ChildrenChanged(); + _inner.PropertyChanged += OnPeerPropertyChanged; if (inner is IRootProvider root) root.FocusChanged += (_, _) => Node?.FocusChanged(); } @@ -41,6 +50,7 @@ namespace Avalonia.Native public int HeadingLevel => _inner.GetHeadingLevel(); public IAvnAutomationPeer? Parent => Wrap(_inner.GetParent()); public IAvnAutomationPeer? VisualRoot => Wrap(_inner.GetVisualRoot()); + public AvnLiveSetting LiveSetting => (AvnLiveSetting)_inner.GetLiveSetting(); public int HasKeyboardFocus() => _inner.HasKeyboardFocus().AsComBool(); public int IsContentElement() => _inner.IsContentElement().AsComBool(); @@ -186,6 +196,12 @@ namespace Avalonia.Native } private int IsProvider() => (_inner.GetProvider() is not null).AsComBool(); + + private void OnPeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) + { + if (s_propertyMap.TryGetValue(e.Property, out var property)) + Node?.PropertyChanged(property); + } } internal class AvnAutomationPeerArray : NativeCallbackBase, IAvnAutomationPeerArray diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 639f9bb71d..7aabeceb72 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -689,6 +689,13 @@ enum AvnPointerDeviceType Pen, } +enum AvnLiveSetting +{ + LiveSettingOff, + LiveSettingPolite, + LiveSettingAssertive, +} + [uuid(809c652e-7396-11d2-9771-00a0c9b4d50c)] interface IAvaloniaNativeFactory : IUnknown { @@ -1324,6 +1331,8 @@ interface IAvnAutomationPeer : IUnknown AvnLandmarkType GetLandmarkType(); int GetHeadingLevel(); + + AvnLiveSetting GetLiveSetting(); } [uuid(b00af5da-78af-4b33-bfff-4ce13a6239a9)] diff --git a/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs index a279fd7116..8562807452 100644 --- a/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs +++ b/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs @@ -141,6 +141,7 @@ namespace Avalonia.Win32.Automation UiaPropertyId.LandmarkType => InvokeSync(() => ToUiaLandmarkType(Peer.GetLandmarkType())), UiaPropertyId.LocalizedLandmarkType => InvokeSync(() => ToUiaLocalizedLandmarkType(Peer.GetLandmarkType())), UiaPropertyId.HeadingLevel => InvokeSync(() => ToUiaHeadingLevel(Peer.GetHeadingLevel())), + UiaPropertyId.LiveSetting => InvokeSync(() => ToUiaLiveSetting(Peer.GetLiveSetting())), UiaPropertyId.ProcessId => s_pid, UiaPropertyId.RuntimeId => _runtimeId, _ => null, @@ -273,6 +274,13 @@ namespace Avalonia.Win32.Automation (int)UiaEventId.AutomationFocusChanged); } + protected void RaiseLiveRegionChanged() + { + UiaCoreProviderApi.UiaRaiseAutomationEvent( + this, + (int)UiaEventId.LiveRegionChanged); + } + private RootAutomationNode? GetRoot() { Dispatcher.UIThread.VerifyAccess(); @@ -294,6 +302,11 @@ namespace Avalonia.Win32.Automation e.OldValue as IConvertible, e.NewValue as IConvertible); } + + if (id == UiaPropertyId.Name && Peer.GetLiveSetting() != AutomationLiveSetting.Off) + { + RaiseLiveRegionChanged(); + } } private void OnEmbeddedRootFocusChanged(object? sender, EventArgs e) @@ -407,6 +420,8 @@ namespace Avalonia.Win32.Automation }; } + private static UiaLiveSetting ToUiaLiveSetting(AutomationLiveSetting liveSetting) => (UiaLiveSetting)liveSetting; + private static int GetProcessId() { #if NET6_0_OR_GREATER diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs index bbbb2a29e2..bf70aa1f40 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs @@ -300,6 +300,13 @@ internal enum UiaHeadingLevel Level9 }; +internal enum UiaLiveSetting +{ + Off = 0, + Polite, + Assertive, +}; + #if NET8_0_OR_GREATER [GeneratedComInterface] #else