diff --git a/.editorconfig b/.editorconfig
index c7a381b730b..25e0135725a 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -137,7 +137,7 @@ space_within_single_line_array_initializer_braces = true
csharp_wrap_before_ternary_opsigns = false
# Xaml files
-[*.xaml]
+[*.{xaml,axaml}]
indent_size = 2
# Xml project files
diff --git a/.ncrunch/Avalonia.IntegrationTests.Appium.v3.ncrunchproject b/.ncrunch/Avalonia.IntegrationTests.Appium.v3.ncrunchproject
new file mode 100644
index 00000000000..319cd523cec
--- /dev/null
+++ b/.ncrunch/Avalonia.IntegrationTests.Appium.v3.ncrunchproject
@@ -0,0 +1,5 @@
+
+
+ True
+
+
\ No newline at end of file
diff --git a/.ncrunch/Avalonia.IntegrationTests.Win32.v3.ncrunchproject b/.ncrunch/Avalonia.IntegrationTests.Win32.v3.ncrunchproject
new file mode 100644
index 00000000000..319cd523cec
--- /dev/null
+++ b/.ncrunch/Avalonia.IntegrationTests.Win32.v3.ncrunchproject
@@ -0,0 +1,5 @@
+
+
+ True
+
+
\ No newline at end of file
diff --git a/.ncrunch/IntegrationTestApp.v3.ncrunchproject b/.ncrunch/IntegrationTestApp.v3.ncrunchproject
new file mode 100644
index 00000000000..319cd523cec
--- /dev/null
+++ b/.ncrunch/IntegrationTestApp.v3.ncrunchproject
@@ -0,0 +1,5 @@
+
+
+ True
+
+
\ No newline at end of file
diff --git a/Avalonia.sln b/Avalonia.sln
index 104245118a1..891255f3f37 100644
--- a/Avalonia.sln
+++ b/Avalonia.sln
@@ -219,6 +219,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.MicroCom", "src\Av
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniMvvm", "samples\MiniMvvm\MiniMvvm.csproj", "{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestApp", "samples\IntegrationTestApp\IntegrationTestApp.csproj", "{676D6BFD-029D-4E43-BFC7-3892265CE251}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.IntegrationTests.Appium", "tests\Avalonia.IntegrationTests.Appium\Avalonia.IntegrationTests.Appium.csproj", "{F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{86A3F706-DC3C-43C6-BE1B-B98F5BAAA268}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Web.Blazor", "src\Web\Avalonia.Web.Blazor\Avalonia.Web.Blazor.csproj", "{25831348-EB2A-483E-9576-E8F6528674A5}"
@@ -2037,6 +2041,54 @@ Global
{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}.Release|iPhone.Build.0 = Release|Any CPU
{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
{BC594FD5-4AF2-409E-A1E6-04123F54D7C5}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.AppStore|Any CPU.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.AppStore|iPhone.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.AppStore|iPhone.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|iPhone.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.Build.0 = Release|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|iPhone.ActiveCfg = Release|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|iPhone.Build.0 = Release|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+ {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.AppStore|Any CPU.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.AppStore|iPhone.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.AppStore|iPhone.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|iPhone.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|iPhone.ActiveCfg = Release|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|iPhone.Build.0 = Release|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
{25831348-EB2A-483E-9576-E8F6528674A5}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
@@ -2195,6 +2247,7 @@ Global
{29132311-1848-4FD6-AE0C-4FF841151BD3} = {9B9E3891-2366-4253-A952-D08BCEB71098}
{7D2D3083-71DD-4CC9-8907-39A0D86FB322} = {3743B0F2-CC41-4F14-A8C8-267F579BF91E}
{39D7B147-1A5B-47C2-9D01-21FB7C47C4B3} = {9B9E3891-2366-4253-A952-D08BCEB71098}
+ {86C53C40-57AA-45B8-AD42-FAE0EFDF0F2B} = {A689DEF5-D50F-4975-8B72-124C9EB54066}
{854568D5-13D1-4B4F-B50D-534DC7EFD3C9} = {86C53C40-57AA-45B8-AD42-FAE0EFDF0F2B}
{638580B0-7910-40EF-B674-DCB34DA308CD} = {A0CC0258-D18C-4AB3-854F-7101680FC3F9}
{CBC4FF2F-92D4-420B-BE21-9FE0B930B04E} = {B39A8919-9F95-48FE-AD7B-76E08B509888}
@@ -2214,6 +2267,8 @@ Global
{909A8CBD-7D0E-42FD-B841-022AD8925820} = {8B6A8209-894F-4BA1-B880-965FD453982C}
{11BE52AF-E2DD-4CF0-B19A-05285ACAF571} = {9B9E3891-2366-4253-A952-D08BCEB71098}
{BC594FD5-4AF2-409E-A1E6-04123F54D7C5} = {9B9E3891-2366-4253-A952-D08BCEB71098}
+ {676D6BFD-029D-4E43-BFC7-3892265CE251} = {9B9E3891-2366-4253-A952-D08BCEB71098}
+ {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
{25831348-EB2A-483E-9576-E8F6528674A5} = {86A3F706-DC3C-43C6-BE1B-B98F5BAAA268}
{C08E9894-AA92-426E-BF56-033E262CAD3E} = {9B9E3891-2366-4253-A952-D08BCEB71098}
{26A98DA1-D89D-4A95-8152-349F404DA2E2} = {A0CC0258-D18C-4AB3-854F-7101680FC3F9}
diff --git a/build/XUnit.props b/build/XUnit.props
index a75e1bac863..17ead91aa3c 100644
--- a/build/XUnit.props
+++ b/build/XUnit.props
@@ -1,14 +1,14 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj b/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
index 85fcf20034f..7571d51c9f8 100644
--- a/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
+++ b/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
@@ -30,6 +30,8 @@
AB661C1E2148230F00291242 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB661C1D2148230F00291242 /* AppKit.framework */; };
AB661C202148286E00291242 /* window.mm in Sources */ = {isa = PBXBuildFile; fileRef = AB661C1F2148286E00291242 /* window.mm */; };
AB8F7D6B21482D7F0057DBA5 /* platformthreading.mm in Sources */ = {isa = PBXBuildFile; fileRef = AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */; };
+ BC11A5BE2608D58F0017BAD0 /* automation.h in Headers */ = {isa = PBXBuildFile; fileRef = BC11A5BC2608D58F0017BAD0 /* automation.h */; };
+ BC11A5BF2608D58F0017BAD0 /* automation.mm in Sources */ = {isa = PBXBuildFile; fileRef = BC11A5BD2608D58F0017BAD0 /* automation.mm */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -64,6 +66,8 @@
AB661C212148288600291242 /* common.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = common.h; sourceTree = ""; };
AB7A61EF2147C815003C5833 /* libAvalonia.Native.OSX.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libAvalonia.Native.OSX.dylib; sourceTree = BUILT_PRODUCTS_DIR; };
AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = platformthreading.mm; sourceTree = ""; };
+ BC11A5BC2608D58F0017BAD0 /* automation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = automation.h; sourceTree = ""; };
+ BC11A5BD2608D58F0017BAD0 /* automation.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = automation.mm; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -97,6 +101,8 @@
AB7A61E62147C814003C5833 = {
isa = PBXGroup;
children = (
+ BC11A5BC2608D58F0017BAD0 /* automation.h */,
+ BC11A5BD2608D58F0017BAD0 /* automation.mm */,
1A1852DB23E05814008F0DED /* deadlock.mm */,
1A002B9D232135EE00021753 /* app.mm */,
37DDA9B121933371002E132B /* AvnString.h */,
@@ -143,6 +149,7 @@
buildActionMask = 2147483647;
files = (
37155CE4233C00EB0034DCE9 /* menu.h in Headers */,
+ BC11A5BE2608D58F0017BAD0 /* automation.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -213,6 +220,7 @@
AB8F7D6B21482D7F0057DBA5 /* platformthreading.mm in Sources */,
1A3E5EA823E9E83B00EDE661 /* rendertarget.mm in Sources */,
1A3E5EAE23E9FB1300EDE661 /* cgl.mm in Sources */,
+ BC11A5BF2608D58F0017BAD0 /* automation.mm in Sources */,
37E2330F21583241000CB7E2 /* KeyTransform.mm in Sources */,
520624B322973F4100C4DCEF /* menu.mm in Sources */,
37A517B32159597E00FBA241 /* Screens.mm in Sources */,
diff --git a/native/Avalonia.Native/src/OSX/AvnString.h b/native/Avalonia.Native/src/OSX/AvnString.h
index 3ce83d370a7..3b750b11dba 100644
--- a/native/Avalonia.Native/src/OSX/AvnString.h
+++ b/native/Avalonia.Native/src/OSX/AvnString.h
@@ -14,4 +14,5 @@ extern IAvnStringArray* CreateAvnStringArray(NSArray* array);
extern IAvnStringArray* CreateAvnStringArray(NSArray* array);
extern IAvnStringArray* CreateAvnStringArray(NSString* string);
extern IAvnString* CreateByteArray(void* data, int len);
+extern NSString* GetNSStringAndRelease(IAvnString* s);
#endif /* AvnString_h */
diff --git a/native/Avalonia.Native/src/OSX/AvnString.mm b/native/Avalonia.Native/src/OSX/AvnString.mm
index cd0e2cdf941..5e50068c516 100644
--- a/native/Avalonia.Native/src/OSX/AvnString.mm
+++ b/native/Avalonia.Native/src/OSX/AvnString.mm
@@ -153,3 +153,19 @@ virtual HRESULT Get(unsigned int index, IAvnString**ppv) override
{
return new AvnStringImpl(data, len);
}
+
+NSString* GetNSStringAndRelease(IAvnString* s)
+{
+ NSString* result = nil;
+
+ if (s != nullptr)
+ {
+ char* p;
+ if (s->Pointer((void**)&p) == S_OK && p != nullptr)
+ result = [NSString stringWithUTF8String:p];
+
+ s->Release();
+ }
+
+ return result;
+}
diff --git a/native/Avalonia.Native/src/OSX/automation.h b/native/Avalonia.Native/src/OSX/automation.h
new file mode 100644
index 00000000000..4a12a965fd8
--- /dev/null
+++ b/native/Avalonia.Native/src/OSX/automation.h
@@ -0,0 +1,12 @@
+#import
+#include "window.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+class IAvnAutomationPeer;
+
+@interface AvnAccessibilityElement : NSAccessibilityElement
++ (AvnAccessibilityElement *) acquire:(IAvnAutomationPeer *) peer;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm
new file mode 100644
index 00000000000..7d697140c23
--- /dev/null
+++ b/native/Avalonia.Native/src/OSX/automation.mm
@@ -0,0 +1,496 @@
+#include "common.h"
+#include "automation.h"
+#include "AvnString.h"
+#include "window.h"
+
+@interface AvnAccessibilityElement (Events)
+- (void) raiseChildrenChanged;
+@end
+
+@interface AvnRootAccessibilityElement : AvnAccessibilityElement
+- (AvnView *) ownerView;
+- (AvnRootAccessibilityElement *) initWithPeer:(IAvnAutomationPeer *) peer owner:(AvnView*) owner;
+- (void) raiseFocusChanged;
+@end
+
+class AutomationNode : public ComSingleObject
+{
+public:
+ FORWARD_IUNKNOWN()
+
+ AutomationNode(AvnAccessibilityElement* owner)
+ {
+ _owner = owner;
+ }
+
+ AvnAccessibilityElement* GetOwner()
+ {
+ return _owner;
+ }
+
+ virtual void Dispose() override
+ {
+ _owner = nil;
+ }
+
+ virtual void ChildrenChanged () override
+ {
+ [_owner raiseChildrenChanged];
+ }
+
+ virtual void PropertyChanged (AvnAutomationProperty property) override
+ {
+
+ }
+
+ virtual void FocusChanged () override
+ {
+ [(AvnRootAccessibilityElement*)_owner raiseFocusChanged];
+ }
+
+private:
+ __strong AvnAccessibilityElement* _owner;
+};
+
+@implementation AvnAccessibilityElement
+{
+ IAvnAutomationPeer* _peer;
+ AutomationNode* _node;
+ NSMutableArray* _children;
+}
+
++ (AvnAccessibilityElement *)acquire:(IAvnAutomationPeer *)peer
+{
+ if (peer == nullptr)
+ return nil;
+
+ auto instance = peer->GetNode();
+
+ if (instance != nullptr)
+ return dynamic_cast(instance)->GetOwner();
+
+ if (peer->IsRootProvider())
+ {
+ auto window = peer->RootProvider_GetWindow();
+ auto holder = dynamic_cast(window);
+ auto view = holder->GetNSView();
+ return [[AvnRootAccessibilityElement alloc] initWithPeer:peer owner:view];
+ }
+ else
+ {
+ return [[AvnAccessibilityElement alloc] initWithPeer:peer];
+ }
+}
+
+- (AvnAccessibilityElement *)initWithPeer:(IAvnAutomationPeer *)peer
+{
+ self = [super init];
+ _peer = peer;
+ _node = new AutomationNode(self);
+ _peer->SetNode(_node);
+ return self;
+}
+
+- (void)dealloc
+{
+ if (_node)
+ delete _node;
+ _node = nullptr;
+}
+
+- (NSString *)description
+{
+ return [NSString stringWithFormat:@"%@ '%@' (%p)",
+ GetNSStringAndRelease(_peer->GetClassName()),
+ GetNSStringAndRelease(_peer->GetName()),
+ _peer];
+}
+
+- (IAvnAutomationPeer *)peer
+{
+ return _peer;
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return _peer->IsControlElement();
+}
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ auto controlType = _peer->GetAutomationControlType();
+
+ switch (controlType) {
+ case AutomationButton: return NSAccessibilityButtonRole;
+ case AutomationCalendar: return NSAccessibilityGridRole;
+ case AutomationCheckBox: return NSAccessibilityCheckBoxRole;
+ case AutomationComboBox: return NSAccessibilityPopUpButtonRole;
+ case AutomationComboBoxItem: return NSAccessibilityMenuItemRole;
+ case AutomationEdit: return NSAccessibilityTextFieldRole;
+ case AutomationHyperlink: return NSAccessibilityLinkRole;
+ case AutomationImage: return NSAccessibilityImageRole;
+ case AutomationListItem: return NSAccessibilityRowRole;
+ case AutomationList: return NSAccessibilityTableRole;
+ case AutomationMenu: return NSAccessibilityMenuBarRole;
+ case AutomationMenuBar: return NSAccessibilityMenuBarRole;
+ case AutomationMenuItem: return NSAccessibilityMenuItemRole;
+ case AutomationProgressBar: return NSAccessibilityProgressIndicatorRole;
+ case AutomationRadioButton: return NSAccessibilityRadioButtonRole;
+ case AutomationScrollBar: return NSAccessibilityScrollBarRole;
+ case AutomationSlider: return NSAccessibilitySliderRole;
+ case AutomationSpinner: return NSAccessibilityIncrementorRole;
+ case AutomationStatusBar: return NSAccessibilityTableRole;
+ case AutomationTab: return NSAccessibilityTabGroupRole;
+ case AutomationTabItem: return NSAccessibilityRadioButtonRole;
+ case AutomationText: return NSAccessibilityStaticTextRole;
+ case AutomationToolBar: return NSAccessibilityToolbarRole;
+ case AutomationToolTip: return NSAccessibilityPopoverRole;
+ case AutomationTree: return NSAccessibilityOutlineRole;
+ case AutomationTreeItem: return NSAccessibilityCellRole;
+ case AutomationCustom: return NSAccessibilityUnknownRole;
+ case AutomationGroup: return NSAccessibilityGroupRole;
+ case AutomationThumb: return NSAccessibilityHandleRole;
+ case AutomationDataGrid: return NSAccessibilityGridRole;
+ case AutomationDataItem: return NSAccessibilityCellRole;
+ case AutomationDocument: return NSAccessibilityStaticTextRole;
+ case AutomationSplitButton: return NSAccessibilityPopUpButtonRole;
+ case AutomationWindow: return NSAccessibilityWindowRole;
+ case AutomationPane: return NSAccessibilityGroupRole;
+ case AutomationHeader: return NSAccessibilityGroupRole;
+ case AutomationHeaderItem: return NSAccessibilityButtonRole;
+ case AutomationTable: return NSAccessibilityTableRole;
+ case AutomationTitleBar: return NSAccessibilityGroupRole;
+ // Treat unknown roles as generic group container items. Returning
+ // NSAccessibilityUnknownRole is also possible but makes the screen
+ // reader focus on the item instead of passing focus to child items.
+ default: return NSAccessibilityGroupRole;
+ }
+}
+
+- (NSString *)accessibilityIdentifier
+{
+ return GetNSStringAndRelease(_peer->GetAutomationId());
+}
+
+- (NSString *)accessibilityTitle
+{
+ // StaticText exposes its text via the value property.
+ if (_peer->GetAutomationControlType() != AutomationText)
+ {
+ return GetNSStringAndRelease(_peer->GetName());
+ }
+
+ return [super accessibilityTitle];
+}
+
+- (id)accessibilityValue
+{
+ if (_peer->IsRangeValueProvider())
+ {
+ return [NSNumber numberWithDouble:_peer->RangeValueProvider_GetValue()];
+ }
+ else if (_peer->IsToggleProvider())
+ {
+ switch (_peer->ToggleProvider_GetToggleState()) {
+ case 0: return [NSNumber numberWithBool:NO];
+ case 1: return [NSNumber numberWithBool:YES];
+ default: return [NSNumber numberWithInt:2];
+ }
+ }
+ else if (_peer->IsValueProvider())
+ {
+ return GetNSStringAndRelease(_peer->ValueProvider_GetValue());
+ }
+ else if (_peer->GetAutomationControlType() == AutomationText)
+ {
+ return GetNSStringAndRelease(_peer->GetName());
+ }
+
+ return [super accessibilityValue];
+}
+
+- (id)accessibilityMinValue
+{
+ if (_peer->IsRangeValueProvider())
+ {
+ return [NSNumber numberWithDouble:_peer->RangeValueProvider_GetMinimum()];
+ }
+
+ return [super accessibilityMinValue];
+}
+
+- (id)accessibilityMaxValue
+{
+ if (_peer->IsRangeValueProvider())
+ {
+ return [NSNumber numberWithDouble:_peer->RangeValueProvider_GetMaximum()];
+ }
+
+ return [super accessibilityMaxValue];
+}
+
+- (BOOL)isAccessibilityEnabled
+{
+ return _peer->IsEnabled();
+}
+
+- (BOOL)isAccessibilityFocused
+{
+ return _peer->HasKeyboardFocus();
+}
+
+- (NSArray *)accessibilityChildren
+{
+ if (_children == nullptr && _peer != nullptr)
+ [self recalculateChildren];
+ return _children;
+}
+
+- (NSRect)accessibilityFrame
+{
+ id topLevel = [self accessibilityTopLevelUIElement];
+ auto result = NSZeroRect;
+
+ if ([topLevel isKindOfClass:[AvnRootAccessibilityElement class]])
+ {
+ auto root = (AvnRootAccessibilityElement*)topLevel;
+ auto view = [root ownerView];
+
+ if (view)
+ {
+ auto window = [view window];
+ auto bounds = ToNSRect(_peer->GetBoundingRectangle());
+ auto windowBounds = [view convertRect:bounds toView:nil];
+ auto screenBounds = [window convertRectToScreen:windowBounds];
+ result = screenBounds;
+ }
+ }
+
+ return result;
+}
+
+- (id)accessibilityParent
+{
+ auto parentPeer = _peer->GetParent();
+ return parentPeer ? [AvnAccessibilityElement acquire:parentPeer] : [NSApplication sharedApplication];
+}
+
+- (id)accessibilityTopLevelUIElement
+{
+ auto rootPeer = _peer->GetRootPeer();
+ return [AvnAccessibilityElement acquire:rootPeer];
+}
+
+- (id)accessibilityWindow
+{
+ id topLevel = [self accessibilityTopLevelUIElement];
+ return [topLevel isKindOfClass:[NSWindow class]] ? topLevel : nil;
+}
+
+- (BOOL)isAccessibilityExpanded
+{
+ if (!_peer->IsExpandCollapseProvider())
+ return NO;
+ return _peer->ExpandCollapseProvider_GetIsExpanded();
+}
+
+- (void)setAccessibilityExpanded:(BOOL)accessibilityExpanded
+{
+ if (!_peer->IsExpandCollapseProvider())
+ return;
+ if (accessibilityExpanded)
+ _peer->ExpandCollapseProvider_Expand();
+ else
+ _peer->ExpandCollapseProvider_Collapse();
+}
+
+- (BOOL)accessibilityPerformPress
+{
+ if (_peer->IsInvokeProvider())
+ {
+ _peer->InvokeProvider_Invoke();
+ }
+ else if (_peer->IsExpandCollapseProvider())
+ {
+ _peer->ExpandCollapseProvider_Expand();
+ }
+ else if (_peer->IsToggleProvider())
+ {
+ _peer->ToggleProvider_Toggle();
+ }
+ return YES;
+}
+
+- (BOOL)accessibilityPerformIncrement
+{
+ if (!_peer->IsRangeValueProvider())
+ return NO;
+ auto value = _peer->RangeValueProvider_GetValue();
+ value += _peer->RangeValueProvider_GetSmallChange();
+ _peer->RangeValueProvider_SetValue(value);
+ return YES;
+}
+
+- (BOOL)accessibilityPerformDecrement
+{
+ if (!_peer->IsRangeValueProvider())
+ return NO;
+ auto value = _peer->RangeValueProvider_GetValue();
+ value -= _peer->RangeValueProvider_GetSmallChange();
+ _peer->RangeValueProvider_SetValue(value);
+ return YES;
+}
+
+- (BOOL)accessibilityPerformShowMenu
+{
+ if (!_peer->IsExpandCollapseProvider())
+ return NO;
+ _peer->ExpandCollapseProvider_Expand();
+ return YES;
+}
+
+- (BOOL)isAccessibilitySelected
+{
+ if (_peer->IsSelectionItemProvider())
+ return _peer->SelectionItemProvider_IsSelected();
+ return NO;
+}
+
+- (BOOL)isAccessibilitySelectorAllowed:(SEL)selector
+{
+ if (selector == @selector(accessibilityPerformShowMenu))
+ {
+ return _peer->IsExpandCollapseProvider() && _peer->ExpandCollapseProvider_GetShowsMenu();
+ }
+ else if (selector == @selector(isAccessibilityExpanded))
+ {
+ return _peer->IsExpandCollapseProvider();
+ }
+ else if (selector == @selector(accessibilityPerformPress))
+ {
+ return _peer->IsInvokeProvider() || _peer->IsExpandCollapseProvider() || _peer->IsToggleProvider();
+ }
+ else if (selector == @selector(accessibilityPerformIncrement) ||
+ selector == @selector(accessibilityPerformDecrement) ||
+ selector == @selector(accessibilityMinValue) ||
+ selector == @selector(accessibilityMaxValue))
+ {
+ return _peer->IsRangeValueProvider();
+ }
+
+ return [super isAccessibilitySelectorAllowed:selector];
+}
+
+- (void)raiseChildrenChanged
+{
+ auto changed = _children ? [NSMutableSet setWithArray:_children] : [NSMutableSet set];
+
+ [self recalculateChildren];
+
+ if (_children)
+ [changed addObjectsFromArray:_children];
+
+ NSAccessibilityPostNotificationWithUserInfo(
+ self,
+ NSAccessibilityLayoutChangedNotification,
+ @{ NSAccessibilityUIElementsKey: [changed allObjects]});
+}
+
+- (void)raisePropertyChanged
+{
+}
+
+- (void)setAccessibilityFocused:(BOOL)accessibilityFocused
+{
+ if (accessibilityFocused)
+ _peer->SetFocus();
+}
+
+- (void)recalculateChildren
+{
+ auto childPeers = _peer->GetChildren();
+ auto childCount = childPeers != nullptr ? childPeers->GetCount() : 0;
+
+ if (childCount > 0)
+ {
+ _children = [[NSMutableArray alloc] initWithCapacity:childCount];
+
+ for (int i = 0; i < childCount; ++i)
+ {
+ IAvnAutomationPeer* child;
+
+ if (childPeers->Get(i, &child) == S_OK)
+ {
+ auto element = [AvnAccessibilityElement acquire:child];
+ [_children addObject:element];
+ }
+ }
+ }
+ else
+ {
+ _children = nil;
+ }
+}
+
+@end
+
+@implementation AvnRootAccessibilityElement
+{
+ AvnView* _owner;
+}
+
+- (AvnRootAccessibilityElement *)initWithPeer:(IAvnAutomationPeer *)peer owner:(AvnView *)owner
+{
+ self = [super initWithPeer:peer];
+ _owner = owner;
+
+ // Seems we need to raise a focus changed notification here if we have focus
+ auto focusedPeer = [self peer]->RootProvider_GetFocus();
+ id focused = [AvnAccessibilityElement acquire:focusedPeer];
+
+ if (focused)
+ NSAccessibilityPostNotification(focused, NSAccessibilityFocusedUIElementChangedNotification);
+
+ return self;
+}
+
+- (AvnView *)ownerView
+{
+ return _owner;
+}
+
+- (id)accessibilityFocusedUIElement
+{
+ auto focusedPeer = [self peer]->RootProvider_GetFocus();
+ return [AvnAccessibilityElement acquire:focusedPeer];
+}
+
+- (id)accessibilityHitTest:(NSPoint)point
+{
+ auto clientPoint = [[_owner window] convertPointFromScreen:point];
+ auto localPoint = [_owner translateLocalPoint:ToAvnPoint(clientPoint)];
+ auto hit = [self peer]->RootProvider_GetPeerFromPoint(localPoint);
+ return [AvnAccessibilityElement acquire:hit];
+}
+
+- (id)accessibilityParent
+{
+ return _owner;
+}
+
+- (void)raiseFocusChanged
+{
+ id focused = [self accessibilityFocusedUIElement];
+ NSAccessibilityPostNotification(focused, NSAccessibilityFocusedUIElementChangedNotification);
+}
+
+// Although this method is marked as deprecated we get runtime warnings if we don't handle it.
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-implementations"
+- (void)accessibilityPerformAction:(NSAccessibilityActionName)action
+{
+ [_owner accessibilityPerformAction:action];
+}
+#pragma clang diagnostic pop
+
+@end
diff --git a/native/Avalonia.Native/src/OSX/common.h b/native/Avalonia.Native/src/OSX/common.h
index 126c9aa87b4..9186d9e15a4 100644
--- a/native/Avalonia.Native/src/OSX/common.h
+++ b/native/Avalonia.Native/src/OSX/common.h
@@ -35,6 +35,7 @@ extern NSMenuItem* GetAppMenuItem ();
extern void InitializeAvnApp(IAvnApplicationEvents* events);
extern NSApplicationActivationPolicy AvnDesiredActivationPolicy;
extern NSPoint ToNSPoint (AvnPoint p);
+extern NSRect ToNSRect (AvnRect r);
extern AvnPoint ToAvnPoint (NSPoint p);
extern AvnPoint ConvertPointY (AvnPoint p);
extern CGFloat PrimaryDisplayHeight();
diff --git a/native/Avalonia.Native/src/OSX/main.mm b/native/Avalonia.Native/src/OSX/main.mm
index 69f2995847d..ea79c494d7f 100644
--- a/native/Avalonia.Native/src/OSX/main.mm
+++ b/native/Avalonia.Native/src/OSX/main.mm
@@ -1,6 +1,7 @@
//This file will contain actual IID structures
#define COM_GUIDS_MATERIALIZE
#include "common.h"
+#include "window.h"
static NSString* s_appTitle = @"Avalonia";
@@ -335,7 +336,7 @@ virtual HRESULT CreateMenuItemSeparator (IAvnMenuItem** ppv) override
return S_OK;
}
}
-
+
virtual HRESULT SetAppMenu (IAvnMenu* appMenu) override
{
START_COM_CALL;
@@ -400,6 +401,15 @@ NSPoint ToNSPoint (AvnPoint p)
return result;
}
+NSRect ToNSRect (AvnRect r)
+{
+ return NSRect
+ {
+ NSPoint { r.X, r.Y },
+ NSSize { r.Width, r.Height }
+ };
+}
+
AvnPoint ToAvnPoint (NSPoint p)
{
AvnPoint result;
diff --git a/native/Avalonia.Native/src/OSX/window.h b/native/Avalonia.Native/src/OSX/window.h
index 1dc091a48d5..1369ceaea0d 100644
--- a/native/Avalonia.Native/src/OSX/window.h
+++ b/native/Avalonia.Native/src/OSX/window.h
@@ -43,6 +43,7 @@ class WindowBaseImpl;
struct INSWindowHolder
{
virtual AvnWindow* _Nonnull GetNSWindow () = 0;
+ virtual AvnView* _Nonnull GetNSView () = 0;
};
struct IWindowStateChanged
diff --git a/native/Avalonia.Native/src/OSX/window.mm b/native/Avalonia.Native/src/OSX/window.mm
index 40180274e11..1de82d9f392 100644
--- a/native/Avalonia.Native/src/OSX/window.mm
+++ b/native/Avalonia.Native/src/OSX/window.mm
@@ -5,14 +5,22 @@
#include "menu.h"
#include
#include "rendertarget.h"
+#include "AvnString.h"
+#include "automation.h"
-class WindowBaseImpl : public virtual ComSingleObject, public INSWindowHolder
+class WindowBaseImpl : public virtual ComObject,
+ public virtual IAvnWindowBase,
+ public INSWindowHolder
{
private:
NSCursor* cursor;
public:
FORWARD_IUNKNOWN()
+ BEGIN_INTERFACE_MAP()
+ INTERFACE_MAP_ENTRY(IAvnWindowBase, IID_IAvnWindowBase)
+ END_INTERFACE_MAP()
+
virtual ~WindowBaseImpl()
{
View = NULL;
@@ -115,7 +123,12 @@ virtual HRESULT ObtainNSViewHandleRetained(void** ret) override
{
return Window;
}
-
+
+ virtual AvnView* GetNSView() override
+ {
+ return View;
+ }
+
virtual HRESULT Show(bool activate, bool isDialog) override
{
START_COM_CALL;
@@ -1396,6 +1409,7 @@ @implementation AvnView
AvnPixelSize _lastPixelSize;
NSObject* _renderTarget;
AvnPlatformResizeReason _resizeReason;
+ AvnAccessibilityElement* _accessibilityChild;
}
- (void)onClosed
@@ -2050,6 +2064,37 @@ - (void)setResizeReason:(AvnPlatformResizeReason)reason
_resizeReason = reason;
}
+- (AvnAccessibilityElement *) accessibilityChild
+{
+ if (_accessibilityChild == nil)
+ {
+ auto peer = _parent->BaseEvents->GetAutomationPeer();
+
+ if (peer == nil)
+ return nil;
+
+ _accessibilityChild = [AvnAccessibilityElement acquire:peer];
+ }
+
+ return _accessibilityChild;
+}
+
+- (NSArray *)accessibilityChildren
+{
+ auto child = [self accessibilityChild];
+ return NSAccessibilityUnignoredChildrenForOnlyChild(child);
+}
+
+- (id)accessibilityHitTest:(NSPoint)point
+{
+ return [[self accessibilityChild] accessibilityHitTest:point];
+}
+
+- (id)accessibilityFocusedUIElement
+{
+ return [[self accessibilityChild] accessibilityFocusedUIElement];
+}
+
@end
@@ -2062,6 +2107,8 @@ @implementation AvnWindow
bool _isExtended;
AvnMenu* _menu;
double _lastScaling;
+ IAvnAutomationPeer* _automationPeer;
+ NSMutableArray* _automationChildren;
}
-(void) setIsExtended:(bool)value;
@@ -2465,6 +2512,7 @@ - (void)sendEvent:(NSEvent *)event
}
}
}
+
@end
class PopupImpl : public virtual WindowBaseImpl, public IAvnPopup
diff --git a/samples/IntegrationTestApp/App.axaml b/samples/IntegrationTestApp/App.axaml
new file mode 100644
index 00000000000..a833e096dfe
--- /dev/null
+++ b/samples/IntegrationTestApp/App.axaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/samples/IntegrationTestApp/App.axaml.cs b/samples/IntegrationTestApp/App.axaml.cs
new file mode 100644
index 00000000000..022931366d3
--- /dev/null
+++ b/samples/IntegrationTestApp/App.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace IntegrationTestApp
+{
+ public class App : Application
+ {
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ }
+}
diff --git a/samples/IntegrationTestApp/IntegrationTestApp.csproj b/samples/IntegrationTestApp/IntegrationTestApp.csproj
new file mode 100644
index 00000000000..e8338adae66
--- /dev/null
+++ b/samples/IntegrationTestApp/IntegrationTestApp.csproj
@@ -0,0 +1,27 @@
+
+
+ WinExe
+ net6.0
+ enable
+
+
+
+ IntegrationTestApp
+ net.avaloniaui.avalonia.integrationtestapp
+ true
+ 1.0.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/IntegrationTestApp/MainWindow.axaml b/samples/IntegrationTestApp/MainWindow.axaml
new file mode 100644
index 00000000000..fe1487a7f2a
--- /dev/null
+++ b/samples/IntegrationTestApp/MainWindow.axaml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextBlockWithName
+
+ TextBlockWithNameAndAutomationId
+
+ Label for TextBox
+
+ Foo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unchecked
+ Checked
+ ThreeState
+
+
+
+
+
+
+ Item 0
+ Item 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+
+
+
+
+
diff --git a/samples/IntegrationTestApp/MainWindow.axaml.cs b/samples/IntegrationTestApp/MainWindow.axaml.cs
new file mode 100644
index 00000000000..b9e631a3125
--- /dev/null
+++ b/samples/IntegrationTestApp/MainWindow.axaml.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace IntegrationTestApp
+{
+ public class MainWindow : Window
+ {
+ public MainWindow()
+ {
+ InitializeComponent();
+ InitializeViewMenu();
+ this.AttachDevTools();
+ AddHandler(Button.ClickEvent, OnButtonClick);
+ ListBoxItems = Enumerable.Range(0, 100).Select(x => "Item " + x).ToList();
+ DataContext = this;
+ }
+
+ public List ListBoxItems { get; }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ private void InitializeViewMenu()
+ {
+ var mainTabs = this.FindControl("MainTabs");
+ var viewMenu = (NativeMenuItem)NativeMenu.GetMenu(this).Items[1];
+
+ foreach (TabItem tabItem in mainTabs.Items)
+ {
+ var menuItem = new NativeMenuItem
+ {
+ Header = (string)tabItem.Header!,
+ IsChecked = tabItem.IsSelected,
+ ToggleType = NativeMenuItemToggleType.Radio,
+ };
+
+ menuItem.Click += (s, e) => tabItem.IsSelected = true;
+ viewMenu.Menu.Items.Add(menuItem);
+ }
+ }
+
+ private void MenuClicked(object? sender, RoutedEventArgs e)
+ {
+ var clickedMenuItemTextBlock = this.FindControl("ClickedMenuItem");
+ clickedMenuItemTextBlock.Text = ((MenuItem)sender!).Header.ToString();
+ }
+
+ private void OnButtonClick(object? sender, RoutedEventArgs e)
+ {
+ var source = e.Source as Button;
+
+ if (source?.Name == "ComboBoxSelectionClear")
+ this.FindControl("BasicComboBox").SelectedIndex = -1;
+ if (source?.Name == "ComboBoxSelectFirst")
+ this.FindControl("BasicComboBox").SelectedIndex = 0;
+ if (source?.Name == "ListBoxSelectionClear")
+ this.FindControl("BasicListBox").SelectedIndex = -1;
+ }
+ }
+}
diff --git a/samples/IntegrationTestApp/Program.cs b/samples/IntegrationTestApp/Program.cs
new file mode 100644
index 00000000000..c09b249cfae
--- /dev/null
+++ b/samples/IntegrationTestApp/Program.cs
@@ -0,0 +1,22 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+
+namespace IntegrationTestApp
+{
+ class Program
+ {
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .LogToTrace();
+ }
+}
diff --git a/samples/IntegrationTestApp/bundle.sh b/samples/IntegrationTestApp/bundle.sh
new file mode 100755
index 00000000000..505991582e8
--- /dev/null
+++ b/samples/IntegrationTestApp/bundle.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+cd $(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)
+dotnet restore -r osx-arm64
+dotnet msbuild -t:BundleApp -p:RuntimeIdentifier=osx-arm64 -p:_AvaloniaUseExternalMSBuild=false
\ No newline at end of file
diff --git a/samples/IntegrationTestApp/nuget.config b/samples/IntegrationTestApp/nuget.config
new file mode 100644
index 00000000000..6c273ab3d9b
--- /dev/null
+++ b/samples/IntegrationTestApp/nuget.config
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs b/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs
new file mode 100644
index 00000000000..4566cd9db5a
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs
@@ -0,0 +1,28 @@
+using Avalonia.Automation.Peers;
+
+namespace Avalonia.Automation
+{
+ ///
+ /// Contains values used as automation property identifiers by UI Automation providers.
+ ///
+ public static class AutomationElementIdentifiers
+ {
+ ///
+ /// Identifies the bounding rectangle automation property. The bounding rectangle property
+ /// value is returned by the method.
+ ///
+ public static AutomationProperty BoundingRectangleProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies the class name automation property. The class name property value is returned
+ /// by the method.
+ ///
+ public static AutomationProperty ClassNameProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies the name automation property. The class name property value is returned
+ /// by the method.
+ ///
+ public static AutomationProperty NameProperty { get; } = new AutomationProperty();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/AutomationLiveSetting.cs b/src/Avalonia.Controls/Automation/AutomationLiveSetting.cs
new file mode 100644
index 00000000000..55de657b32b
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/AutomationLiveSetting.cs
@@ -0,0 +1,28 @@
+namespace Avalonia.Automation
+{
+ ///
+ /// Describes the notification characteristics of a particular live region
+ ///
+ public enum AutomationLiveSetting
+ {
+ ///
+ /// The element does not send notifications if the content of the live region has changed.
+ ///
+ Off = 0,
+
+ ///
+ /// The element sends non-interruptive notifications if the content of the live region has
+ /// changed. With this setting, UI Automation clients and assistive technologies are expected
+ /// to not interrupt the user to inform of changes to the live region.
+ ///
+ Polite = 1,
+
+ ///
+ /// The element sends interruptive notifications if the content of the live region has changed.
+ /// With this setting, UI Automation clients and assistive technologies are expected to interrupt
+ /// the user to inform of changes to the live region.
+ ///
+ Assertive = 2,
+ }
+}
+
diff --git a/src/Avalonia.Controls/Automation/AutomationProperties.cs b/src/Avalonia.Controls/Automation/AutomationProperties.cs
new file mode 100644
index 00000000000..c20af148b8f
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/AutomationProperties.cs
@@ -0,0 +1,630 @@
+using System;
+using Avalonia.Automation.Peers;
+using Avalonia.Controls;
+
+namespace Avalonia.Automation
+{
+ ///
+ /// Declares how a control should included in different views of the automation tree.
+ ///
+ public enum AccessibilityView
+ {
+ ///
+ /// The control is included in the Raw view of the automation tree.
+ ///
+ Raw,
+
+ ///
+ /// The control is included in the Control view of the automation tree.
+ ///
+ Control,
+
+ ///
+ /// The control is included in the Content view of the automation tree.
+ ///
+ Content,
+ }
+
+ public static class AutomationProperties
+ {
+ internal const int AutomationPositionInSetDefault = -1;
+ internal const int AutomationSizeOfSetDefault = -1;
+
+ ///
+ /// Defines the AutomationProperties.AcceleratorKey attached property.
+ ///
+ public static readonly AttachedProperty AcceleratorKeyProperty =
+ AvaloniaProperty.RegisterAttached(
+ "AcceleratorKey",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.AccessibilityView attached property.
+ ///
+ public static readonly AttachedProperty AccessibilityViewProperty =
+ AvaloniaProperty.RegisterAttached(
+ "AccessibilityView",
+ typeof(AutomationProperties),
+ defaultValue: AccessibilityView.Content);
+
+ ///
+ /// Defines the AutomationProperties.AccessKey attached property
+ ///
+ public static readonly AttachedProperty AccessKeyProperty =
+ AvaloniaProperty.RegisterAttached(
+ "AccessKey",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.AutomationId attached property.
+ ///
+ public static readonly AttachedProperty AutomationIdProperty =
+ AvaloniaProperty.RegisterAttached(
+ "AutomationId",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.ControlTypeOverride attached property.
+ ///
+ public static readonly AttachedProperty ControlTypeOverrideProperty =
+ AvaloniaProperty.RegisterAttached(
+ "ControlTypeOverride",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.HelpText attached property.
+ ///
+ public static readonly AttachedProperty HelpTextProperty =
+ AvaloniaProperty.RegisterAttached(
+ "HelpText",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.IsColumnHeader attached property.
+ ///
+ public static readonly AttachedProperty IsColumnHeaderProperty =
+ AvaloniaProperty.RegisterAttached(
+ "IsColumnHeader",
+ typeof(AutomationProperties),
+ false);
+
+ ///
+ /// Defines the AutomationProperties.IsRequiredForForm attached property.
+ ///
+ public static readonly AttachedProperty IsRequiredForFormProperty =
+ AvaloniaProperty.RegisterAttached(
+ "IsRequiredForForm",
+ typeof(AutomationProperties),
+ false);
+
+ ///
+ /// Defines the AutomationProperties.IsRowHeader attached property.
+ ///
+ public static readonly AttachedProperty IsRowHeaderProperty =
+ AvaloniaProperty.RegisterAttached(
+ "IsRowHeader",
+ typeof(AutomationProperties),
+ false);
+
+ ///
+ /// Defines the AutomationProperties.IsOffscreenBehavior attached property.
+ ///
+ public static readonly AttachedProperty IsOffscreenBehaviorProperty =
+ AvaloniaProperty.RegisterAttached(
+ "IsOffscreenBehavior",
+ typeof(AutomationProperties),
+ IsOffscreenBehavior.Default);
+
+ ///
+ /// Defines the AutomationProperties.ItemStatus attached property.
+ ///
+ public static readonly AttachedProperty ItemStatusProperty =
+ AvaloniaProperty.RegisterAttached(
+ "ItemStatus",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.ItemType attached property.
+ ///
+ public static readonly AttachedProperty ItemTypeProperty =
+ AvaloniaProperty.RegisterAttached(
+ "ItemType",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.LabeledBy attached property.
+ ///
+ public static readonly AttachedProperty LabeledByProperty =
+ AvaloniaProperty.RegisterAttached(
+ "LabeledBy",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.LiveSetting attached property.
+ ///
+ public static readonly AttachedProperty LiveSettingProperty =
+ AvaloniaProperty.RegisterAttached(
+ "LiveSetting",
+ typeof(AutomationProperties),
+ AutomationLiveSetting.Off);
+
+ ///
+ /// Defines the AutomationProperties.Name attached attached property.
+ ///
+ public static readonly AttachedProperty NameProperty =
+ AvaloniaProperty.RegisterAttached(
+ "Name",
+ typeof(AutomationProperties));
+
+ ///
+ /// Defines the AutomationProperties.PositionInSet attached property.
+ ///
+ ///
+ /// The PositionInSet property describes the ordinal location of the element within a set
+ /// of elements which are considered to be siblings. PositionInSet works in coordination
+ /// with the SizeOfSet property to describe the ordinal location in the set.
+ ///
+ public static readonly AttachedProperty PositionInSetProperty =
+ AvaloniaProperty.RegisterAttached(
+ "PositionInSet",
+ typeof(AutomationProperties),
+ AutomationPositionInSetDefault);
+
+ ///
+ /// Defines the AutomationProperties.SizeOfSet attached property.
+ ///
+ ///
+ /// The SizeOfSet property describes the count of automation elements in a group or set
+ /// that are considered to be siblings. SizeOfSet works in coordination with the PositionInSet
+ /// property to describe the count of items in the set.
+ ///
+ public static readonly AttachedProperty SizeOfSetProperty =
+ AvaloniaProperty.RegisterAttached(
+ "SizeOfSet",
+ typeof(AutomationProperties),
+ AutomationSizeOfSetDefault);
+
+ ///
+ /// Helper for setting AcceleratorKey property on a StyledElement.
+ ///
+ public static void SetAcceleratorKey(StyledElement element, string value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(AcceleratorKeyProperty, value);
+ }
+
+ ///
+ /// Helper for reading AcceleratorKey property from a StyledElement.
+ ///
+ public static string GetAcceleratorKey(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((string)element.GetValue(AcceleratorKeyProperty));
+ }
+
+ ///
+ /// Helper for setting AccessibilityView property on a StyledElement.
+ ///
+ public static void SetAccessibilityView(StyledElement element, AccessibilityView value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(AccessibilityViewProperty, value);
+ }
+
+ ///
+ /// Helper for reading AccessibilityView property from a StyledElement.
+ ///
+ public static AccessibilityView GetAccessibilityView(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return element.GetValue(AccessibilityViewProperty);
+ }
+
+ ///
+ /// Helper for setting AccessKey property on a StyledElement.
+ ///
+ public static void SetAccessKey(StyledElement element, string value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(AccessKeyProperty, value);
+ }
+
+ ///
+ /// Helper for reading AccessKey property from a StyledElement.
+ ///
+ public static string GetAccessKey(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((string)element.GetValue(AccessKeyProperty));
+ }
+
+ ///
+ /// Helper for setting AutomationId property on a StyledElement.
+ ///
+ public static void SetAutomationId(StyledElement element, string value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(AutomationIdProperty, value);
+ }
+
+ ///
+ /// Helper for reading AutomationId property from a StyledElement.
+ ///
+ public static string GetAutomationId(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return element.GetValue(AutomationIdProperty);
+ }
+
+ ///
+ /// Helper for setting ControlTypeOverride property on a StyledElement.
+ ///
+ public static void SetControlTypeOverride(StyledElement element, AutomationControlType? value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(ControlTypeOverrideProperty, value);
+ }
+
+ ///
+ /// Helper for reading ControlTypeOverride property from a StyledElement.
+ ///
+ public static AutomationControlType? GetControlTypeOverride(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return element.GetValue(ControlTypeOverrideProperty);
+ }
+
+ ///
+ /// Helper for setting HelpText property on a StyledElement.
+ ///
+ public static void SetHelpText(StyledElement element, string value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(HelpTextProperty, value);
+ }
+
+ ///
+ /// Helper for reading HelpText property from a StyledElement.
+ ///
+ public static string GetHelpText(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((string)element.GetValue(HelpTextProperty));
+ }
+
+ ///
+ /// Helper for setting IsColumnHeader property on a StyledElement.
+ ///
+ public static void SetIsColumnHeader(StyledElement element, bool value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(IsColumnHeaderProperty, value);
+ }
+
+ ///
+ /// Helper for reading IsColumnHeader property from a StyledElement.
+ ///
+ public static bool GetIsColumnHeader(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((bool)element.GetValue(IsColumnHeaderProperty));
+ }
+
+ ///
+ /// Helper for setting IsRequiredForForm property on a StyledElement.
+ ///
+ public static void SetIsRequiredForForm(StyledElement element, bool value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(IsRequiredForFormProperty, value);
+ }
+
+ ///
+ /// Helper for reading IsRequiredForForm property from a StyledElement.
+ ///
+ public static bool GetIsRequiredForForm(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((bool)element.GetValue(IsRequiredForFormProperty));
+ }
+
+ ///
+ /// Helper for reading IsRowHeader property from a StyledElement.
+ ///
+ public static bool GetIsRowHeader(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((bool)element.GetValue(IsRowHeaderProperty));
+ }
+
+ ///
+ /// Helper for setting IsRowHeader property on a StyledElement.
+ ///
+ public static void SetIsRowHeader(StyledElement element, bool value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(IsRowHeaderProperty, value);
+ }
+
+ ///
+ /// Helper for setting IsOffscreenBehavior property on a StyledElement.
+ ///
+ public static void SetIsOffscreenBehavior(StyledElement element, IsOffscreenBehavior value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(IsOffscreenBehaviorProperty, value);
+ }
+
+ ///
+ /// Helper for reading IsOffscreenBehavior property from a StyledElement.
+ ///
+ public static IsOffscreenBehavior GetIsOffscreenBehavior(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((IsOffscreenBehavior)element.GetValue(IsOffscreenBehaviorProperty));
+ }
+
+ ///
+ /// Helper for setting ItemStatus property on a StyledElement.
+ ///
+ public static void SetItemStatus(StyledElement element, string value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(ItemStatusProperty, value);
+ }
+
+ ///
+ /// Helper for reading ItemStatus property from a StyledElement.
+ ///
+ public static string GetItemStatus(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((string)element.GetValue(ItemStatusProperty));
+ }
+
+ ///
+ /// Helper for setting ItemType property on a StyledElement.
+ ///
+ public static void SetItemType(StyledElement element, string value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(ItemTypeProperty, value);
+ }
+
+ ///
+ /// Helper for reading ItemType property from a StyledElement.
+ ///
+ public static string GetItemType(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((string)element.GetValue(ItemTypeProperty));
+ }
+
+ ///
+ /// Helper for setting LabeledBy property on a StyledElement.
+ ///
+ public static void SetLabeledBy(StyledElement element, IControl value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(LabeledByProperty, value);
+ }
+
+ ///
+ /// Helper for reading LabeledBy property from a StyledElement.
+ ///
+ public static IControl GetLabeledBy(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return element.GetValue(LabeledByProperty);
+ }
+
+ ///
+ /// Helper for setting LiveSetting property on a StyledElement.
+ ///
+ public static void SetLiveSetting(StyledElement element, AutomationLiveSetting value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(LiveSettingProperty, value);
+ }
+
+ ///
+ /// Helper for reading LiveSetting property from a StyledElement.
+ ///
+ public static AutomationLiveSetting GetLiveSetting(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((AutomationLiveSetting)element.GetValue(LiveSettingProperty));
+ }
+
+ ///
+ /// Helper for setting Name property on a StyledElement.
+ ///
+ public static void SetName(StyledElement element, string value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(NameProperty, value);
+ }
+
+ ///
+ /// Helper for reading Name property from a StyledElement.
+ ///
+ public static string GetName(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((string)element.GetValue(NameProperty));
+ }
+
+ ///
+ /// Helper for setting PositionInSet property on a StyledElement.
+ ///
+ public static void SetPositionInSet(StyledElement element, int value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(PositionInSetProperty, value);
+ }
+
+ ///
+ /// Helper for reading PositionInSet property from a StyledElement.
+ ///
+ public static int GetPositionInSet(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((int)element.GetValue(PositionInSetProperty));
+ }
+
+ ///
+ /// Helper for setting SizeOfSet property on a StyledElement.
+ ///
+ public static void SetSizeOfSet(StyledElement element, int value)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ element.SetValue(SizeOfSetProperty, value);
+ }
+
+ ///
+ /// Helper for reading SizeOfSet property from a StyledElement.
+ ///
+ public static int GetSizeOfSet(StyledElement element)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ return ((int)element.GetValue(SizeOfSetProperty));
+ }
+ }
+}
+
diff --git a/src/Avalonia.Controls/Automation/AutomationProperty.cs b/src/Avalonia.Controls/Automation/AutomationProperty.cs
new file mode 100644
index 00000000000..16968b271df
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/AutomationProperty.cs
@@ -0,0 +1,11 @@
+namespace Avalonia.Automation
+{
+ ///
+ /// Identifies a property of or of a specific
+ /// control pattern.
+ ///
+ public sealed class AutomationProperty
+ {
+ internal AutomationProperty() { }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/AutomationPropertyChangedEventArgs.cs b/src/Avalonia.Controls/Automation/AutomationPropertyChangedEventArgs.cs
new file mode 100644
index 00000000000..3b7eb70fcb5
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/AutomationPropertyChangedEventArgs.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace Avalonia.Automation
+{
+ public class AutomationPropertyChangedEventArgs : EventArgs
+ {
+ public AutomationPropertyChangedEventArgs(
+ AutomationProperty property,
+ object? oldValue,
+ object? newValue)
+ {
+ Property = property;
+ OldValue = oldValue;
+ NewValue = newValue;
+ }
+
+ public AutomationProperty Property { get; }
+ public object? OldValue { get; }
+ public object? NewValue { get; }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/ElementNotEnabledException.cs b/src/Avalonia.Controls/Automation/ElementNotEnabledException.cs
new file mode 100644
index 00000000000..ac73d50603e
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/ElementNotEnabledException.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace Avalonia.Automation
+{
+ public class ElementNotEnabledException : Exception
+ {
+ public ElementNotEnabledException() : base("Element not enabled.") { }
+ public ElementNotEnabledException(string message) : base(message) { }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/ExpandCollapsePatternIdentifiers.cs b/src/Avalonia.Controls/Automation/ExpandCollapsePatternIdentifiers.cs
new file mode 100644
index 00000000000..e2b67821620
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/ExpandCollapsePatternIdentifiers.cs
@@ -0,0 +1,15 @@
+using Avalonia.Automation.Provider;
+
+namespace Avalonia.Automation
+{
+ ///
+ /// Contains values used as identifiers by .
+ ///
+ public static class ExpandCollapsePatternIdentifiers
+ {
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty ExpandCollapseStateProperty { get; } = new AutomationProperty();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/ExpandCollapseState.cs b/src/Avalonia.Controls/Automation/ExpandCollapseState.cs
new file mode 100644
index 00000000000..c6b4feeb505
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/ExpandCollapseState.cs
@@ -0,0 +1,29 @@
+namespace Avalonia.Automation
+{
+ ///
+ /// Contains values that specify the of a UI Automation element.
+ ///
+ public enum ExpandCollapseState
+ {
+ ///
+ /// No child nodes, controls, or content of the UI Automation element are displayed.
+ ///
+ Collapsed,
+
+ ///
+ /// All child nodes, controls or content of the UI Automation element are displayed.
+ ///
+ Expanded,
+
+ ///
+ /// The UI Automation element has no child nodes, controls, or content to display.
+ ///
+ LeafNode,
+
+ ///
+ /// Some, but not all, child nodes, controls, or content of the UI Automation element are
+ /// displayed.
+ ///
+ PartiallyExpanded
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs b/src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs
new file mode 100644
index 00000000000..128c1e1dcc6
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/IsOffscreenBehavior.cs
@@ -0,0 +1,26 @@
+namespace Avalonia.Automation
+{
+ ///
+ /// This enum offers different ways of evaluating the IsOffscreen AutomationProperty
+ ///
+ public enum IsOffscreenBehavior
+ {
+ ///
+ /// The AutomationProperty IsOffscreen is calculated based on IsVisible.
+ ///
+ Default,
+ ///
+ /// The AutomationProperty IsOffscreen is false.
+ ///
+ Onscreen,
+ ///
+ /// The AutomationProperty IsOffscreen if true.
+ ///
+ Offscreen,
+ ///
+ /// The AutomationProperty IsOffscreen is calculated based on clip regions.
+ ///
+ FromClip,
+ }
+}
+
diff --git a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs
new file mode 100644
index 00000000000..71421ac1362
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Automation.Peers
+{
+ public enum AutomationControlType
+ {
+ None,
+ Button,
+ Calendar,
+ CheckBox,
+ ComboBox,
+ ComboBoxItem,
+ Edit,
+ Hyperlink,
+ Image,
+ ListItem,
+ List,
+ Menu,
+ MenuBar,
+ MenuItem,
+ ProgressBar,
+ RadioButton,
+ ScrollBar,
+ Slider,
+ Spinner,
+ StatusBar,
+ Tab,
+ TabItem,
+ Text,
+ ToolBar,
+ ToolTip,
+ Tree,
+ TreeItem,
+ Custom,
+ Group,
+ Thumb,
+ DataGrid,
+ DataItem,
+ Document,
+ SplitButton,
+ Window,
+ Pane,
+ Header,
+ HeaderItem,
+ Table,
+ TitleBar,
+ Separator,
+ }
+
+ ///
+ /// Provides a base class that exposes an element to UI Automation.
+ ///
+ public abstract class AutomationPeer
+ {
+ ///
+ /// Attempts to bring the element associated with the automation peer into view.
+ ///
+ public void BringIntoView() => BringIntoViewCore();
+
+ ///
+ /// Gets the accelerator key combinations for the element that is associated with the UI
+ /// Automation peer.
+ ///
+ public string? GetAcceleratorKey() => GetAcceleratorKeyCore();
+
+ ///
+ /// Gets the access key for the element that is associated with the automation peer.
+ ///
+ public string? GetAccessKey() => GetAccessKeyCore();
+
+ ///
+ /// Gets the control type for the element that is associated with the UI Automation peer.
+ ///
+ public AutomationControlType GetAutomationControlType() => GetControlTypeOverrideCore();
+
+ ///
+ /// Gets the automation ID of the element that is associated with the UI Automation peer.
+ ///
+ public string? GetAutomationId() => GetAutomationIdCore();
+
+ ///
+ /// Gets the bounding rectangle of the element that is associated with the automation peer
+ /// in top-level coordinates.
+ ///
+ public Rect GetBoundingRectangle() => GetBoundingRectangleCore();
+
+ ///
+ /// Gets the child automation peers.
+ ///
+ public IReadOnlyList GetChildren() => GetOrCreateChildrenCore();
+
+ ///
+ /// Gets a string that describes the class of the element.
+ ///
+ public string GetClassName() => GetClassNameCore() ?? string.Empty;
+
+ ///
+ /// Gets the automation peer for the label that is targeted to the element.
+ ///
+ ///
+ public AutomationPeer? GetLabeledBy() => GetLabeledByCore();
+
+ ///
+ /// Gets a human-readable localized string that represents the type of the control that is
+ /// associated with this automation peer.
+ ///
+ public string GetLocalizedControlType() => GetLocalizedControlTypeCore();
+
+ ///
+ /// Gets text that describes the element that is associated with this automation peer.
+ ///
+ public string GetName() => GetNameCore() ?? string.Empty;
+
+ ///
+ /// Gets the that is the parent of this .
+ ///
+ ///
+ public AutomationPeer? GetParent() => GetParentCore();
+
+ ///
+ /// Gets a value that indicates whether the element that is associated with this automation
+ /// peer currently has keyboard focus.
+ ///
+ public bool HasKeyboardFocus() => HasKeyboardFocusCore();
+
+ ///
+ /// Gets a value that indicates whether the element that is associated with this automation
+ /// peer contains data that is presented to the user.
+ ///
+ public bool IsContentElement() => IsControlElement() && IsContentElementCore();
+
+ ///
+ /// Gets a value that indicates whether the element is understood by the user as
+ /// interactive or as contributing to the logical structure of the control in the GUI.
+ ///
+ public bool IsControlElement() => IsControlElementCore();
+
+ ///
+ /// Gets a value indicating whether the control is enabled for user interaction.
+ ///
+ public bool IsEnabled() => IsEnabledCore();
+
+ ///
+ /// Gets a value that indicates whether the element can accept keyboard focus.
+ ///
+ ///
+ public bool IsKeyboardFocusable() => IsKeyboardFocusableCore();
+
+ ///
+ /// Sets the keyboard focus on the element that is associated with this automation peer.
+ ///
+ public void SetFocus() => SetFocusCore();
+
+ ///
+ /// Shows the context menu for the element that is associated with this automation peer.
+ ///
+ /// true if a context menu is present for the element; otherwise false.
+ public bool ShowContextMenu() => ShowContextMenuCore();
+
+ ///
+ /// Tries to get a provider of the specified type from the peer.
+ ///
+ /// The provider type.
+ /// The provider, or null if not implemented on this peer.
+ public T? GetProvider() => (T?)GetProviderCore(typeof(T));
+
+ ///
+ /// Occurs when the children of the automation peer have changed.
+ ///
+ public event EventHandler? ChildrenChanged;
+
+ ///
+ /// Occurs when a property value of the automation peer has changed.
+ ///
+ public event EventHandler? PropertyChanged;
+
+ ///
+ /// Raises an event to notify the automation client the the children of the peer have changed.
+ ///
+ protected void RaiseChildrenChangedEvent() => ChildrenChanged?.Invoke(this, EventArgs.Empty);
+
+ ///
+ /// Raises an event to notify the automation client of a changed property value.
+ ///
+ /// The property that changed.
+ /// The previous value of the property.
+ /// The new value of the property.
+ public void RaisePropertyChangedEvent(
+ AutomationProperty property,
+ object? oldValue,
+ object? newValue)
+ {
+ PropertyChanged?.Invoke(this, new AutomationPropertyChangedEventArgs(property, oldValue, newValue));
+ }
+
+ protected virtual string GetLocalizedControlTypeCore()
+ {
+ var controlType = GetAutomationControlType();
+
+ return controlType switch
+ {
+ AutomationControlType.CheckBox => "check box",
+ AutomationControlType.ComboBox => "combo box",
+ AutomationControlType.ListItem => "list item",
+ AutomationControlType.MenuBar => "menu bar",
+ AutomationControlType.MenuItem => "menu item",
+ AutomationControlType.ProgressBar => "progress bar",
+ AutomationControlType.RadioButton => "radio button",
+ AutomationControlType.ScrollBar => "scroll bar",
+ AutomationControlType.StatusBar => "status bar",
+ AutomationControlType.TabItem => "tab item",
+ AutomationControlType.ToolBar => "toolbar",
+ AutomationControlType.ToolTip => "tooltip",
+ AutomationControlType.TreeItem => "tree item",
+ AutomationControlType.Custom => "custom",
+ AutomationControlType.DataGrid => "data grid",
+ AutomationControlType.DataItem => "data item",
+ AutomationControlType.SplitButton => "split button",
+ AutomationControlType.HeaderItem => "header item",
+ AutomationControlType.TitleBar => "title bar",
+ _ => controlType.ToString().ToLowerInvariant(),
+ };
+ }
+
+ protected abstract void BringIntoViewCore();
+ protected abstract string? GetAcceleratorKeyCore();
+ protected abstract string? GetAccessKeyCore();
+ protected abstract AutomationControlType GetAutomationControlTypeCore();
+ protected abstract string? GetAutomationIdCore();
+ protected abstract Rect GetBoundingRectangleCore();
+ protected abstract IReadOnlyList GetOrCreateChildrenCore();
+ protected abstract string GetClassNameCore();
+ protected abstract AutomationPeer? GetLabeledByCore();
+ protected abstract string? GetNameCore();
+ protected abstract AutomationPeer? GetParentCore();
+ protected abstract bool HasKeyboardFocusCore();
+ protected abstract bool IsContentElementCore();
+ protected abstract bool IsControlElementCore();
+ protected abstract bool IsEnabledCore();
+ protected abstract bool IsKeyboardFocusableCore();
+ protected abstract void SetFocusCore();
+ protected abstract bool ShowContextMenuCore();
+
+ protected virtual AutomationControlType GetControlTypeOverrideCore()
+ {
+ return GetAutomationControlTypeCore();
+ }
+
+ protected virtual object? GetProviderCore(Type providerType)
+ {
+ return providerType.IsAssignableFrom(this.GetType()) ? this : null;
+ }
+
+ protected internal abstract bool TrySetParent(AutomationPeer? parent);
+
+ protected void EnsureEnabled()
+ {
+ if (!IsEnabled())
+ throw new ElementNotEnabledException();
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/ButtonAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ButtonAutomationPeer.cs
new file mode 100644
index 00000000000..4ac07717da7
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ButtonAutomationPeer.cs
@@ -0,0 +1,43 @@
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ public class ButtonAutomationPeer : ContentControlAutomationPeer,
+ IInvokeProvider
+ {
+ public ButtonAutomationPeer(Button owner)
+ : base(owner)
+ {
+ }
+
+ public new Button Owner => (Button)base.Owner;
+
+ public void Invoke()
+ {
+ EnsureEnabled();
+ (Owner as Button)?.PerformClick();
+ }
+
+ protected override string? GetAcceleratorKeyCore()
+ {
+ var result = base.GetAcceleratorKeyCore();
+
+ if (string.IsNullOrWhiteSpace(result))
+ {
+ result = Owner.HotKey?.ToString();
+ }
+
+ return result;
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Button;
+ }
+
+ protected override bool IsContentElementCore() => true;
+ protected override bool IsControlElementCore() => true;
+ }
+}
+
diff --git a/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs
new file mode 100644
index 00000000000..5ff291d9725
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ public class ComboBoxAutomationPeer : SelectingItemsControlAutomationPeer,
+ IExpandCollapseProvider,
+ IValueProvider
+ {
+ private UnrealizedSelectionPeer[]? _selection;
+
+ public ComboBoxAutomationPeer(ComboBox owner)
+ : base(owner)
+ {
+ }
+
+ public new ComboBox Owner => (ComboBox)base.Owner;
+
+ public ExpandCollapseState ExpandCollapseState => ToState(Owner.IsDropDownOpen);
+ public bool ShowsMenu => true;
+ public void Collapse() => Owner.IsDropDownOpen = false;
+ public void Expand() => Owner.IsDropDownOpen = true;
+ bool IValueProvider.IsReadOnly => true;
+
+ string? IValueProvider.Value
+ {
+ get
+ {
+ var selection = GetSelection();
+ return selection.Count == 1 ? selection[0].GetName() : null;
+ }
+ }
+
+ void IValueProvider.SetValue(string? value) => throw new NotSupportedException();
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.ComboBox;
+ }
+
+ protected override IReadOnlyList? GetSelectionCore()
+ {
+ if (ExpandCollapseState == ExpandCollapseState.Expanded)
+ return base.GetSelectionCore();
+
+ // If the combo box is not open then we won't have an ItemsPresenter so the default
+ // GetSelectionCore implementation won't work. For this case we create a separate
+ // peer to represent the unrealized item.
+ if (Owner.SelectedItem is object selection)
+ {
+ _selection ??= new[] { new UnrealizedSelectionPeer(this) };
+ _selection[0].Item = selection;
+ return _selection;
+ }
+
+ return null;
+ }
+
+ protected override void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ base.OwnerPropertyChanged(sender, e);
+
+ if (e.Property == ComboBox.IsDropDownOpenProperty)
+ {
+ RaisePropertyChangedEvent(
+ ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty,
+ ToState((bool)e.OldValue!),
+ ToState((bool)e.NewValue!));
+ }
+ }
+
+ private ExpandCollapseState ToState(bool value)
+ {
+ return value ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed;
+ }
+
+ private class UnrealizedSelectionPeer : UnrealizedElementAutomationPeer
+ {
+ private readonly ComboBoxAutomationPeer _owner;
+ private object? _item;
+
+ public UnrealizedSelectionPeer(ComboBoxAutomationPeer owner)
+ {
+ _owner = owner;
+ }
+
+ public object? Item
+ {
+ get => _item;
+ set
+ {
+ if (_item != value)
+ {
+ var oldValue = GetNameCore();
+ _item = value;
+ RaisePropertyChangedEvent(
+ AutomationElementIdentifiers.NameProperty,
+ oldValue,
+ GetNameCore());
+ }
+ }
+ }
+
+ protected override string? GetAcceleratorKeyCore() => null;
+ protected override string? GetAccessKeyCore() => null;
+ protected override string? GetAutomationIdCore() => null;
+ protected override string GetClassNameCore() => typeof(ComboBoxItem).Name;
+ protected override AutomationPeer? GetLabeledByCore() => null;
+ protected override AutomationPeer? GetParentCore() => _owner;
+ protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.ListItem;
+
+ protected override string? GetNameCore()
+ {
+ if (_item is Control c)
+ {
+ var result = AutomationProperties.GetName(c);
+
+ if (result is null && c is ContentControl cc && cc.Presenter?.Child is TextBlock text)
+ {
+ result = text.Text;
+ }
+
+ if (result is null)
+ {
+ result = c.GetValue(ContentControl.ContentProperty)?.ToString();
+ }
+
+ return result;
+ }
+
+ return _item?.ToString();
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs
new file mode 100644
index 00000000000..df24222a0c5
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ContentControlAutomationPeer.cs
@@ -0,0 +1,36 @@
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ public class ContentControlAutomationPeer : ControlAutomationPeer
+ {
+ protected ContentControlAutomationPeer(ContentControl owner)
+ : base(owner)
+ {
+ }
+
+ public new ContentControl Owner => (ContentControl)base.Owner;
+
+ protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Pane;
+
+ protected override string? GetNameCore()
+ {
+ var result = base.GetNameCore();
+
+ if (result is null && Owner.Presenter?.Child is TextBlock text)
+ {
+ result = text.Text;
+ }
+
+ if (result is null)
+ {
+ result = Owner.Content?.ToString();
+ }
+
+ return result;
+ }
+
+ protected override bool IsContentElementCore() => false;
+ protected override bool IsControlElementCore() => false;
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs
new file mode 100644
index 00000000000..28cb3e34b25
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs
@@ -0,0 +1,221 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.VisualTree;
+
+namespace Avalonia.Automation.Peers
+{
+ ///
+ /// An automation peer which represents a element.
+ ///
+ public class ControlAutomationPeer : AutomationPeer
+ {
+ private IReadOnlyList? _children;
+ private bool _childrenValid;
+ private AutomationPeer? _parent;
+ private bool _parentValid;
+
+ public ControlAutomationPeer(Control owner)
+ {
+ Owner = owner ?? throw new ArgumentNullException("owner");
+ Initialize();
+ }
+
+ public Control Owner { get; }
+
+ public AutomationPeer GetOrCreate(Control element)
+ {
+ if (element == Owner)
+ return this;
+ return CreatePeerForElement(element);
+ }
+
+ public static AutomationPeer CreatePeerForElement(Control element)
+ {
+ return element.GetOrCreateAutomationPeer();
+ }
+
+ protected override void BringIntoViewCore() => Owner.BringIntoView();
+
+ protected override IReadOnlyList GetOrCreateChildrenCore()
+ {
+ var children = _children ?? Array.Empty();
+
+ if (_childrenValid)
+ return children;
+
+ var newChildren = GetChildrenCore() ?? Array.Empty();
+
+ foreach (var peer in children.Except(newChildren))
+ peer.TrySetParent(null);
+ foreach (var peer in newChildren)
+ peer.TrySetParent(this);
+
+ _childrenValid = true;
+ return _children = newChildren;
+ }
+
+ protected virtual IReadOnlyList? GetChildrenCore()
+ {
+ var children = ((IVisual)Owner).VisualChildren;
+
+ if (children.Count == 0)
+ return null;
+
+ var result = new List();
+
+ foreach (var child in children)
+ {
+ if (child is Control c && c.IsVisible)
+ {
+ result.Add(GetOrCreate(c));
+ }
+ }
+
+ return result;
+ }
+
+ protected override AutomationPeer? GetLabeledByCore()
+ {
+ var label = AutomationProperties.GetLabeledBy(Owner);
+ return label is Control c ? GetOrCreate(c) : null;
+ }
+
+ protected override string? GetNameCore()
+ {
+ var result = AutomationProperties.GetName(Owner);
+
+ if (string.IsNullOrWhiteSpace(result) && GetLabeledBy() is AutomationPeer labeledBy)
+ {
+ return labeledBy.GetName();
+ }
+
+ return null;
+ }
+
+ protected override AutomationPeer? GetParentCore()
+ {
+ EnsureConnected();
+ return _parent;
+ }
+
+ ///
+ /// Invalidates the peer's children and causes a re-read from .
+ ///
+ protected void InvalidateChildren()
+ {
+ _childrenValid = false;
+ RaiseChildrenChangedEvent();
+ }
+
+ ///
+ /// Invalidates the peer's parent.
+ ///
+ protected void InvalidateParent()
+ {
+ _parent = null;
+ _parentValid = false;
+ }
+
+ protected override bool ShowContextMenuCore()
+ {
+ var c = Owner;
+
+ while (c is object)
+ {
+ if (c.ContextMenu is object)
+ {
+ c.ContextMenu.Open(c);
+ return true;
+ }
+
+ c = c.Parent as Control;
+ }
+
+ return false;
+ }
+
+ protected internal override bool TrySetParent(AutomationPeer? parent)
+ {
+ _parent = parent;
+ return true;
+ }
+
+ protected override string? GetAcceleratorKeyCore() => AutomationProperties.GetAcceleratorKey(Owner);
+ protected override string? GetAccessKeyCore() => AutomationProperties.GetAccessKey(Owner);
+ protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Custom;
+ protected override string? GetAutomationIdCore() => AutomationProperties.GetAutomationId(Owner) ?? Owner.Name;
+ protected override Rect GetBoundingRectangleCore() => GetBounds(Owner.TransformedBounds);
+ protected override string GetClassNameCore() => Owner.GetType().Name;
+ protected override bool HasKeyboardFocusCore() => Owner.IsFocused;
+ protected override bool IsContentElementCore() => AutomationProperties.GetAccessibilityView(Owner) >= AccessibilityView.Content;
+ protected override bool IsControlElementCore() => AutomationProperties.GetAccessibilityView(Owner) >= AccessibilityView.Control;
+ protected override bool IsEnabledCore() => Owner.IsEnabled;
+ protected override bool IsKeyboardFocusableCore() => Owner.Focusable;
+ protected override void SetFocusCore() => Owner.Focus();
+
+ protected override AutomationControlType GetControlTypeOverrideCore()
+ {
+ return AutomationProperties.GetControlTypeOverride(Owner) ?? GetAutomationControlTypeCore();
+ }
+
+ private static Rect GetBounds(TransformedBounds? bounds)
+ {
+ return bounds?.Bounds.TransformToAABB(bounds!.Value.Transform) ?? default;
+ }
+
+ private void Initialize()
+ {
+ Owner.PropertyChanged += OwnerPropertyChanged;
+ var visualChildren = ((IVisual)Owner).VisualChildren;
+ visualChildren.CollectionChanged += VisualChildrenChanged;
+ }
+
+ private void VisualChildrenChanged(object? sender, EventArgs e) => InvalidateChildren();
+
+ private void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == Visual.IsVisibleProperty)
+ {
+ var parent = Owner.GetVisualParent();
+ if (parent is Control c)
+ (GetOrCreate(c) as ControlAutomationPeer)?.InvalidateChildren();
+ }
+ else if (e.Property == Visual.TransformedBoundsProperty)
+ {
+ RaisePropertyChangedEvent(
+ AutomationElementIdentifiers.BoundingRectangleProperty,
+ GetBounds((TransformedBounds?)e.OldValue),
+ GetBounds((TransformedBounds?)e.NewValue));
+ }
+ else if (e.Property == Visual.VisualParentProperty)
+ {
+ InvalidateParent();
+ }
+ }
+
+
+ private void EnsureConnected()
+ {
+ if (!_parentValid)
+ {
+ var parent = Owner.GetVisualParent();
+
+ while (parent is object)
+ {
+ if (parent is Control c)
+ {
+ var parentPeer = GetOrCreate(c);
+ parentPeer.GetChildren();
+ }
+
+ parent = parent.GetVisualParent();
+ }
+
+ _parentValid = true;
+ }
+ }
+ }
+}
+
diff --git a/src/Avalonia.Controls/Automation/Peers/ItemsControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ItemsControlAutomationPeer.cs
new file mode 100644
index 00000000000..db16bf0a538
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ItemsControlAutomationPeer.cs
@@ -0,0 +1,54 @@
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ public class ItemsControlAutomationPeer : ControlAutomationPeer, IScrollProvider
+ {
+ private bool _searchedForScrollable;
+ private IScrollProvider? _scroller;
+
+ public ItemsControlAutomationPeer(ItemsControl owner)
+ : base(owner)
+ {
+ }
+
+ public new ItemsControl Owner => (ItemsControl)base.Owner;
+ public bool HorizontallyScrollable => _scroller?.HorizontallyScrollable ?? false;
+ public double HorizontalScrollPercent => _scroller?.HorizontalScrollPercent ?? -1;
+ public double HorizontalViewSize => _scroller?.HorizontalViewSize ?? 0;
+ public bool VerticallyScrollable => _scroller?.VerticallyScrollable ?? false;
+ public double VerticalScrollPercent => _scroller?.VerticalScrollPercent ?? -1;
+ public double VerticalViewSize => _scroller?.VerticalViewSize ?? 0;
+
+ protected virtual IScrollProvider? Scroller
+ {
+ get
+ {
+ if (!_searchedForScrollable)
+ {
+ if (Owner.GetValue(ListBox.ScrollProperty) is Control scrollable)
+ _scroller = GetOrCreate(scrollable) as IScrollProvider;
+ _searchedForScrollable = true;
+ }
+
+ return _scroller;
+ }
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.List;
+ }
+
+ public void Scroll(ScrollAmount horizontalAmount, ScrollAmount verticalAmount)
+ {
+ _scroller?.Scroll(horizontalAmount, verticalAmount);
+ }
+
+ public void SetScrollPercent(double horizontalPercent, double verticalPercent)
+ {
+ _scroller?.SetScrollPercent(horizontalPercent, verticalPercent);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs
new file mode 100644
index 00000000000..ac23873e6aa
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs
@@ -0,0 +1,82 @@
+using System;
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Selection;
+
+namespace Avalonia.Automation.Peers
+{
+ public class ListItemAutomationPeer : ContentControlAutomationPeer,
+ ISelectionItemProvider
+ {
+ public ListItemAutomationPeer(ContentControl owner)
+ : base(owner)
+ {
+ }
+
+ public bool IsSelected => Owner.GetValue(ListBoxItem.IsSelectedProperty);
+
+ public ISelectionProvider? SelectionContainer
+ {
+ get
+ {
+ if (Owner.Parent is Control parent)
+ {
+ var parentPeer = GetOrCreate(parent);
+ return parentPeer as ISelectionProvider;
+ }
+
+ return null;
+ }
+ }
+
+ public void Select()
+ {
+ EnsureEnabled();
+
+ if (Owner.Parent is SelectingItemsControl parent)
+ {
+ var index = parent.ItemContainerGenerator.IndexFromContainer(Owner);
+
+ if (index != -1)
+ parent.SelectedIndex = index;
+ }
+ }
+
+ void ISelectionItemProvider.AddToSelection()
+ {
+ EnsureEnabled();
+
+ if (Owner.Parent is ItemsControl parent &&
+ parent.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel)
+ {
+ var index = parent.ItemContainerGenerator.IndexFromContainer(Owner);
+
+ if (index != -1)
+ selectionModel.Select(index);
+ }
+ }
+
+ void ISelectionItemProvider.RemoveFromSelection()
+ {
+ EnsureEnabled();
+
+ if (Owner.Parent is ItemsControl parent &&
+ parent.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel)
+ {
+ var index = parent.ItemContainerGenerator.IndexFromContainer(Owner);
+
+ if (index != -1)
+ selectionModel.Deselect(index);
+ }
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.ListItem;
+ }
+
+ protected override bool IsContentElementCore() => true;
+ protected override bool IsControlElementCore() => true;
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs
new file mode 100644
index 00000000000..c98c5c9a22f
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs
@@ -0,0 +1,59 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+
+namespace Avalonia.Automation.Peers
+{
+ public class MenuItemAutomationPeer : ControlAutomationPeer
+ {
+ public MenuItemAutomationPeer(MenuItem owner)
+ : base(owner)
+ {
+ }
+
+ public new MenuItem Owner => (MenuItem)base.Owner;
+
+ protected override string? GetAccessKeyCore()
+ {
+ var result = base.GetAccessKeyCore();
+
+ if (string.IsNullOrWhiteSpace(result))
+ {
+ if (Owner.HeaderPresenter?.Child is AccessText accessText)
+ {
+ result = accessText.AccessKey.ToString();
+ }
+ }
+
+ return result;
+ }
+
+ protected override string? GetAcceleratorKeyCore()
+ {
+ var result = base.GetAcceleratorKeyCore();
+
+ if (string.IsNullOrWhiteSpace(result))
+ {
+ result = Owner.InputGesture?.ToString();
+ }
+
+ return result;
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.MenuItem;
+ }
+
+ protected override string? GetNameCore()
+ {
+ var result = base.GetNameCore();
+
+ if (result is null && Owner.Header is string header)
+ {
+ result = AccessText.RemoveAccessKeyMarker(header);
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/NoneAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/NoneAutomationPeer.cs
new file mode 100644
index 00000000000..0f92fed6f3a
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/NoneAutomationPeer.cs
@@ -0,0 +1,25 @@
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ ///
+ /// An automation peer which represents an element that is exposed to automation as non-
+ /// interactive or as not contributing to the logical structure of the application.
+ ///
+ public class NoneAutomationPeer : ControlAutomationPeer
+ {
+ public NoneAutomationPeer(Control owner)
+ : base(owner)
+ {
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.None;
+ }
+
+ protected override bool IsContentElementCore() => false;
+ protected override bool IsControlElementCore() => false;
+ }
+}
+
diff --git a/src/Avalonia.Controls/Automation/Peers/PopupAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/PopupAutomationPeer.cs
new file mode 100644
index 00000000000..25f6ca6e2d4
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/PopupAutomationPeer.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Controls.Diagnostics;
+using Avalonia.Controls.Primitives;
+
+namespace Avalonia.Automation.Peers
+{
+ public class PopupAutomationPeer : ControlAutomationPeer
+ {
+ public PopupAutomationPeer(Popup owner)
+ : base(owner)
+ {
+ owner.Opened += PopupOpenedClosed;
+ owner.Closed += PopupOpenedClosed;
+ }
+
+ protected override IReadOnlyList? GetChildrenCore()
+ {
+ var host = (IPopupHostProvider)Owner;
+ return host.PopupHost is Control c ? new[] { GetOrCreate(c) } : null;
+ }
+
+ protected override bool IsContentElementCore() => false;
+ protected override bool IsControlElementCore() => false;
+
+ private void PopupOpenedClosed(object? sender, EventArgs e)
+ {
+ // This is golden. We're following WPF's automation peer API here where the
+ // parent of a peer is set when another peer returns it as a child. We want to
+ // add the popup root as a child of the popup, so we need to return it as a
+ // child right? Yeah except invalidating children doesn't automatically cause
+ // UIA to re-read the children meaning that the parent doesn't get set. So the
+ // MAIN MECHANISM FOR PARENTING CONTROLS IS BROKEN WITH THE ONLY AUTOMATION API
+ // IT WAS WRITTEN FOR. Luckily WPF provides an escape-hatch by exposing the
+ // TrySetParent API internally to work around this. We're exposing it publicly
+ // to shame whoever came up with this abomination of an API.
+ GetPopupRoot()?.TrySetParent(this);
+ InvalidateChildren();
+ }
+
+ private AutomationPeer? GetPopupRoot()
+ {
+ var popupRoot = ((IPopupHostProvider)Owner).PopupHost as Control;
+ return popupRoot is object ? GetOrCreate(popupRoot) : null;
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/PopupRootAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/PopupRootAutomationPeer.cs
new file mode 100644
index 00000000000..cb65682c068
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/PopupRootAutomationPeer.cs
@@ -0,0 +1,40 @@
+using System;
+using Avalonia.Controls.Primitives;
+
+namespace Avalonia.Automation.Peers
+{
+ public class PopupRootAutomationPeer : WindowBaseAutomationPeer
+ {
+ public PopupRootAutomationPeer(PopupRoot owner)
+ : base(owner)
+ {
+ if (owner.IsVisible)
+ StartTrackingFocus();
+ else
+ owner.Opened += OnOpened;
+ owner.Closed += OnClosed;
+ }
+
+ protected override bool IsContentElementCore() => false;
+ protected override bool IsControlElementCore() => false;
+
+
+ protected override AutomationPeer? GetParentCore()
+ {
+ var parent = base.GetParentCore();
+ return parent;
+ }
+
+ private void OnOpened(object? sender, EventArgs e)
+ {
+ ((PopupRoot)Owner).Opened -= OnOpened;
+ StartTrackingFocus();
+ }
+
+ private void OnClosed(object? sender, EventArgs e)
+ {
+ ((PopupRoot)Owner).Closed -= OnClosed;
+ StopTrackingFocus();
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/RangeBaseAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/RangeBaseAutomationPeer.cs
new file mode 100644
index 00000000000..39398933fa4
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/RangeBaseAutomationPeer.cs
@@ -0,0 +1,34 @@
+using Avalonia.Automation.Provider;
+using Avalonia.Controls.Primitives;
+
+namespace Avalonia.Automation.Peers
+{
+ public abstract class RangeBaseAutomationPeer : ControlAutomationPeer, IRangeValueProvider
+ {
+ public RangeBaseAutomationPeer(RangeBase owner)
+ : base(owner)
+ {
+ owner.PropertyChanged += OwnerPropertyChanged;
+ }
+
+ public new RangeBase Owner => (RangeBase)base.Owner;
+ public virtual bool IsReadOnly => false;
+ public double Maximum => Owner.Maximum;
+ public double Minimum => Owner.Minimum;
+ public double Value => Owner.Value;
+ public double SmallChange => Owner.SmallChange;
+ public double LargeChange => Owner.LargeChange;
+
+ public void SetValue(double value) => Owner.Value = value;
+
+ protected virtual void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == RangeBase.MinimumProperty)
+ RaisePropertyChangedEvent(RangeValuePatternIdentifiers.MinimumProperty, e.OldValue, e.NewValue);
+ else if (e.Property == RangeBase.MaximumProperty)
+ RaisePropertyChangedEvent(RangeValuePatternIdentifiers.MaximumProperty, e.OldValue, e.NewValue);
+ else if (e.Property == RangeBase.ValueProperty)
+ RaisePropertyChangedEvent(RangeValuePatternIdentifiers.ValueProperty, e.OldValue, e.NewValue);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/ScrollViewerAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ScrollViewerAutomationPeer.cs
new file mode 100644
index 00000000000..835ed1c4af3
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ScrollViewerAutomationPeer.cs
@@ -0,0 +1,172 @@
+using System;
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+using Avalonia.Utilities;
+
+namespace Avalonia.Automation.Peers
+{
+ public class ScrollViewerAutomationPeer : ControlAutomationPeer, IScrollProvider
+ {
+ public ScrollViewerAutomationPeer(ScrollViewer owner)
+ : base(owner)
+ {
+ }
+
+ public new ScrollViewer Owner => (ScrollViewer)base.Owner;
+
+ public bool HorizontallyScrollable
+ {
+ get => MathUtilities.GreaterThan(Owner.Extent.Width, Owner.Viewport.Width);
+ }
+
+ public double HorizontalScrollPercent
+ {
+ get
+ {
+ if (!HorizontallyScrollable)
+ return ScrollPatternIdentifiers.NoScroll;
+ return (double)(Owner.Offset.X * 100.0 / (Owner.Extent.Width - Owner.Viewport.Width));
+ }
+ }
+
+ public double HorizontalViewSize
+ {
+ get
+ {
+ if (MathUtilities.IsZero(Owner.Extent.Width))
+ return 100;
+ return Math.Min(100, Owner.Viewport.Width * 100.0 / Owner.Extent.Width);
+ }
+ }
+
+ public bool VerticallyScrollable
+ {
+ get => MathUtilities.GreaterThan(Owner.Extent.Height, Owner.Viewport.Height);
+ }
+
+ public double VerticalScrollPercent
+ {
+ get
+ {
+ if (!VerticallyScrollable)
+ return ScrollPatternIdentifiers.NoScroll;
+ return (double)(Owner.Offset.Y * 100.0 / (Owner.Extent.Height - Owner.Viewport.Height));
+ }
+ }
+
+ public double VerticalViewSize
+ {
+ get
+ {
+ if (MathUtilities.IsZero(Owner.Extent.Height))
+ return 100;
+ return Math.Min(100, Owner.Viewport.Height * 100.0 / Owner.Extent.Height);
+ }
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Pane;
+ }
+
+ protected override bool IsContentElementCore() => false;
+
+ protected override bool IsControlElementCore()
+ {
+ // Return false if the control is part of a control template.
+ return Owner.TemplatedParent is null && base.IsControlElementCore();
+ }
+
+ public void Scroll(ScrollAmount horizontalAmount, ScrollAmount verticalAmount)
+ {
+ if (!IsEnabled())
+ throw new ElementNotEnabledException();
+
+ var scrollHorizontally = horizontalAmount != ScrollAmount.NoAmount;
+ var scrollVertically = verticalAmount != ScrollAmount.NoAmount;
+
+ if (scrollHorizontally && !HorizontallyScrollable || scrollVertically && !VerticallyScrollable)
+ {
+ throw new InvalidOperationException("Operation cannot be performed");
+ }
+
+ switch (horizontalAmount)
+ {
+ case ScrollAmount.LargeDecrement:
+ Owner.PageLeft();
+ break;
+ case ScrollAmount.SmallDecrement:
+ Owner.LineLeft();
+ break;
+ case ScrollAmount.SmallIncrement:
+ Owner.LineRight();
+ break;
+ case ScrollAmount.LargeIncrement:
+ Owner.PageRight();
+ break;
+ case ScrollAmount.NoAmount:
+ break;
+ default:
+ throw new InvalidOperationException("Operation cannot be performed");
+ }
+
+ switch (verticalAmount)
+ {
+ case ScrollAmount.LargeDecrement:
+ Owner.PageUp();
+ break;
+ case ScrollAmount.SmallDecrement:
+ Owner.LineUp();
+ break;
+ case ScrollAmount.SmallIncrement:
+ Owner.LineDown();
+ break;
+ case ScrollAmount.LargeIncrement:
+ Owner.PageDown();
+ break;
+ case ScrollAmount.NoAmount:
+ break;
+ default:
+ throw new InvalidOperationException("Operation cannot be performed");
+ }
+ }
+
+ public void SetScrollPercent(double horizontalPercent, double verticalPercent)
+ {
+ if (!IsEnabled())
+ throw new ElementNotEnabledException();
+
+ var scrollHorizontally = horizontalPercent != ScrollPatternIdentifiers.NoScroll;
+ var scrollVertically = verticalPercent != ScrollPatternIdentifiers.NoScroll;
+
+ if (scrollHorizontally && !HorizontallyScrollable || scrollVertically && !VerticallyScrollable)
+ {
+ throw new InvalidOperationException("Operation cannot be performed");
+ }
+
+ if (scrollHorizontally && (horizontalPercent < 0.0) || (horizontalPercent > 100.0))
+ {
+ throw new ArgumentOutOfRangeException("horizontalPercent");
+ }
+
+ if (scrollVertically && (verticalPercent < 0.0) || (verticalPercent > 100.0))
+ {
+ throw new ArgumentOutOfRangeException("verticalPercent");
+ }
+
+ var offset = Owner.Offset;
+
+ if (scrollHorizontally)
+ {
+ offset = offset.WithX((Owner.Extent.Width - Owner.Viewport.Width) * horizontalPercent * 0.01);
+ }
+
+ if (scrollVertically)
+ {
+ offset = offset.WithY((Owner.Extent.Height - Owner.Viewport.Height) * verticalPercent * 0.01);
+ }
+
+ Owner.Offset = offset;
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/SelectingItemsControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/SelectingItemsControlAutomationPeer.cs
new file mode 100644
index 00000000000..4626e30ff10
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/SelectingItemsControlAutomationPeer.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Selection;
+using Avalonia.VisualTree;
+
+namespace Avalonia.Automation.Peers
+{
+ public abstract class SelectingItemsControlAutomationPeer : ItemsControlAutomationPeer,
+ ISelectionProvider
+ {
+ private ISelectionModel _selection;
+
+ protected SelectingItemsControlAutomationPeer(SelectingItemsControl owner)
+ : base(owner)
+ {
+ _selection = owner.GetValue(ListBox.SelectionProperty);
+ _selection.SelectionChanged += OwnerSelectionChanged;
+ owner.PropertyChanged += OwnerPropertyChanged;
+ }
+
+ public bool CanSelectMultiple => GetSelectionModeCore().HasAllFlags(SelectionMode.Multiple);
+ public bool IsSelectionRequired => GetSelectionModeCore().HasAllFlags(SelectionMode.AlwaysSelected);
+ public IReadOnlyList GetSelection() => GetSelectionCore() ?? Array.Empty();
+
+ protected virtual IReadOnlyList? GetSelectionCore()
+ {
+ List? result = null;
+
+ if (Owner is SelectingItemsControl owner)
+ {
+ var selection = Owner.GetValue(ListBox.SelectionProperty);
+
+ foreach (var i in selection.SelectedIndexes)
+ {
+ var container = owner.ItemContainerGenerator.ContainerFromIndex(i);
+
+ if (container is Control c && ((IVisual)c).IsAttachedToVisualTree)
+ {
+ var peer = GetOrCreate(c);
+
+ if (peer is object)
+ {
+ result ??= new List();
+ result.Add(peer);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ return result;
+ }
+
+ protected virtual SelectionMode GetSelectionModeCore()
+ {
+ return (Owner as SelectingItemsControl)?.GetValue(ListBox.SelectionModeProperty) ?? SelectionMode.Single;
+ }
+
+ protected virtual void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == ListBox.SelectionProperty)
+ {
+ _selection.SelectionChanged -= OwnerSelectionChanged;
+ _selection = Owner.GetValue(ListBox.SelectionProperty);
+ _selection.SelectionChanged += OwnerSelectionChanged;
+ RaiseSelectionChanged();
+ }
+ }
+
+ protected virtual void OwnerSelectionChanged(object? sender, SelectionModelSelectionChangedEventArgs e)
+ {
+ RaiseSelectionChanged();
+ }
+
+ private void RaiseSelectionChanged()
+ {
+ RaisePropertyChangedEvent(SelectionPatternIdentifiers.SelectionProperty, null, null);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs
new file mode 100644
index 00000000000..8a89e38f620
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/TextBlockAutomationPeer.cs
@@ -0,0 +1,27 @@
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ public class TextBlockAutomationPeer : ControlAutomationPeer
+ {
+ public TextBlockAutomationPeer(TextBlock owner)
+ : base(owner)
+ {
+ }
+
+ public new TextBlock Owner => (TextBlock)base.Owner;
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Text;
+ }
+
+ protected override string? GetNameCore() => Owner.Text;
+
+ protected override bool IsControlElementCore()
+ {
+ // Return false if the control is part of a control template.
+ return Owner.TemplatedParent is null && base.IsControlElementCore();
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs
new file mode 100644
index 00000000000..9be17afa8c7
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs
@@ -0,0 +1,23 @@
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ public class TextBoxAutomationPeer : ControlAutomationPeer, IValueProvider
+ {
+ public TextBoxAutomationPeer(TextBox owner)
+ : base(owner)
+ {
+ }
+
+ public new TextBox Owner => (TextBox)base.Owner;
+ public bool IsReadOnly => Owner.IsReadOnly;
+ public string? Value => Owner.Text;
+ public void SetValue(string? value) => Owner.Text = value;
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Edit;
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/ToggleButtonAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ToggleButtonAutomationPeer.cs
new file mode 100644
index 00000000000..979d54f48e9
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/ToggleButtonAutomationPeer.cs
@@ -0,0 +1,39 @@
+using Avalonia.Automation.Provider;
+using Avalonia.Controls.Primitives;
+
+namespace Avalonia.Automation.Peers
+{
+ public class ToggleButtonAutomationPeer : ContentControlAutomationPeer, IToggleProvider
+ {
+ public ToggleButtonAutomationPeer(ToggleButton owner)
+ : base(owner)
+ {
+ }
+
+ public new ToggleButton Owner => (ToggleButton)base.Owner;
+
+ ToggleState IToggleProvider.ToggleState
+ {
+ get => Owner.IsChecked switch
+ {
+ true => ToggleState.On,
+ false => ToggleState.Off,
+ null => ToggleState.Indeterminate,
+ };
+ }
+
+ void IToggleProvider.Toggle()
+ {
+ EnsureEnabled();
+ Owner.PerformClick();
+ }
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Button;
+ }
+
+ protected override bool IsContentElementCore() => true;
+ protected override bool IsControlElementCore() => true;
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs
new file mode 100644
index 00000000000..56d5aa79aee
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/UnrealizedElementAutomationPeer.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Automation.Peers
+{
+ ///
+ /// An automation peer which represents an unrealized element
+ ///
+ public abstract class UnrealizedElementAutomationPeer : AutomationPeer
+ {
+ public void SetParent(AutomationPeer? parent) => TrySetParent(parent);
+ protected override void BringIntoViewCore() => GetParent()?.BringIntoView();
+ protected override Rect GetBoundingRectangleCore() => GetParent()?.GetBoundingRectangle() ?? default;
+ protected override IReadOnlyList GetOrCreateChildrenCore() => Array.Empty();
+ protected override bool HasKeyboardFocusCore() => false;
+ protected override bool IsContentElementCore() => false;
+ protected override bool IsControlElementCore() => false;
+ protected override bool IsEnabledCore() => true;
+ protected override bool IsKeyboardFocusableCore() => false;
+ protected override void SetFocusCore() { }
+ protected override bool ShowContextMenuCore() => false;
+ protected internal override bool TrySetParent(AutomationPeer? parent) => false;
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Peers/WindowAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/WindowAutomationPeer.cs
new file mode 100644
index 00000000000..1162132d54e
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/WindowAutomationPeer.cs
@@ -0,0 +1,36 @@
+using System;
+using Avalonia.Controls;
+
+namespace Avalonia.Automation.Peers
+{
+ public class WindowAutomationPeer : WindowBaseAutomationPeer
+ {
+ public WindowAutomationPeer(Window owner)
+ : base(owner)
+ {
+ if (owner.IsVisible)
+ StartTrackingFocus();
+ else
+ owner.Opened += OnOpened;
+ owner.Closed += OnClosed;
+ }
+
+ public new Window Owner => (Window)base.Owner;
+
+ protected override string? GetNameCore() => Owner.Title;
+
+ 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/WindowBaseAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/WindowBaseAutomationPeer.cs
new file mode 100644
index 00000000000..30b56bbd960
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Peers/WindowBaseAutomationPeer.cs
@@ -0,0 +1,78 @@
+using System;
+using System.ComponentModel;
+using Avalonia.Automation.Provider;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Platform;
+using Avalonia.VisualTree;
+
+namespace Avalonia.Automation.Peers
+{
+ public class WindowBaseAutomationPeer : ControlAutomationPeer, IRootProvider
+ {
+ private Control? _focus;
+
+ public WindowBaseAutomationPeer(WindowBase owner)
+ : base(owner)
+ {
+ }
+
+ public new WindowBase Owner => (WindowBase)base.Owner;
+ public ITopLevelImpl? PlatformImpl => Owner.PlatformImpl;
+
+ public event EventHandler? FocusChanged;
+
+ protected override AutomationControlType GetAutomationControlTypeCore()
+ {
+ return AutomationControlType.Window;
+ }
+
+ public AutomationPeer? GetFocus() => _focus is object ? GetOrCreate(_focus) : null;
+
+ public AutomationPeer? GetPeerFromPoint(Point p)
+ {
+ var hit = Owner.GetVisualAt(p)?.FindAncestorOfType(includeSelf: true);
+ return hit is object ? GetOrCreate(hit) : null;
+ }
+
+ protected void StartTrackingFocus()
+ {
+ if (KeyboardDevice.Instance is not null)
+ {
+ KeyboardDevice.Instance.PropertyChanged += KeyboardDevicePropertyChanged;
+ OnFocusChanged(KeyboardDevice.Instance.FocusedElement);
+ }
+ }
+
+ protected void StopTrackingFocus()
+ {
+ if (KeyboardDevice.Instance is not null)
+ KeyboardDevice.Instance.PropertyChanged -= KeyboardDevicePropertyChanged;
+ }
+
+ private void OnFocusChanged(IInputElement? focus)
+ {
+ var oldFocus = _focus;
+
+ _focus = focus?.VisualRoot == Owner ? focus as Control : 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);
+ }
+ }
+ }
+}
+
+
diff --git a/src/Avalonia.Controls/Automation/Provider/IExpandCollapseProvider.cs b/src/Avalonia.Controls/Automation/Provider/IExpandCollapseProvider.cs
new file mode 100644
index 00000000000..a4691180a3f
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/IExpandCollapseProvider.cs
@@ -0,0 +1,33 @@
+namespace Avalonia.Automation.Provider
+{
+ ///
+ /// Exposes methods and properties to support UI Automation client access to controls that
+ /// visually expand to display content and collapse to hide content.
+ ///
+ public interface IExpandCollapseProvider
+ {
+ ///
+ /// Gets the state, expanded or collapsed, of the control.
+ ///
+ ExpandCollapseState ExpandCollapseState { get; }
+
+ ///
+ /// Gets a value indicating whether expanding the element shows a menu of items to the user,
+ /// such as drop-down list.
+ ///
+ ///
+ /// Used in OSX to enable the "Show Menu" action on the element.
+ ///
+ bool ShowsMenu { get; }
+
+ ///
+ /// Displays all child nodes, controls, or content of the control.
+ ///
+ void Expand();
+
+ ///
+ /// Hides all nodes, controls, or content that are descendants of the control.
+ ///
+ void Collapse();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/IInvokeProvider.cs b/src/Avalonia.Controls/Automation/Provider/IInvokeProvider.cs
new file mode 100644
index 00000000000..47d7211c929
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/IInvokeProvider.cs
@@ -0,0 +1,15 @@
+namespace Avalonia.Automation.Provider
+{
+ ///
+ /// Exposes methods and properties to support UI Automation client access to controls that
+ /// initiate or perform a single, unambiguous action and do not maintain state when
+ /// activated.
+ ///
+ public interface IInvokeProvider
+ {
+ ///
+ /// Sends a request to activate a control and initiate its single, unambiguous action.
+ ///
+ void Invoke();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/IRangeValueProvider.cs b/src/Avalonia.Controls/Automation/Provider/IRangeValueProvider.cs
new file mode 100644
index 00000000000..43a877a21a5
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/IRangeValueProvider.cs
@@ -0,0 +1,47 @@
+namespace Avalonia.Automation.Provider
+{
+ ///
+ /// Exposes methods and properties to support access by a UI Automation client to controls
+ /// that can be set to a value within a range.
+ ///
+ public interface IRangeValueProvider
+ {
+ ///
+ /// Gets a value that indicates whether the value of a control is read-only.
+ ///
+ bool IsReadOnly { get; }
+
+ ///
+ /// Gets the minimum range value that is supported by the control.
+ ///
+ double Minimum { get; }
+
+ ///
+ /// Gets the maximum range value that is supported by the control.
+ ///
+ double Maximum { get; }
+
+ ///
+ /// Gets the value of the control.
+ ///
+ double Value { get; }
+
+ ///
+ /// Gets the value that is added to or subtracted from the Value property when a large
+ /// change is made, such as with the PAGE DOWN key.
+ ///
+ double LargeChange { get; }
+
+ ///
+ /// Gets the value that is added to or subtracted from the Value property when a small
+ /// change is made, such as with an arrow key.
+ ///
+ double SmallChange { get; }
+
+ ///
+ /// Sets the value of the control.
+ ///
+ /// The value to set.
+ public void SetValue(double value);
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/IRootProvider.cs b/src/Avalonia.Controls/Automation/Provider/IRootProvider.cs
new file mode 100644
index 00000000000..ce380595596
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/IRootProvider.cs
@@ -0,0 +1,14 @@
+using System;
+using Avalonia.Automation.Peers;
+using Avalonia.Platform;
+
+namespace Avalonia.Automation.Provider
+{
+ public interface IRootProvider
+ {
+ ITopLevelImpl? PlatformImpl { get; }
+ AutomationPeer? GetFocus();
+ AutomationPeer? GetPeerFromPoint(Point p);
+ event EventHandler? FocusChanged;
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/IScrollProvider.cs b/src/Avalonia.Controls/Automation/Provider/IScrollProvider.cs
new file mode 100644
index 00000000000..1055a2f1e11
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/IScrollProvider.cs
@@ -0,0 +1,71 @@
+namespace Avalonia.Automation.Provider
+{
+ public enum ScrollAmount
+ {
+ LargeDecrement,
+ SmallDecrement,
+ NoAmount,
+ LargeIncrement,
+ SmallIncrement,
+ }
+
+ ///
+ /// Exposes methods and properties to support access by a UI Automation client to a control
+ /// that acts as a scrollable container for a collection of child objects.
+ ///
+ public interface IScrollProvider
+ {
+ ///
+ /// Gets a value that indicates whether the control can scroll horizontally.
+ ///
+ bool HorizontallyScrollable { get; }
+
+ ///
+ /// Gets the current horizontal scroll position.
+ ///
+ double HorizontalScrollPercent { get; }
+
+ ///
+ /// Gets the current horizontal view size.
+ ///
+ double HorizontalViewSize { get; }
+
+ ///
+ /// Gets a value that indicates whether the control can scroll vertically.
+ ///
+ bool VerticallyScrollable { get; }
+
+ ///
+ /// Gets the current vertical scroll position.
+ ///
+ double VerticalScrollPercent { get; }
+
+ ///
+ /// Gets the vertical view size.
+ ///
+ double VerticalViewSize { get; }
+
+ ///
+ /// Scrolls the visible region of the content area horizontally and vertically.
+ ///
+ /// The horizontal increment specific to the control.
+ /// The vertical increment specific to the control.
+ void Scroll(ScrollAmount horizontalAmount, ScrollAmount verticalAmount);
+
+ ///
+ /// Sets the horizontal and vertical scroll position as a percentage of the total content
+ /// area within the control.
+ ///
+ ///
+ /// The horizontal position as a percentage of the content area's total range.
+ /// should be passed in if the control
+ /// cannot be scrolled in this direction.
+ ///
+ ///
+ /// The vertical position as a percentage of the content area's total range.
+ /// should be passed in if the control
+ /// cannot be scrolled in this direction.
+ ///
+ void SetScrollPercent(double horizontalPercent, double verticalPercent);
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs b/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs
new file mode 100644
index 00000000000..6cea1d13500
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs
@@ -0,0 +1,35 @@
+namespace Avalonia.Automation.Provider
+{
+ ///
+ /// Exposes methods and properties to support access by a UI Automation client to individual,
+ /// selectable child controls of containers that implement .
+ ///
+ public interface ISelectionItemProvider
+ {
+ ///
+ /// Gets a value that indicates whether an item is selected.
+ ///
+ bool IsSelected { get; }
+
+ ///
+ /// Gets the UI Automation provider that implements and
+ /// acts as the container for the calling object.
+ ///
+ ISelectionProvider? SelectionContainer { get; }
+
+ ///
+ /// Adds the current element to the collection of selected items.
+ ///
+ void AddToSelection();
+
+ ///
+ /// Removes the current element from the collection of selected items.
+ ///
+ void RemoveFromSelection();
+
+ ///
+ /// Clears any existing selection and then selects the current element.
+ ///
+ void Select();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/ISelectionProvider.cs b/src/Avalonia.Controls/Automation/Provider/ISelectionProvider.cs
new file mode 100644
index 00000000000..bf21c0151f3
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/ISelectionProvider.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using Avalonia.Automation.Peers;
+
+namespace Avalonia.Automation.Provider
+{
+ ///
+ /// Exposes methods and properties to support access by a UI Automation client to controls
+ /// that act as containers for a collection of individual, selectable child items.
+ ///
+ public interface ISelectionProvider
+ {
+ ///
+ /// Gets a value that indicates whether the provider allows more than one child element
+ /// to be selected concurrently.
+ ///
+ bool CanSelectMultiple { get; }
+
+ ///
+ /// Gets a value that indicates whether the provider requires at least one child element
+ /// to be selected.
+ ///
+ bool IsSelectionRequired { get; }
+
+ ///
+ /// Retrieves a provider for each child element that is selected.
+ ///
+ IReadOnlyList GetSelection();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/IToggleProvider.cs b/src/Avalonia.Controls/Automation/Provider/IToggleProvider.cs
new file mode 100644
index 00000000000..67913e32048
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/IToggleProvider.cs
@@ -0,0 +1,40 @@
+namespace Avalonia.Automation.Provider
+{
+ ///
+ /// Contains values that specify the toggle state of a UI Automation element.
+ ///
+ public enum ToggleState
+ {
+ ///
+ /// The UI Automation element isn't selected, checked, marked, or otherwise activated.
+ ///
+ Off,
+
+ ///
+ /// The UI Automation element is selected, checked, marked, or otherwise activated.
+ ///
+ On,
+
+ ///
+ /// The UI Automation element is in an indeterminate state.
+ ///
+ Indeterminate,
+ }
+
+ ///
+ /// Exposes methods and properties to support UI Automation client access to controls that can
+ /// cycle through a set of states and maintain a particular state.
+ ///
+ public interface IToggleProvider
+ {
+ ///
+ /// Gets the toggle state of the control.
+ ///
+ ToggleState ToggleState { get; }
+
+ ///
+ /// Cycles through the toggle states of a control.
+ ///
+ void Toggle();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/Provider/IValueProvider.cs b/src/Avalonia.Controls/Automation/Provider/IValueProvider.cs
new file mode 100644
index 00000000000..e025e287823
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/Provider/IValueProvider.cs
@@ -0,0 +1,29 @@
+namespace Avalonia.Automation.Provider
+{
+ ///
+ /// Exposes methods and properties to support access by a UI Automation client to controls
+ /// that have an intrinsic value that does not span a range and that can be represented as
+ /// a string.
+ ///
+ public interface IValueProvider
+ {
+ ///
+ /// Gets a value that indicates whether the value of a control is read-only.
+ ///
+ bool IsReadOnly { get; }
+
+ ///
+ /// Gets the value of the control.
+ ///
+ public string? Value { get; }
+
+ ///
+ /// Sets the value of a control.
+ ///
+ ///
+ /// The value to set. The provider is responsible for converting the value to the
+ /// appropriate data type.
+ ///
+ public void SetValue(string? value);
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/RangeValuePatternIdentifiers.cs b/src/Avalonia.Controls/Automation/RangeValuePatternIdentifiers.cs
new file mode 100644
index 00000000000..625b37d0017
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/RangeValuePatternIdentifiers.cs
@@ -0,0 +1,30 @@
+using Avalonia.Automation.Provider;
+
+namespace Avalonia.Automation
+{
+ ///
+ /// Contains values used as identifiers by .
+ ///
+ public static class RangeValuePatternIdentifiers
+ {
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty IsReadOnlyProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty MinimumProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty MaximumProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty ValueProperty { get; } = new AutomationProperty();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/ScrollPatternIdentifiers.cs b/src/Avalonia.Controls/Automation/ScrollPatternIdentifiers.cs
new file mode 100644
index 00000000000..d9e843e75aa
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/ScrollPatternIdentifiers.cs
@@ -0,0 +1,45 @@
+using Avalonia.Automation.Provider;
+
+namespace Avalonia.Automation
+{
+ ///
+ /// Contains values used as identifiers by .
+ ///
+ public static class ScrollPatternIdentifiers
+ {
+ ///
+ /// Specifies that scrolling should not be performed.
+ ///
+ public const double NoScroll = -1;
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty HorizontallyScrollableProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty HorizontalScrollPercentProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty HorizontalViewSizeProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty VerticallyScrollableProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty VerticalScrollPercentProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty VerticalViewSizeProperty { get; } = new AutomationProperty();
+ }
+}
diff --git a/src/Avalonia.Controls/Automation/SelectionPatternIdentifiers.cs b/src/Avalonia.Controls/Automation/SelectionPatternIdentifiers.cs
new file mode 100644
index 00000000000..c3669528cd7
--- /dev/null
+++ b/src/Avalonia.Controls/Automation/SelectionPatternIdentifiers.cs
@@ -0,0 +1,25 @@
+using Avalonia.Automation.Provider;
+
+namespace Avalonia.Automation
+{
+ ///
+ /// Contains values used as identifiers by .
+ ///
+ public static class SelectionPatternIdentifiers
+ {
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty CanSelectMultipleProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies automation property.
+ ///
+ public static AutomationProperty IsSelectionRequiredProperty { get; } = new AutomationProperty();
+
+ ///
+ /// Identifies the property that gets the selected items in a container.
+ ///
+ public static AutomationProperty SelectionProperty { get; } = new AutomationProperty();
+ }
+}
diff --git a/src/Avalonia.Controls/Button.cs b/src/Avalonia.Controls/Button.cs
index a7a4759182e..72495bdcb33 100644
--- a/src/Avalonia.Controls/Button.cs
+++ b/src/Avalonia.Controls/Button.cs
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Windows.Input;
+using Avalonia.Automation.Peers;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
@@ -237,7 +238,7 @@ protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e
{
HotKey = _hotkey;
}
-
+
base.OnAttachedToLogicalTree(e);
if (Command != null)
@@ -455,6 +456,8 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs
}
}
+ protected override AutomationPeer OnCreateAutomationPeer() => new ButtonAutomationPeer(this);
+
///
protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value)
{
@@ -472,6 +475,8 @@ protected override void UpdateDataValidation(AvaloniaProperty property, Bi
}
}
+ internal void PerformClick() => OnClick();
+
///
/// Called when the event fires.
///
diff --git a/src/Avalonia.Controls/CheckBox.cs b/src/Avalonia.Controls/CheckBox.cs
index 05d49a44b1a..238a21393f8 100644
--- a/src/Avalonia.Controls/CheckBox.cs
+++ b/src/Avalonia.Controls/CheckBox.cs
@@ -1,3 +1,5 @@
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls
@@ -7,5 +9,9 @@ namespace Avalonia.Controls
///
public class CheckBox : ToggleButton
{
+ static CheckBox()
+ {
+ AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.CheckBox);
+ }
}
}
diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs
index 72b09b7a3c4..c5410ae9b0f 100644
--- a/src/Avalonia.Controls/ComboBox.cs
+++ b/src/Avalonia.Controls/ComboBox.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
+using Avalonia.Automation.Peers;
using System.Reactive.Disposables;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Mixins;
@@ -295,6 +296,11 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
_popup.Closed += PopupClosed;
}
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new ComboBoxAutomationPeer(this);
+ }
+
internal void ItemFocused(ComboBoxItem dropDownItem)
{
if (IsDropDownOpen && dropDownItem.IsFocused && dropDownItem.IsArrangeValid)
diff --git a/src/Avalonia.Controls/ComboBoxItem.cs b/src/Avalonia.Controls/ComboBoxItem.cs
index a0a1f2a4aaa..83057d139ff 100644
--- a/src/Avalonia.Controls/ComboBoxItem.cs
+++ b/src/Avalonia.Controls/ComboBoxItem.cs
@@ -1,5 +1,7 @@
using System;
using System.Reactive.Linq;
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
namespace Avalonia.Controls
{
@@ -13,5 +15,10 @@ public ComboBoxItem()
this.GetObservable(ComboBoxItem.IsFocusedProperty).Where(focused => focused)
.Subscribe(_ => (Parent as ComboBox)?.ItemFocused(this));
}
+
+ static ComboBoxItem()
+ {
+ AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.ComboBoxItem);
+ }
}
}
diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs
index dff06a63695..7a8438f1c33 100644
--- a/src/Avalonia.Controls/ContextMenu.cs
+++ b/src/Avalonia.Controls/ContextMenu.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
+using Avalonia.Automation.Peers;
using System.Linq;
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Generators;
@@ -13,6 +14,7 @@
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Styling;
+using Avalonia.Automation;
namespace Avalonia.Controls
{
@@ -107,6 +109,8 @@ static ContextMenu()
ItemsPanelProperty.OverrideDefaultValue(DefaultPanel);
PlacementModeProperty.OverrideDefaultValue(PlacementMode.Pointer);
ContextMenuProperty.Changed.Subscribe(ContextMenuChanged);
+ AutomationProperties.AccessibilityViewProperty.OverrideDefaultValue(AccessibilityView.Control);
+ AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.Menu);
}
///
diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs
index b9f3bd88903..cec662aad81 100644
--- a/src/Avalonia.Controls/Control.cs
+++ b/src/Avalonia.Controls/Control.cs
@@ -1,6 +1,7 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
+using Avalonia.Automation.Peers;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
@@ -69,6 +70,7 @@ public class Control : InputElement, IControl, INamed, IVisualBrushInitialize, I
private DataTemplates? _dataTemplates;
private IControl? _focusAdorner;
+ private AutomationPeer? _automationPeer;
///
/// Gets or sets the control's focus adorner.
@@ -242,6 +244,24 @@ protected override void OnLostFocus(RoutedEventArgs e)
}
}
+ protected virtual AutomationPeer OnCreateAutomationPeer()
+ {
+ return new NoneAutomationPeer(this);
+ }
+
+ internal AutomationPeer GetOrCreateAutomationPeer()
+ {
+ VerifyAccess();
+
+ if (_automationPeer is object)
+ {
+ return _automationPeer;
+ }
+
+ _automationPeer = OnCreateAutomationPeer();
+ return _automationPeer;
+ }
+
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
diff --git a/src/Avalonia.Controls/Image.cs b/src/Avalonia.Controls/Image.cs
index c4487296431..3d678806380 100644
--- a/src/Avalonia.Controls/Image.cs
+++ b/src/Avalonia.Controls/Image.cs
@@ -1,3 +1,5 @@
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Metadata;
@@ -33,6 +35,7 @@ static Image()
{
AffectsRender(SourceProperty, StretchProperty, StretchDirectionProperty);
AffectsMeasure(SourceProperty, StretchProperty, StretchDirectionProperty);
+ AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.Image);
}
///
diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs
index ed8f9efb2e5..0cd72dc91c7 100644
--- a/src/Avalonia.Controls/ItemsControl.cs
+++ b/src/Avalonia.Controls/ItemsControl.cs
@@ -4,6 +4,7 @@
using System.Collections.Specialized;
using System.Linq;
using Avalonia.Collections;
+using Avalonia.Automation.Peers;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Presenters;
@@ -335,6 +336,11 @@ protected override void OnKeyDown(KeyEventArgs e)
base.OnKeyDown(e);
}
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new ItemsControlAutomationPeer(this);
+ }
+
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
diff --git a/src/Avalonia.Controls/ListBoxItem.cs b/src/Avalonia.Controls/ListBoxItem.cs
index 4fe5f4de40d..66a46cab4a5 100644
--- a/src/Avalonia.Controls/ListBoxItem.cs
+++ b/src/Avalonia.Controls/ListBoxItem.cs
@@ -1,6 +1,6 @@
+using Avalonia.Automation.Peers;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Mixins;
-using Avalonia.Input;
namespace Avalonia.Controls
{
@@ -34,5 +34,10 @@ public bool IsSelected
get { return GetValue(IsSelectedProperty); }
set { SetValue(IsSelectedProperty, value); }
}
+
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new ListItemAutomationPeer(this);
+ }
}
}
diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs
index cc89677f82a..611811f1705 100644
--- a/src/Avalonia.Controls/Menu.cs
+++ b/src/Avalonia.Controls/Menu.cs
@@ -1,3 +1,5 @@
+using Avalonia.Automation;
+using Avalonia.Automation.Peers;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
@@ -35,6 +37,8 @@ public Menu(IMenuInteractionHandler interactionHandler)
static Menu()
{
ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel);
+ AutomationProperties.AccessibilityViewProperty.OverrideDefaultValue
+
+
+
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IDockProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IDockProvider.cs
new file mode 100644
index 00000000000..2787434d266
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IDockProvider.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("70d46e77-e3a8-449d-913c-e30eb2afecdb")]
+ public enum DockPosition
+ {
+ Top,
+ Left,
+ Bottom,
+ Right,
+ Fill,
+ None
+ }
+
+ [ComVisible(true)]
+ [Guid("159bc72c-4ad3-485e-9637-d7052edf0146")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IDockProvider
+ {
+ void SetDockPosition(DockPosition dockPosition);
+ DockPosition DockPosition { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IExpandCollapseProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IExpandCollapseProvider.cs
new file mode 100644
index 00000000000..67be1e6c717
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IExpandCollapseProvider.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia.Automation;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("d847d3a5-cab0-4a98-8c32-ecb45c59ad24")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IExpandCollapseProvider
+ {
+ void Expand();
+ void Collapse();
+ ExpandCollapseState ExpandCollapseState { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IGridItemProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IGridItemProvider.cs
new file mode 100644
index 00000000000..f911c384722
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IGridItemProvider.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("d02541f1-fb81-4d64-ae32-f520f8a6dbd1")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IGridItemProvider
+ {
+ int Row { get; }
+ int Column { get; }
+ int RowSpan { get; }
+ int ColumnSpan { get; }
+ IRawElementProviderSimple ContainingGrid { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IGridProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IGridProvider.cs
new file mode 100644
index 00000000000..a8caf265247
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IGridProvider.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("b17d6187-0907-464b-a168-0ef17a1572b1")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IGridProvider
+ {
+ IRawElementProviderSimple GetItem(int row, int column);
+ int RowCount { get; }
+ int ColumnCount { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IInvokeProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IInvokeProvider.cs
new file mode 100644
index 00000000000..f35646d4561
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IInvokeProvider.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+// Description: Invoke pattern provider interface
+
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("54fcb24b-e18e-47a2-b4d3-eccbe77599a2")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IInvokeProvider
+ {
+ void Invoke();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IMultipleViewProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IMultipleViewProvider.cs
new file mode 100644
index 00000000000..c487a0f5df0
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IMultipleViewProvider.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("6278cab1-b556-4a1a-b4e0-418acc523201")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IMultipleViewProvider
+ {
+ string GetViewName(int viewId);
+ void SetCurrentView(int viewId);
+ int CurrentView { get; }
+ int[] GetSupportedViews();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IRangeValueProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IRangeValueProvider.cs
new file mode 100644
index 00000000000..558f38a2cc6
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IRangeValueProvider.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("36dc7aef-33e6-4691-afe1-2be7274b3d33")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IRangeValueProvider
+ {
+ void SetValue(double value);
+ double Value { get; }
+ bool IsReadOnly { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ double Maximum { get; }
+ double Minimum { get; }
+ double LargeChange { get; }
+ double SmallChange { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderAdviseEvents.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderAdviseEvents.cs
new file mode 100644
index 00000000000..1e799e05a22
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderAdviseEvents.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Runtime.InteropServices;
+
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("a407b27b-0f6d-4427-9292-473c7bf93258")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IRawElementProviderAdviseEvents : IRawElementProviderSimple
+ {
+ void AdviseEventAdded(int eventId, int [] properties);
+ void AdviseEventRemoved(int eventId, int [] properties);
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderFragment.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderFragment.cs
new file mode 100644
index 00000000000..a62aa842cb2
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderFragment.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Runtime.InteropServices;
+
+#nullable enable
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("670c3006-bf4c-428b-8534-e1848f645122")]
+ public enum NavigateDirection
+ {
+ Parent,
+ NextSibling,
+ PreviousSibling,
+ FirstChild,
+ LastChild,
+ }
+
+ // NOTE: This interface needs to be public otherwise Navigate is never called. I have no idea
+ // why given that IRawElementProviderSimple and IRawElementProviderFragmentRoot seem to get
+ // called fine when they're internal, but I lost a couple of days to this.
+ [ComVisible(true)]
+ [Guid("f7063da8-8359-439c-9297-bbc5299a7d87")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IRawElementProviderFragment : IRawElementProviderSimple
+ {
+ IRawElementProviderFragment? Navigate(NavigateDirection direction);
+ int[]? GetRuntimeId();
+ Rect BoundingRectangle { get; }
+ IRawElementProviderSimple[]? GetEmbeddedFragmentRoots();
+ void SetFocus();
+ IRawElementProviderFragmentRoot? FragmentRoot { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderFragmentRoot.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderFragmentRoot.cs
new file mode 100644
index 00000000000..71d1bdce608
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderFragmentRoot.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("620ce2a5-ab8f-40a9-86cb-de3c75599b58")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IRawElementProviderFragmentRoot : IRawElementProviderFragment
+ {
+ IRawElementProviderFragment ElementProviderFromPoint(double x, double y);
+ IRawElementProviderFragment GetFocus();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderSimple.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderSimple.cs
new file mode 100644
index 00000000000..439036290ed
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderSimple.cs
@@ -0,0 +1,285 @@
+using System;
+using System.Runtime.InteropServices;
+
+#nullable enable
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [Flags]
+ public enum ProviderOptions
+ {
+ ClientSideProvider = 0x0001,
+ ServerSideProvider = 0x0002,
+ NonClientAreaProvider = 0x0004,
+ OverrideProvider = 0x0008,
+ ProviderOwnsSetFocus = 0x0010,
+ UseComThreading = 0x0020
+ }
+
+ internal enum UiaPropertyId
+ {
+ RuntimeId = 30000,
+ BoundingRectangle,
+ ProcessId,
+ ControlType,
+ LocalizedControlType,
+ Name,
+ AcceleratorKey,
+ AccessKey,
+ HasKeyboardFocus,
+ IsKeyboardFocusable,
+ IsEnabled,
+ AutomationId,
+ ClassName,
+ HelpText,
+ ClickablePoint,
+ Culture,
+ IsControlElement,
+ IsContentElement,
+ LabeledBy,
+ IsPassword,
+ NativeWindowHandle,
+ ItemType,
+ IsOffscreen,
+ Orientation,
+ FrameworkId,
+ IsRequiredForForm,
+ ItemStatus,
+ IsDockPatternAvailable,
+ IsExpandCollapsePatternAvailable,
+ IsGridItemPatternAvailable,
+ IsGridPatternAvailable,
+ IsInvokePatternAvailable,
+ IsMultipleViewPatternAvailable,
+ IsRangeValuePatternAvailable,
+ IsScrollPatternAvailable,
+ IsScrollItemPatternAvailable,
+ IsSelectionItemPatternAvailable,
+ IsSelectionPatternAvailable,
+ IsTablePatternAvailable,
+ IsTableItemPatternAvailable,
+ IsTextPatternAvailable,
+ IsTogglePatternAvailable,
+ IsTransformPatternAvailable,
+ IsValuePatternAvailable,
+ IsWindowPatternAvailable,
+ ValueValue,
+ ValueIsReadOnly,
+ RangeValueValue,
+ RangeValueIsReadOnly,
+ RangeValueMinimum,
+ RangeValueMaximum,
+ RangeValueLargeChange,
+ RangeValueSmallChange,
+ ScrollHorizontalScrollPercent,
+ ScrollHorizontalViewSize,
+ ScrollVerticalScrollPercent,
+ ScrollVerticalViewSize,
+ ScrollHorizontallyScrollable,
+ ScrollVerticallyScrollable,
+ SelectionSelection,
+ SelectionCanSelectMultiple,
+ SelectionIsSelectionRequired,
+ GridRowCount,
+ GridColumnCount,
+ GridItemRow,
+ GridItemColumn,
+ GridItemRowSpan,
+ GridItemColumnSpan,
+ GridItemContainingGrid,
+ DockDockPosition,
+ ExpandCollapseExpandCollapseState,
+ MultipleViewCurrentView,
+ MultipleViewSupportedViews,
+ WindowCanMaximize,
+ WindowCanMinimize,
+ WindowWindowVisualState,
+ WindowWindowInteractionState,
+ WindowIsModal,
+ WindowIsTopmost,
+ SelectionItemIsSelected,
+ SelectionItemSelectionContainer,
+ TableRowHeaders,
+ TableColumnHeaders,
+ TableRowOrColumnMajor,
+ TableItemRowHeaderItems,
+ TableItemColumnHeaderItems,
+ ToggleToggleState,
+ TransformCanMove,
+ TransformCanResize,
+ TransformCanRotate,
+ IsLegacyIAccessiblePatternAvailable,
+ LegacyIAccessibleChildId,
+ LegacyIAccessibleName,
+ LegacyIAccessibleValue,
+ LegacyIAccessibleDescription,
+ LegacyIAccessibleRole,
+ LegacyIAccessibleState,
+ LegacyIAccessibleHelp,
+ LegacyIAccessibleKeyboardShortcut,
+ LegacyIAccessibleSelection,
+ LegacyIAccessibleDefaultAction,
+ AriaRole,
+ AriaProperties,
+ IsDataValidForForm,
+ ControllerFor,
+ DescribedBy,
+ FlowsTo,
+ ProviderDescription,
+ IsItemContainerPatternAvailable,
+ IsVirtualizedItemPatternAvailable,
+ IsSynchronizedInputPatternAvailable,
+ OptimizeForVisualContent,
+ IsObjectModelPatternAvailable,
+ AnnotationAnnotationTypeId,
+ AnnotationAnnotationTypeName,
+ AnnotationAuthor,
+ AnnotationDateTime,
+ AnnotationTarget,
+ IsAnnotationPatternAvailable,
+ IsTextPattern2Available,
+ StylesStyleId,
+ StylesStyleName,
+ StylesFillColor,
+ StylesFillPatternStyle,
+ StylesShape,
+ StylesFillPatternColor,
+ StylesExtendedProperties,
+ IsStylesPatternAvailable,
+ IsSpreadsheetPatternAvailable,
+ SpreadsheetItemFormula,
+ SpreadsheetItemAnnotationObjects,
+ SpreadsheetItemAnnotationTypes,
+ IsSpreadsheetItemPatternAvailable,
+ Transform2CanZoom,
+ IsTransformPattern2Available,
+ LiveSetting,
+ IsTextChildPatternAvailable,
+ IsDragPatternAvailable,
+ DragIsGrabbed,
+ DragDropEffect,
+ DragDropEffects,
+ IsDropTargetPatternAvailable,
+ DropTargetDropTargetEffect,
+ DropTargetDropTargetEffects,
+ DragGrabbedItems,
+ Transform2ZoomLevel,
+ Transform2ZoomMinimum,
+ Transform2ZoomMaximum,
+ FlowsFrom,
+ IsTextEditPatternAvailable,
+ IsPeripheral,
+ IsCustomNavigationPatternAvailable,
+ PositionInSet,
+ SizeOfSet,
+ Level,
+ AnnotationTypes,
+ AnnotationObjects,
+ LandmarkType,
+ LocalizedLandmarkType,
+ FullDescription,
+ FillColor,
+ OutlineColor,
+ FillType,
+ VisualEffects,
+ OutlineThickness,
+ CenterPoint,
+ Rotatation,
+ Size
+ }
+
+ internal enum UiaPatternId
+ {
+ Invoke = 10000,
+ Selection,
+ Value,
+ RangeValue,
+ Scroll,
+ ExpandCollapse,
+ Grid,
+ GridItem,
+ MultipleView,
+ Window,
+ SelectionItem,
+ Dock,
+ Table,
+ TableItem,
+ Text,
+ Toggle,
+ Transform,
+ ScrollItem,
+ LegacyIAccessible,
+ ItemContainer,
+ VirtualizedItem,
+ SynchronizedInput,
+ ObjectModel,
+ Annotation,
+ Text2,
+ Styles,
+ Spreadsheet,
+ SpreadsheetItem,
+ Transform2,
+ TextChild,
+ Drag,
+ DropTarget,
+ TextEdit,
+ CustomNavigation
+ };
+
+ internal enum UiaControlTypeId
+ {
+ Button = 50000,
+ Calendar,
+ CheckBox,
+ ComboBox,
+ Edit,
+ Hyperlink,
+ Image,
+ ListItem,
+ List,
+ Menu,
+ MenuBar,
+ MenuItem,
+ ProgressBar,
+ RadioButton,
+ ScrollBar,
+ Slider,
+ Spinner,
+ StatusBar,
+ Tab,
+ TabItem,
+ Text,
+ ToolBar,
+ ToolTip,
+ Tree,
+ TreeItem,
+ Custom,
+ Group,
+ Thumb,
+ DataGrid,
+ DataItem,
+ Document,
+ SplitButton,
+ Window,
+ Pane,
+ Header,
+ HeaderItem,
+ Table,
+ TitleBar,
+ Separator,
+ SemanticZoom,
+ AppBar
+ };
+
+ [ComVisible(true)]
+ [Guid("d6dd68d1-86fd-4332-8666-9abedea2d24c")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IRawElementProviderSimple
+ {
+ ProviderOptions ProviderOptions { get; }
+ [return: MarshalAs(UnmanagedType.IUnknown)]
+ object? GetPatternProvider(int patternId);
+ object? GetPropertyValue(int propertyId);
+ IRawElementProviderSimple? HostRawElementProvider { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderSimple2.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderSimple2.cs
new file mode 100644
index 00000000000..f3504b8d773
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IRawElementProviderSimple2.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Runtime.InteropServices;
+
+#nullable enable
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("a0a839a9-8da1-4a82-806a-8e0d44e79f56")]
+ public interface IRawElementProviderSimple2
+ {
+ void ShowContextMenu();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IScrollItemProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IScrollItemProvider.cs
new file mode 100644
index 00000000000..c34c8667ef8
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IScrollItemProvider.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("2360c714-4bf1-4b26-ba65-9b21316127eb")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IScrollItemProvider
+ {
+ void ScrollIntoView();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IScrollProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IScrollProvider.cs
new file mode 100644
index 00000000000..154d52c6af6
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IScrollProvider.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia.Automation.Provider;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("b38b8077-1fc3-42a5-8cae-d40c2215055a")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IScrollProvider
+ {
+ void Scroll(ScrollAmount horizontalAmount, ScrollAmount verticalAmount);
+ void SetScrollPercent(double horizontalPercent, double verticalPercent);
+ double HorizontalScrollPercent { get; }
+ double VerticalScrollPercent { get; }
+ double HorizontalViewSize { get; }
+ double VerticalViewSize { get; }
+ bool HorizontallyScrollable { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ bool VerticallyScrollable { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ISelectionItemProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ISelectionItemProvider.cs
new file mode 100644
index 00000000000..1de0cf0f9b2
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ISelectionItemProvider.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("2acad808-b2d4-452d-a407-91ff1ad167b2")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface ISelectionItemProvider
+ {
+ void Select();
+ void AddToSelection();
+ void RemoveFromSelection();
+ bool IsSelected { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ IRawElementProviderSimple? SelectionContainer { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ISelectionProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ISelectionProvider.cs
new file mode 100644
index 00000000000..8a5924126dc
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ISelectionProvider.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("fb8b03af-3bdf-48d4-bd36-1a65793be168")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface ISelectionProvider
+ {
+ IRawElementProviderSimple [] GetSelection();
+ bool CanSelectMultiple { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ bool IsSelectionRequired { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ISynchronizedInputProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ISynchronizedInputProvider.cs
new file mode 100644
index 00000000000..def1bbd4b96
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ISynchronizedInputProvider.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("fdc8f176-aed2-477a-8c89-5604c66f278d")]
+ public enum SynchronizedInputType
+ {
+ KeyUp = 0x01,
+ KeyDown = 0x02,
+ MouseLeftButtonUp = 0x04,
+ MouseLeftButtonDown = 0x08,
+ MouseRightButtonUp = 0x10,
+ MouseRightButtonDown = 0x20
+ }
+
+ [ComVisible(true)]
+ [Guid("29db1a06-02ce-4cf7-9b42-565d4fab20ee")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface ISynchronizedInputProvider
+ {
+ void StartListening(SynchronizedInputType inputType);
+ void Cancel();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ITableItemProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ITableItemProvider.cs
new file mode 100644
index 00000000000..36751122d14
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ITableItemProvider.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("b9734fa6-771f-4d78-9c90-2517999349cd")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface ITableItemProvider : IGridItemProvider
+ {
+ IRawElementProviderSimple [] GetRowHeaderItems();
+ IRawElementProviderSimple [] GetColumnHeaderItems();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ITableProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ITableProvider.cs
new file mode 100644
index 00000000000..e82bda3272c
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ITableProvider.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("15fdf2e2-9847-41cd-95dd-510612a025ea")]
+ public enum RowOrColumnMajor
+ {
+ RowMajor,
+ ColumnMajor,
+ Indeterminate,
+ }
+
+ [ComVisible(true)]
+ [Guid("9c860395-97b3-490a-b52a-858cc22af166")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface ITableProvider : IGridProvider
+ {
+ IRawElementProviderSimple [] GetRowHeaders();
+ IRawElementProviderSimple [] GetColumnHeaders();
+ RowOrColumnMajor RowOrColumnMajor { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs
new file mode 100644
index 00000000000..3f8fbc80c74
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ITextProvider.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [Flags]
+ [ComVisible(true)]
+ [Guid("3d9e3d8f-bfb0-484f-84ab-93ff4280cbc4")]
+ public enum SupportedTextSelection
+ {
+ None,
+ Single,
+ Multiple,
+ }
+
+ [ComVisible(true)]
+ [Guid("3589c92c-63f3-4367-99bb-ada653b77cf2")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface ITextProvider
+ {
+ ITextRangeProvider [] GetSelection();
+ ITextRangeProvider [] GetVisibleRanges();
+ 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
new file mode 100644
index 00000000000..9ebb4c9f497
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ITextRangeProvider.cs
@@ -0,0 +1,48 @@
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ public enum TextPatternRangeEndpoint
+ {
+ Start = 0,
+ End = 1,
+ }
+
+ public enum TextUnit
+ {
+ Character = 0,
+ Format = 1,
+ Word = 2,
+ Line = 3,
+ Paragraph = 4,
+ Page = 5,
+ Document = 6,
+ }
+
+ [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();
+ IRawElementProviderSimple GetEnclosingElement();
+ string GetText(int maxLength);
+ int Move(TextUnit unit, int count);
+ int MoveEndpointByUnit(TextPatternRangeEndpoint endpoint, TextUnit unit, int count);
+ void MoveEndpointByRange(TextPatternRangeEndpoint endpoint, ITextRangeProvider targetRange, TextPatternRangeEndpoint targetEndpoint);
+ void Select();
+ void AddToSelection();
+ void RemoveFromSelection();
+ void ScrollIntoView([MarshalAs(UnmanagedType.Bool)] bool alignToTop);
+ IRawElementProviderSimple[] GetChildren();
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IToggleProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IToggleProvider.cs
new file mode 100644
index 00000000000..e4072a12506
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IToggleProvider.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia.Automation.Provider;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("56d00bd0-c4f4-433c-a836-1a52a57e0892")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IToggleProvider
+ {
+ void Toggle( );
+ ToggleState ToggleState { get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/ITransformProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/ITransformProvider.cs
new file mode 100644
index 00000000000..4859f2d0781
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/ITransformProvider.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("6829ddc4-4f91-4ffa-b86f-bd3e2987cb4c")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface ITransformProvider
+ {
+ void Move( double x, double y );
+ void Resize( double width, double height );
+ void Rotate( double degrees );
+ bool CanMove { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ bool CanResize { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ bool CanRotate { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IValueProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IValueProvider.cs
new file mode 100644
index 00000000000..919be647f8d
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IValueProvider.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Runtime.InteropServices;
+
+#nullable enable
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("c7935180-6fb3-4201-b174-7df73adbf64a")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IValueProvider
+ {
+ void SetValue([MarshalAs(UnmanagedType.LPWStr)] string? value);
+ string? Value { get; }
+ bool IsReadOnly { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/IWindowProvider.cs b/src/Windows/Avalonia.Win32/Interop/Automation/IWindowProvider.cs
new file mode 100644
index 00000000000..d915beb601e
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/IWindowProvider.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("fdc8f176-aed2-477a-8c89-ea04cc5f278d")]
+ public enum WindowVisualState
+ {
+ Normal,
+ Maximized,
+ Minimized
+ }
+
+ [ComVisible(true)]
+ [Guid("65101cc7-7904-408e-87a7-8c6dbd83a18b")]
+ public enum WindowInteractionState
+ {
+ Running,
+ Closing,
+ ReadyForUserInteraction,
+ BlockedByModalWindow,
+ NotResponding
+ }
+
+ [ComVisible(true)]
+ [Guid("987df77b-db06-4d77-8f8a-86a9c3bb90b9")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IWindowProvider
+ {
+ void SetVisualState(WindowVisualState state);
+ void Close();
+ [return: MarshalAs(UnmanagedType.Bool)]
+ bool WaitForInputIdle(int milliseconds);
+ bool Maximizable { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ bool Minimizable { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ bool IsModal { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ WindowVisualState VisualState { get; }
+ WindowInteractionState InteractionState { get; }
+ bool IsTopmost { [return: MarshalAs(UnmanagedType.Bool)] get; }
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/UiaCoreProviderApi.cs b/src/Windows/Avalonia.Win32/Interop/Automation/UiaCoreProviderApi.cs
new file mode 100644
index 00000000000..4ba7a710d48
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/UiaCoreProviderApi.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ [ComVisible(true)]
+ [Guid("d8e55844-7043-4edc-979d-593cc6b4775e")]
+ internal enum AsyncContentLoadedState
+ {
+ Beginning,
+ Progress,
+ Completed,
+ }
+
+ [ComVisible(true)]
+ [Guid("e4cfef41-071d-472c-a65c-c14f59ea81eb")]
+ internal enum StructureChangeType
+ {
+ ChildAdded,
+ ChildRemoved,
+ ChildrenInvalidated,
+ ChildrenBulkAdded,
+ ChildrenBulkRemoved,
+ ChildrenReordered,
+ }
+
+ internal enum UiaEventId
+ {
+ ToolTipOpened = 20000,
+ ToolTipClosed,
+ StructureChanged,
+ MenuOpened,
+ AutomationPropertyChanged,
+ AutomationFocusChanged,
+ AsyncContentLoaded,
+ MenuClosed,
+ LayoutInvalidated,
+ Invoke_Invoked,
+ SelectionItem_ElementAddedToSelection,
+ SelectionItem_ElementRemovedFromSelection,
+ SelectionItem_ElementSelected,
+ Selection_Invalidated,
+ Text_TextSelectionChanged,
+ Text_TextChanged,
+ Window_WindowOpened,
+ Window_WindowClosed,
+ MenuModeStart,
+ MenuModeEnd,
+ InputReachedTarget,
+ InputReachedOtherElement,
+ InputDiscarded,
+ SystemAlert,
+ LiveRegionChanged,
+ HostedFragmentRootsInvalidated,
+ Drag_DragStart,
+ Drag_DragCancel,
+ Drag_DragComplete,
+ DropTarget_DragEnter,
+ DropTarget_DragLeave,
+ DropTarget_Dropped,
+ TextEdit_TextChanged,
+ TextEdit_ConversionTargetChanged,
+ Changes
+ };
+
+ internal static class UiaCoreProviderApi
+ {
+ public const int UIA_E_ELEMENTNOTENABLED = unchecked((int)0x80040200);
+
+ [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)]
+ public static extern bool UiaClientsAreListening();
+
+ [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)]
+ public static extern IntPtr UiaReturnRawElementProvider(IntPtr hwnd, IntPtr wParam, IntPtr lParam, IRawElementProviderSimple el);
+
+ [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)]
+ public static extern int UiaHostProviderFromHwnd(IntPtr hwnd, [MarshalAs(UnmanagedType.Interface)] out IRawElementProviderSimple provider);
+
+ [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)]
+ public static extern int UiaRaiseAutomationEvent(IRawElementProviderSimple provider, int id);
+
+ [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)]
+ public static extern int UiaRaiseAutomationPropertyChangedEvent(IRawElementProviderSimple provider, int id, object oldValue, object newValue);
+
+ [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)]
+ public static extern int UiaRaiseStructureChangedEvent(IRawElementProviderSimple provider, StructureChangeType structureChangeType, int[] runtimeId, int runtimeIdLen);
+
+ [DllImport("UIAutomationCore.dll", CharSet = CharSet.Unicode)]
+ public static extern int UiaDisconnectProvider(IRawElementProviderSimple provider);
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/Interop/Automation/UiaCoreTypesApi.cs b/src/Windows/Avalonia.Win32/Interop/Automation/UiaCoreTypesApi.cs
new file mode 100644
index 00000000000..4375b2fde18
--- /dev/null
+++ b/src/Windows/Avalonia.Win32/Interop/Automation/UiaCoreTypesApi.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Win32.Interop.Automation
+{
+ internal static class UiaCoreTypesApi
+ {
+ private const string StartListeningExportName = "SynchronizedInputPattern_StartListening";
+
+ internal enum AutomationIdType
+ {
+ Property,
+ Pattern,
+ Event,
+ ControlType,
+ TextAttribute
+ }
+
+ internal const int UIA_E_ELEMENTNOTENABLED = unchecked((int)0x80040200);
+ internal const int UIA_E_ELEMENTNOTAVAILABLE = unchecked((int)0x80040201);
+ internal const int UIA_E_NOCLICKABLEPOINT = unchecked((int)0x80040202);
+ internal const int UIA_E_PROXYASSEMBLYNOTLOADED = unchecked((int)0x80040203);
+
+ internal static int UiaLookupId(AutomationIdType type, ref Guid guid)
+ {
+ return RawUiaLookupId( type, ref guid );
+ }
+
+ internal static object UiaGetReservedNotSupportedValue()
+ {
+ object notSupportedValue;
+ CheckError(RawUiaGetReservedNotSupportedValue(out notSupportedValue));
+ return notSupportedValue;
+ }
+
+ internal static object UiaGetReservedMixedAttributeValue()
+ {
+ object mixedAttributeValue;
+ CheckError(RawUiaGetReservedMixedAttributeValue(out mixedAttributeValue));
+ return mixedAttributeValue;
+ }
+
+ private static void CheckError(int hr)
+ {
+ if (hr >= 0)
+ {
+ return;
+ }
+
+ Marshal.ThrowExceptionForHR(hr, (IntPtr)(-1));
+ }
+
+ [DllImport("UIAutomationCore.dll", EntryPoint = "UiaLookupId", CharSet = CharSet.Unicode)]
+ private static extern int RawUiaLookupId(AutomationIdType type, ref Guid guid);
+
+ [DllImport("UIAutomationCore.dll", EntryPoint = "UiaGetReservedNotSupportedValue", CharSet = CharSet.Unicode)]
+ private static extern int RawUiaGetReservedNotSupportedValue([MarshalAs(UnmanagedType.IUnknown)] out object notSupportedValue);
+
+ [DllImport("UIAutomationCore.dll", EntryPoint = "UiaGetReservedMixedAttributeValue", CharSet = CharSet.Unicode)]
+ private static extern int RawUiaGetReservedMixedAttributeValue([MarshalAs(UnmanagedType.IUnknown)] out object mixedAttributeValue);
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs
index 88a0744e3ee..64ab15bc306 100644
--- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs
+++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs
@@ -2,12 +2,15 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Text;
+using Avalonia.Automation.Peers;
using Avalonia.Controls;
using Avalonia.Controls.Remote;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Platform;
+using Avalonia.Win32.Automation;
using Avalonia.Win32.Input;
+using Avalonia.Win32.Interop.Automation;
using static Avalonia.Win32.Interop.UnmanagedMethods;
namespace Avalonia.Win32
@@ -19,6 +22,7 @@ public partial class WindowImpl
protected virtual unsafe IntPtr AppWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
const double wheelDelta = 120.0;
+ const long UiaRootObjectId = -25;
uint timestamp = unchecked((uint)GetMessageTime());
RawInputEventArgs e = null;
var shouldTakeFocus = false;
@@ -77,6 +81,8 @@ protected virtual unsafe IntPtr AppWndProc(IntPtr hWnd, uint msg, IntPtr wParam,
case WindowsMessage.WM_DESTROY:
{
+ UiaCoreProviderApi.UiaReturnRawElementProvider(_hwnd, IntPtr.Zero, IntPtr.Zero, null);
+
//Window doesn't exist anymore
_hwnd = IntPtr.Zero;
//Remove root reference to this class, so unmanaged delegate can be collected
@@ -503,6 +509,15 @@ protected virtual unsafe IntPtr AppWndProc(IntPtr hWnd, uint msg, IntPtr wParam,
case WindowsMessage.WM_IME_ENDCOMPOSITION:
Imm32InputMethod.Current.IsComposing = false;
break;
+
+ case WindowsMessage.WM_GETOBJECT:
+ if ((long)lParam == UiaRootObjectId)
+ {
+ var peer = ControlAutomationPeer.CreatePeerForElement((Control)_owner);
+ var node = AutomationNode.GetOrCreate(peer);
+ return UiaCoreProviderApi.UiaReturnRawElementProvider(_hwnd, wParam, lParam, node);
+ }
+ break;
}
#if USE_MANAGED_DRAG
diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs
index e8a149c918b..8ccbbc12cdf 100644
--- a/src/Windows/Avalonia.Win32/WindowImpl.cs
+++ b/src/Windows/Avalonia.Win32/WindowImpl.cs
@@ -3,6 +3,7 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using Avalonia.Controls;
+using Avalonia.Automation.Peers;
using Avalonia.Controls.Platform;
using Avalonia.Input;
using Avalonia.Input.Raw;
@@ -13,6 +14,7 @@
using Avalonia.OpenGL.Surfaces;
using Avalonia.Platform;
using Avalonia.Rendering;
+using Avalonia.Win32.Automation;
using Avalonia.Win32.Input;
using Avalonia.Win32.Interop;
using Avalonia.Win32.OpenGl;
@@ -764,7 +766,7 @@ private void CreateWindow()
throw new Win32Exception();
}
- Handle = new PlatformHandle(_hwnd, PlatformConstants.WindowHandleType);
+ Handle = new WindowImplPlatformHandle(this);
_multitouch = Win32Platform.Options.EnableMultitouch ?? true;
@@ -1360,5 +1362,13 @@ public ResizeReasonScope(WindowImpl owner, PlatformResizeReason restore)
}
public ITextInputMethodImpl TextInputMethod => Imm32InputMethod.Current;
+
+ private class WindowImplPlatformHandle : IPlatformHandle
+ {
+ private readonly WindowImpl _owner;
+ public WindowImplPlatformHandle(WindowImpl owner) => _owner = owner;
+ public IntPtr Handle => _owner.Hwnd;
+ public string HandleDescriptor => PlatformConstants.WindowHandleType;
+ }
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/Automation/ControlAutomationPeerTests.cs b/tests/Avalonia.Controls.UnitTests/Automation/ControlAutomationPeerTests.cs
new file mode 100644
index 00000000000..b128e6d83a8
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/Automation/ControlAutomationPeerTests.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Linq;
+using Avalonia.Automation.Peers;
+using Avalonia.Automation.Provider;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Templates;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Avalonia.VisualTree;
+using Xunit;
+
+#nullable enable
+
+namespace Avalonia.Controls.UnitTests.Automation
+{
+ public class ControlAutomationPeerTests
+ {
+ public class Children
+ {
+ [Fact]
+ public void Creates_Children_For_Controls_In_Visual_Tree()
+ {
+ var panel = new Panel
+ {
+ Children =
+ {
+ new Border(),
+ new Border(),
+ },
+ };
+
+ var target = CreatePeer(panel);
+
+ Assert.Equal(
+ panel.GetVisualChildren(),
+ target.GetChildren().Cast().Select(x => x.Owner));
+ }
+
+ [Fact]
+ public void Creates_Children_when_Controls_Attached_To_Visual_Tree()
+ {
+ var contentControl = new ContentControl
+ {
+ Template = new FuncControlTemplate((o, ns) =>
+ new ContentPresenter
+ {
+ Name = "PART_ContentPresenter",
+ [!ContentPresenter.ContentProperty] = o[!ContentControl.ContentProperty],
+ }),
+ Content = new Border(),
+ };
+
+ var target = CreatePeer(contentControl);
+
+ Assert.Empty(target.GetChildren());
+
+ contentControl.Measure(Size.Infinity);
+
+ Assert.Equal(1, target.GetChildren().Count);
+ }
+
+ [Fact]
+ public void Updates_Children_When_VisualChildren_Added()
+ {
+ var panel = new Panel
+ {
+ Children =
+ {
+ new Border(),
+ new Border(),
+ },
+ };
+
+ var target = CreatePeer(panel);
+ var children = target.GetChildren();
+
+ Assert.Equal(2, children.Count);
+
+ panel.Children.Add(new Decorator());
+
+ children = target.GetChildren();
+ Assert.Equal(3, children.Count);
+ }
+
+ [Fact]
+ public void Updates_Children_When_VisualChildren_Removed()
+ {
+ var panel = new Panel
+ {
+ Children =
+ {
+ new Border(),
+ new Border(),
+ },
+ };
+
+ var target = CreatePeer(panel);
+ var children = target.GetChildren();
+
+ Assert.Equal(2, children.Count);
+
+ panel.Children.RemoveAt(1);
+
+ children = target.GetChildren();
+ Assert.Equal(1, children.Count);
+ }
+
+ [Fact]
+ public void Updates_Children_When_Visibility_Changes()
+ {
+ var panel = new Panel
+ {
+ Children =
+ {
+ new Border(),
+ new Border(),
+ },
+ };
+
+ var target = CreatePeer(panel);
+ var children = target.GetChildren();
+
+ Assert.Equal(2, children.Count);
+
+ panel.Children[1].IsVisible = false;
+ children = target.GetChildren();
+ Assert.Equal(1, children.Count);
+
+ panel.Children[1].IsVisible = true;
+ children = target.GetChildren();
+ Assert.Equal(2, children.Count);
+ }
+ }
+
+ public class Parent
+ {
+ [Fact]
+ public void Connects_Peer_To_Tree_When_GetParent_Called()
+ {
+ var border = new Border();
+ var tree = new Decorator
+ {
+ Child = new Decorator
+ {
+ Child = border,
+ }
+ };
+
+ // We're accessing Border directly without going via its ancestors. Because the tree
+ // is built lazily, ensure that calling GetParent causes the ancestor tree to be built.
+ var target = CreatePeer(border);
+
+ var parentPeer = Assert.IsAssignableFrom(target.GetParent());
+ Assert.Same(border.GetVisualParent(), parentPeer.Owner);
+ }
+
+ [Fact]
+ public void Parent_Updated_When_Moved_To_Separate_Visual_Tree()
+ {
+ var border = new Border();
+ var root1 = new Decorator { Child = border };
+ var root2 = new Decorator();
+ var target = CreatePeer(border);
+
+ var parentPeer = Assert.IsAssignableFrom(target.GetParent());
+ Assert.Same(root1, parentPeer.Owner);
+
+ root1.Child = null;
+
+ Assert.Null(target.GetParent());
+
+ root2.Child = border;
+
+ parentPeer = Assert.IsAssignableFrom(target.GetParent());
+ Assert.Same(root2, parentPeer.Owner);
+ }
+ }
+
+ private static AutomationPeer CreatePeer(Control control)
+ {
+ return ControlAutomationPeer.CreatePeerForElement(control);
+ }
+
+ private class TestControl : Control
+ {
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new TestAutomationPeer(this);
+ }
+ }
+
+ private class AutomationTestRoot : TestRoot
+ {
+ protected override AutomationPeer OnCreateAutomationPeer()
+ {
+ return new TestRootAutomationPeer(this);
+ }
+ }
+
+ private class TestAutomationPeer : ControlAutomationPeer
+ {
+ public TestAutomationPeer( Control owner)
+ : base(owner)
+ {
+ }
+ }
+
+ private class TestRootAutomationPeer : ControlAutomationPeer, IRootProvider
+ {
+ public TestRootAutomationPeer(Control owner)
+ : base(owner)
+ {
+ }
+
+ public ITopLevelImpl PlatformImpl => throw new System.NotImplementedException();
+ public event EventHandler? FocusChanged;
+
+ public AutomationPeer GetFocus()
+ {
+ throw new System.NotImplementedException();
+ }
+
+ public AutomationPeer GetPeerFromPoint(Point p)
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+ }
+}
diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
index 9c1822ff5cd..cac8ca885db 100644
--- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
@@ -525,6 +525,7 @@ public void Closing_Popup_Sets_Focus_On_PlacementTarget()
using (CreateServicesWithFocus())
{
var window = PreparedWindow();
+ window.Focusable = true;
var tb = new TextBox();
var p = new Popup
diff --git a/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs b/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs
index c354dbe72e8..32ba14d3373 100644
--- a/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs
+++ b/tests/Avalonia.Input.UnitTests/KeyboardDeviceTests.cs
@@ -129,5 +129,30 @@ public event EventHandler CanExecuteChanged { add { } remove { } }
public bool CanExecute(object parameter) => true;
public void Execute(object parameter) => _action();
}
+
+ [Fact]
+ public void Control_Focus_Should_Be_Set_Before_FocusedElement_Raises_PropertyChanged()
+ {
+ var target = new KeyboardDevice();
+ var focused = new Mock();
+ var root = Mock.Of();
+ var raised = 0;
+
+ target.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(target.FocusedElement))
+ {
+ focused.Verify(x => x.RaiseEvent(It.IsAny()));
+ ++raised;
+ }
+ };
+
+ target.SetFocusedElement(
+ focused.Object,
+ NavigationMethod.Unspecified,
+ KeyModifiers.None);
+
+ Assert.Equal(1, raised);
+ }
}
}
diff --git a/tests/Avalonia.IntegrationTests.Appium/AutomationTests.cs b/tests/Avalonia.IntegrationTests.Appium/AutomationTests.cs
new file mode 100644
index 00000000000..bad015506f9
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/AutomationTests.cs
@@ -0,0 +1,39 @@
+using OpenQA.Selenium.Appium;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [Collection("Default")]
+ public class AutomationTests
+ {
+ private readonly AppiumDriver _session;
+
+ public AutomationTests(TestAppFixture fixture)
+ {
+ _session = fixture.Session;
+
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var tab = tabs.FindElementByName("Automation");
+ tab.Click();
+ }
+
+ [Fact]
+ public void AutomationId()
+ {
+ // AutomationID can be specified by the Name or AutomationProperties.AutomationId
+ // properties, with the latter taking precedence.
+ var byName = _session.FindElementByAccessibilityId("TextBlockWithName");
+ var byAutomationId = _session.FindElementByAccessibilityId("TextBlockWithNameAndAutomationId");
+ }
+
+ [Fact]
+ public void LabeledBy()
+ {
+ var label = _session.FindElementByAccessibilityId("TextBlockAsLabel");
+ var labeledTextBox = _session.FindElementByAccessibilityId("LabeledByTextBox");
+
+ Assert.Equal("Label for TextBox", label.Text);
+ Assert.Equal("Label for TextBox", labeledTextBox.GetName());
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/Avalonia.IntegrationTests.Appium.csproj b/tests/Avalonia.IntegrationTests.Appium/Avalonia.IntegrationTests.Appium.csproj
new file mode 100644
index 00000000000..095f0e63e07
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/Avalonia.IntegrationTests.Appium.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net6.0
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/tests/Avalonia.IntegrationTests.Appium/ButtonTests.cs b/tests/Avalonia.IntegrationTests.Appium/ButtonTests.cs
new file mode 100644
index 00000000000..2ac859e091f
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/ButtonTests.cs
@@ -0,0 +1,55 @@
+using System.Runtime.InteropServices;
+using OpenQA.Selenium.Appium;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [Collection("Default")]
+ public class ButtonTests
+ {
+ private readonly AppiumDriver _session;
+
+ public ButtonTests(TestAppFixture fixture)
+ {
+ _session = fixture.Session;
+
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var tab = tabs.FindElementByName("Button");
+ tab.Click();
+ }
+
+ [Fact]
+ public void DisabledButton()
+ {
+ var button = _session.FindElementByAccessibilityId("DisabledButton");
+
+ Assert.Equal("Disabled Button", button.Text);
+ Assert.False(button.Enabled);
+ }
+
+ [Fact]
+ public void BasicButton()
+ {
+ var button = _session.FindElementByAccessibilityId("BasicButton");
+
+ Assert.Equal("Basic Button", button.Text);
+ Assert.True(button.Enabled);
+ }
+
+ [Fact]
+ public void ButtonWithTextBlock()
+ {
+ var button = _session.FindElementByAccessibilityId("ButtonWithTextBlock");
+
+ Assert.Equal("Button with TextBlock", button.Text);
+ }
+
+ [PlatformFact(SkipOnOSX = true)]
+ public void ButtonWithAcceleratorKey()
+ {
+ var button = _session.FindElementByAccessibilityId("ButtonWithAcceleratorKey");
+
+ Assert.Equal("Ctrl+B", button.GetAttribute("AcceleratorKey"));
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/CheckBoxTests.cs b/tests/Avalonia.IntegrationTests.Appium/CheckBoxTests.cs
new file mode 100644
index 00000000000..02e7ac60c41
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/CheckBoxTests.cs
@@ -0,0 +1,62 @@
+using OpenQA.Selenium.Appium;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [Collection("Default")]
+ public class CheckBoxTests
+ {
+ private readonly AppiumDriver _session;
+
+ public CheckBoxTests(TestAppFixture fixture)
+ {
+ _session = fixture.Session;
+
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var tab = tabs.FindElementByName("CheckBox");
+ tab.Click();
+ }
+
+ [Fact]
+ public void UncheckedCheckBox()
+ {
+ var checkBox = _session.FindElementByAccessibilityId("UncheckedCheckBox");
+
+ Assert.Equal("Unchecked", checkBox.GetName());
+ Assert.Equal(false, checkBox.GetIsChecked());
+
+ checkBox.Click();
+ Assert.Equal(true, checkBox.GetIsChecked());
+ }
+
+ [Fact]
+ public void CheckedCheckBox()
+ {
+ var checkBox = _session.FindElementByAccessibilityId("CheckedCheckBox");
+
+ Assert.Equal("Checked", checkBox.GetName());
+ Assert.Equal(true, checkBox.GetIsChecked());
+
+ checkBox.Click();
+ Assert.Equal(false, checkBox.GetIsChecked());
+ }
+
+ [Fact]
+ public void ThreeStateCheckBox()
+ {
+ var checkBox = _session.FindElementByAccessibilityId("ThreeStateCheckBox");
+
+ Assert.Equal("ThreeState", checkBox.GetName());
+ Assert.Null(checkBox.GetIsChecked());
+
+ checkBox.Click();
+ Assert.Equal(false, checkBox.GetIsChecked());
+
+ checkBox.Click();
+ Assert.Equal(true, checkBox.GetIsChecked());
+
+ checkBox.Click();
+ Assert.Null(checkBox.GetIsChecked());
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/ComboBoxTests.cs b/tests/Avalonia.IntegrationTests.Appium/ComboBoxTests.cs
new file mode 100644
index 00000000000..fad3e1eb9f2
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/ComboBoxTests.cs
@@ -0,0 +1,100 @@
+using OpenQA.Selenium;
+using OpenQA.Selenium.Appium;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [Collection("Default")]
+ public class ComboBoxTests
+ {
+ private readonly AppiumDriver _session;
+
+ public ComboBoxTests(TestAppFixture fixture)
+ {
+ _session = fixture.Session;
+
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var tab = tabs.FindElementByName("ComboBox");
+ tab.Click();
+ }
+
+ [Fact]
+ public void Can_Change_Selection_Using_Mouse()
+ {
+ var comboBox = _session.FindElementByAccessibilityId("BasicComboBox");
+
+ _session.FindElementByAccessibilityId("ComboBoxSelectFirst").Click();
+ Assert.Equal("Item 0", comboBox.GetComboBoxValue());
+
+ comboBox.Click();
+ _session.FindElementByName("Item 1").SendClick();
+
+ Assert.Equal("Item 1", comboBox.GetComboBoxValue());
+ }
+
+ [Fact]
+ public void Can_Change_Selection_From_Unselected_Using_Mouse()
+ {
+ var comboBox = _session.FindElementByAccessibilityId("BasicComboBox");
+
+ _session.FindElementByAccessibilityId("ComboBoxSelectionClear").Click();
+ Assert.Equal(string.Empty, comboBox.GetComboBoxValue());
+
+ comboBox.Click();
+ _session.FindElementByName("Item 0").SendClick();
+
+ Assert.Equal("Item 0", comboBox.GetComboBoxValue());
+ }
+
+ [PlatformFact(SkipOnOSX = true)]
+ public void Can_Change_Selection_With_Keyboard()
+ {
+ var comboBox = _session.FindElementByAccessibilityId("BasicComboBox");
+
+ _session.FindElementByAccessibilityId("ComboBoxSelectFirst").Click();
+ Assert.Equal("Item 0", comboBox.GetComboBoxValue());
+
+ comboBox.SendKeys(Keys.LeftAlt + Keys.ArrowDown);
+ comboBox.SendKeys(Keys.ArrowDown);
+
+ var item = _session.FindElementByName("Item 1");
+ item.SendKeys(Keys.Enter);
+
+ Assert.Equal("Item 1", comboBox.GetComboBoxValue());
+ }
+
+ [PlatformFact(SkipOnOSX = true)]
+ public void Can_Change_Selection_With_Keyboard_From_Unselected()
+ {
+ var comboBox = _session.FindElementByAccessibilityId("BasicComboBox");
+
+ _session.FindElementByAccessibilityId("ComboBoxSelectionClear").Click();
+ Assert.Equal(string.Empty, comboBox.GetComboBoxValue());
+
+ comboBox.SendKeys(Keys.LeftAlt + Keys.ArrowDown);
+ comboBox.SendKeys(Keys.ArrowDown);
+
+ var item = _session.FindElementByName("Item 0");
+ item.SendKeys(Keys.Enter);
+
+ Assert.Equal("Item 0", comboBox.GetComboBoxValue());
+ }
+
+ [PlatformFact(SkipOnOSX = true)]
+ public void Can_Cancel_Keyboard_Selection_With_Escape()
+ {
+ var comboBox = _session.FindElementByAccessibilityId("BasicComboBox");
+
+ _session.FindElementByAccessibilityId("ComboBoxSelectionClear").Click();
+ Assert.Equal(string.Empty, comboBox.GetComboBoxValue());
+
+ comboBox.SendKeys(Keys.LeftAlt + Keys.ArrowDown);
+ comboBox.SendKeys(Keys.ArrowDown);
+
+ var item = _session.FindElementByName("Item 0");
+ item.SendKeys(Keys.Escape);
+
+ Assert.Equal(string.Empty, comboBox.GetComboBoxValue());
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/DefaultCollection.cs b/tests/Avalonia.IntegrationTests.Appium/DefaultCollection.cs
new file mode 100644
index 00000000000..bb2dd1fbecb
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/DefaultCollection.cs
@@ -0,0 +1,9 @@
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [CollectionDefinition("Default")]
+ public class DefaultCollection : ICollectionFixture
+ {
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs b/tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs
new file mode 100644
index 00000000000..15e22f44241
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using OpenQA.Selenium.Appium;
+using OpenQA.Selenium.Interactions;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ internal static class ElementExtensions
+ {
+ public static IReadOnlyList GetChildren(this AppiumWebElement element) =>
+ element.FindElementsByXPath("*/*");
+
+ public static string GetComboBoxValue(this AppiumWebElement element)
+ {
+ return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
+ element.Text :
+ element.GetAttribute("value");
+ }
+
+ public static string GetName(this AppiumWebElement element) => GetAttribute(element, "Name", "title");
+
+ public static bool? GetIsChecked(this AppiumWebElement element) =>
+ GetAttribute(element, "Toggle.ToggleState", "value") switch
+ {
+ "0" => false,
+ "1" => true,
+ "2" => null,
+ _ => throw new ArgumentOutOfRangeException($"Unexpected IsChecked value.")
+ };
+
+ public static void SendClick(this AppiumWebElement element)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ element.Click();
+ }
+ else
+ {
+ // The Click() method seems to correspond to accessibilityPerformPress on macOS but certain controls
+ // such as list items don't support this action, so instead simulate a physical click as VoiceOver
+ // does.
+ new Actions(element.WrappedDriver).MoveToElement(element).Click().Perform();
+ }
+ }
+
+ public static string GetAttribute(AppiumWebElement element, string windows, string macOS)
+ {
+ return element.GetAttribute(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? windows : macOS);
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/ListBoxTests.cs b/tests/Avalonia.IntegrationTests.Appium/ListBoxTests.cs
new file mode 100644
index 00000000000..625742ac202
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/ListBoxTests.cs
@@ -0,0 +1,103 @@
+using System.Threading;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Appium;
+using OpenQA.Selenium.Interactions;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [Collection("Default")]
+ public class ListBoxTests
+ {
+ private readonly AppiumDriver _session;
+
+ public ListBoxTests(TestAppFixture fixture)
+ {
+ _session = fixture.Session;
+
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var tab = tabs.FindElementByName("ListBox");
+ tab.Click();
+ }
+
+ [Fact]
+ public void Can_Select_Item_By_Clicking()
+ {
+ var listBox = GetTarget();
+ var item2 = listBox.FindElementByName("Item 2");
+ var item4 = listBox.FindElementByName("Item 4");
+
+ Assert.False(item2.Selected);
+ Assert.False(item4.Selected);
+
+ item2.SendClick();
+ Assert.True(item2.Selected);
+ Assert.False(item4.Selected);
+
+ item4.SendClick();
+ Assert.False(item2.Selected);
+ Assert.True(item4.Selected);
+ }
+
+ [Fact(Skip = "WinAppDriver seems unable to consistently send a Ctrl key and appium-mac2-driver just hangs")]
+ public void Can_Select_Items_By_Ctrl_Clicking()
+ {
+ var listBox = GetTarget();
+ var item2 = listBox.FindElementByName("Item 2");
+ var item4 = listBox.FindElementByName("Item 4");
+
+ Assert.False(item2.Selected);
+ Assert.False(item4.Selected);
+
+ new Actions(_session)
+ .Click(item2)
+ .KeyDown(Keys.Control)
+ .Click(item4)
+ .KeyUp(Keys.Control)
+ .Perform();
+
+ Assert.True(item2.Selected);
+ Assert.True(item4.Selected);
+ }
+
+ // appium-mac2-driver just hangs
+ [PlatformFact(SkipOnOSX = true)]
+ public void Can_Select_Range_By_Shift_Clicking()
+ {
+ var listBox = GetTarget();
+ var item2 = listBox.FindElementByName("Item 2");
+ var item3 = listBox.FindElementByName("Item 3");
+ var item4 = listBox.FindElementByName("Item 4");
+
+ Assert.False(item2.Selected);
+ Assert.False(item3.Selected);
+ Assert.False(item4.Selected);
+
+ new Actions(_session)
+ .Click(item2)
+ .KeyDown(Keys.Shift)
+ .Click(item4)
+ .KeyUp(Keys.Shift)
+ .Perform();
+
+ Assert.True(item2.Selected);
+ Assert.True(item3.Selected);
+ Assert.True(item4.Selected);
+ }
+
+ [Fact]
+ public void Is_Virtualized()
+ {
+ var listBox = GetTarget();
+ var children = listBox.GetChildren();
+
+ Assert.True(children.Count < 100);
+ }
+
+ private AppiumWebElement GetTarget()
+ {
+ _session.FindElementByAccessibilityId("ListBoxSelectionClear").Click();
+ return _session.FindElementByAccessibilityId("BasicListBox");
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/MenuTests.cs b/tests/Avalonia.IntegrationTests.Appium/MenuTests.cs
new file mode 100644
index 00000000000..e9a433b9754
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/MenuTests.cs
@@ -0,0 +1,63 @@
+using OpenQA.Selenium.Appium;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [Collection("Default")]
+ public class MenuTests
+ {
+ private readonly AppiumDriver _session;
+
+ public MenuTests(TestAppFixture fixture)
+ {
+ _session = fixture.Session;
+
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var tab = tabs.FindElementByName("Menu");
+ tab.Click();
+ }
+
+ [Fact]
+ public void Click_Child()
+ {
+ var rootMenuItem = _session.FindElementByAccessibilityId("RootMenuItem");
+
+ rootMenuItem.SendClick();
+
+ var childMenuItem = _session.FindElementByAccessibilityId("Child1MenuItem");
+ childMenuItem.SendClick();
+
+ var clickedMenuItem = _session.FindElementByAccessibilityId("ClickedMenuItem");
+ Assert.Equal("_Child 1", clickedMenuItem.Text);
+ }
+
+ [Fact]
+ public void Click_Grandchild()
+ {
+ var rootMenuItem = _session.FindElementByAccessibilityId("RootMenuItem");
+
+ rootMenuItem.SendClick();
+
+ var childMenuItem = _session.FindElementByAccessibilityId("Child2MenuItem");
+ childMenuItem.SendClick();
+
+ var grandchildMenuItem = _session.FindElementByAccessibilityId("GrandchildMenuItem");
+ grandchildMenuItem.SendClick();
+
+ var clickedMenuItem = _session.FindElementByAccessibilityId("ClickedMenuItem");
+ Assert.Equal("_Grandchild", clickedMenuItem.Text);
+ }
+
+ [PlatformFact(SkipOnOSX = true)]
+ public void Child_AcceleratorKey()
+ {
+ var rootMenuItem = _session.FindElementByAccessibilityId("RootMenuItem");
+
+ rootMenuItem.SendClick();
+
+ var childMenuItem = _session.FindElementByAccessibilityId("Child1MenuItem");
+
+ Assert.Equal("Ctrl+O", childMenuItem.GetAttribute("AcceleratorKey"));
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/NativeMenuTests.cs b/tests/Avalonia.IntegrationTests.Appium/NativeMenuTests.cs
new file mode 100644
index 00000000000..fde01f0e414
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/NativeMenuTests.cs
@@ -0,0 +1,37 @@
+using OpenQA.Selenium.Appium;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ [Collection("Default")]
+ public class NativeMenuTests
+ {
+ private readonly AppiumDriver _session;
+
+ public NativeMenuTests(TestAppFixture fixture)
+ {
+ _session = fixture.Session;
+
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var tab = tabs.FindElementByName("Automation");
+ tab.Click();
+ }
+
+ [PlatformFact(SkipOnWindows = true)]
+ public void View_Menu_Select_Button_Tab()
+ {
+ var tabs = _session.FindElementByAccessibilityId("MainTabs");
+ var buttonTab = tabs.FindElementByName("Button");
+ var menuBar = _session.FindElementByXPath("/XCUIElementTypeApplication/XCUIElementTypeMenuBar");
+ var viewMenu = menuBar.FindElementByName("View");
+
+ Assert.False(buttonTab.Selected);
+
+ viewMenu.Click();
+ var buttonMenu = viewMenu.FindElementByName("Button");
+ buttonMenu.Click();
+
+ Assert.True(buttonTab.Selected);
+ }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/PlatformFactAttribute.cs b/tests/Avalonia.IntegrationTests.Appium/PlatformFactAttribute.cs
new file mode 100644
index 00000000000..60338b92c23
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/PlatformFactAttribute.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Linq;
+using System.Runtime.InteropServices;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ internal class PlatformFactAttribute : FactAttribute
+ {
+ public override string? Skip
+ {
+ get
+ {
+ if (SkipOnWindows && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return "Ignored on Windows";
+ if (SkipOnOSX && RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ return "Ignored on MacOS";
+ return null;
+ }
+ set => throw new NotSupportedException();
+ }
+ public bool SkipOnOSX { get; set; }
+ public bool SkipOnWindows { get; set; }
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/Properties/AssemblyInfo.cs b/tests/Avalonia.IntegrationTests.Appium/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000000..f9248a31524
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using Xunit;
+
+// Don't run tests in parallel.
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/tests/Avalonia.IntegrationTests.Appium/TestAppFixture.cs b/tests/Avalonia.IntegrationTests.Appium/TestAppFixture.cs
new file mode 100644
index 00000000000..b3385d8ee73
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/TestAppFixture.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using OpenQA.Selenium.Appium;
+using OpenQA.Selenium.Appium.Enums;
+using OpenQA.Selenium.Appium.Mac;
+using OpenQA.Selenium.Appium.Windows;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+ public class TestAppFixture : IDisposable
+ {
+ private const string TestAppPath = @"..\..\..\..\..\samples\IntegrationTestApp\bin\Debug\net6.0\IntegrationTestApp.exe";
+ private const string TestAppBundleId = "net.avaloniaui.avalonia.integrationtestapp";
+
+ public TestAppFixture()
+ {
+ var opts = new AppiumOptions();
+ var path = Path.GetFullPath(TestAppPath);
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ opts.AddAdditionalCapability(MobileCapabilityType.App, path);
+ opts.AddAdditionalCapability(MobileCapabilityType.PlatformName, MobilePlatform.Windows);
+ opts.AddAdditionalCapability(MobileCapabilityType.DeviceName, "WindowsPC");
+
+ Session = new WindowsDriver(
+ new Uri("http://127.0.0.1:4723"),
+ opts);
+
+ // https://github.com/microsoft/WinAppDriver/issues/1025
+ SetForegroundWindow(new IntPtr(int.Parse(
+ Session.WindowHandles[0].Substring(2),
+ NumberStyles.AllowHexSpecifier)));
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ opts.AddAdditionalCapability("appium:bundleId", TestAppBundleId);
+ opts.AddAdditionalCapability(MobileCapabilityType.PlatformName, MobilePlatform.MacOS);
+ opts.AddAdditionalCapability(MobileCapabilityType.AutomationName, "mac2");
+ opts.AddAdditionalCapability("appium:showServerLogs", true);
+
+ Session = new MacDriver(
+ new Uri("http://127.0.0.1:4723/wd/hub"),
+ opts);
+ }
+ else
+ {
+ throw new NotSupportedException("Unsupported platform.");
+ }
+ }
+
+ public AppiumDriver Session { get; }
+
+ public void Dispose()
+ {
+ try
+ {
+ Session.Close();
+ }
+ catch
+ {
+ // Closing the session currently seems to crash the mac2 driver.
+ }
+ }
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetForegroundWindow(IntPtr hWnd);
+ }
+}
diff --git a/tests/Avalonia.IntegrationTests.Appium/readme.md b/tests/Avalonia.IntegrationTests.Appium/readme.md
new file mode 100644
index 00000000000..2a8c3068baa
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/readme.md
@@ -0,0 +1,30 @@
+# Running Integration Tests
+
+## Windows
+
+### Prerequisites
+
+- Install WinAppDriver: https://github.com/microsoft/WinAppDriver
+
+### Running
+
+- Run WinAppDriver (it gets installed to the start menu)
+- Run the tests in this project
+
+## MacOS
+
+### Prerequisites
+
+- Install Appium: https://appium.io/
+- Give [XCode helper the required permissions](https://apple.stackexchange.com/questions/334008)
+- `cd samples/IntegrationTestApp` then `./bundle.sh` to create an app bundle for `IntegrationTestApp`
+- Register the app bundle by running `open -n ./bin/Debug/net6.0/osx-arm64/publish/IntegrationTestApp.app`
+
+### Running
+
+- Run `appium`
+- Run the tests in this project
+
+Each time you make a change to Avalonia or `IntegrationTestApp`, re-run the `bundle.sh` script (registration only needs to be done once).
+
+
diff --git a/tests/Avalonia.IntegrationTests.Appium/xunit.runner.json b/tests/Avalonia.IntegrationTests.Appium/xunit.runner.json
new file mode 100644
index 00000000000..f78bc2f0c65
--- /dev/null
+++ b/tests/Avalonia.IntegrationTests.Appium/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}
\ No newline at end of file
diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs
index 317a1c50d61..461e0f43926 100644
--- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs
+++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs
@@ -89,7 +89,12 @@ public static Mock CreatePopupMock(IWindowBaseImpl parent)
popupImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize);
popupImpl.Setup(x => x.RenderScaling).Returns(1);
popupImpl.Setup(x => x.PopupPositioner).Returns(positioner);
-
+
+ popupImpl.Setup(x => x.Dispose()).Callback(() =>
+ {
+ popupImpl.Object.Closed?.Invoke();
+ });
+
SetupToplevel(popupImpl);
return popupImpl;