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(AccessibilityView.Control); + AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.Menu); } /// diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 185b834052f..34215e97131 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reactive.Linq; using System.Windows.Input; +using Avalonia.Automation.Peers; using Avalonia.Controls.Generators; using Avalonia.Controls.Metadata; using Avalonia.Controls.Mixins; @@ -494,6 +495,11 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) } } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new MenuItemAutomationPeer(this); + } + protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value) { base.UpdateDataValidation(property, value); diff --git a/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs b/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs index ff83e007b45..066f4579c0b 100644 --- a/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs +++ b/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Automation.Peers; namespace Avalonia.Platform { diff --git a/src/Avalonia.Controls/Primitives/AccessText.cs b/src/Avalonia.Controls/Primitives/AccessText.cs index 1e0f26907dc..87cf660cadb 100644 --- a/src/Avalonia.Controls/Primitives/AccessText.cs +++ b/src/Avalonia.Controls/Primitives/AccessText.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using Avalonia.Automation.Peers; using Avalonia.Input; using Avalonia.Media; using Avalonia.Media.TextFormatting; @@ -80,7 +81,7 @@ public override void Render(DrawingContext context) /// protected override TextLayout CreateTextLayout(Size constraint, string? text) { - return base.CreateTextLayout(constraint, StripAccessKey(text)); + return base.CreateTextLayout(constraint, RemoveAccessKeyMarker(text)); } /// @@ -107,29 +108,40 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e } } - /// - /// Returns a string with the first underscore stripped. - /// - /// The text. - /// The text with the first underscore stripped. - [return: NotNullIfNotNull("text")] - private string? StripAccessKey(string? text) + protected override AutomationPeer OnCreateAutomationPeer() { - if (text is null) - { - return null; - } - - var position = text.IndexOf('_'); + return new NoneAutomationPeer(this); + } - if (position == -1) + internal static string? RemoveAccessKeyMarker(string? text) + { + if (!string.IsNullOrEmpty(text)) { - return text; + var accessKeyMarker = "_"; + var doubleAccessKeyMarker = accessKeyMarker + accessKeyMarker; + int index = FindAccessKeyMarker(text); + if (index >= 0 && index < text.Length - 1) + text = text.Remove(index, 1); + text = text.Replace(doubleAccessKeyMarker, accessKeyMarker); } - else + return text; + } + + private static int FindAccessKeyMarker(string text) + { + var length = text.Length; + var startIndex = 0; + while (startIndex < length) { - return text.Substring(0, position) + text.Substring(position + 1); + int index = text.IndexOf('_', startIndex); + if (index == -1) + return -1; + if (index + 1 < length && text[index + 1] != '_') + return index; + startIndex = index + 2; } + + return -1; } /// diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index f3fec95bcc0..bb546107e05 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Linq; using System.Reactive.Disposables; +using Avalonia.Automation.Peers; using Avalonia.Controls.Mixins; using Avalonia.Controls.Diagnostics; using Avalonia.Controls.Presenters; @@ -560,6 +561,11 @@ private void HandlePositionChange() } } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new PopupAutomationPeer(this); + } + private static IDisposable SubscribeToEventHandler(T target, TEventHandler handler, Action subscribe, Action unsubscribe) { subscribe(target, handler); @@ -651,7 +657,17 @@ private void CloseCore() { if (PlacementTarget != null) { - FocusManager.Instance?.Focus(PlacementTarget); + var e = (IControl?)PlacementTarget; + + while (e is object && (!e.Focusable || !e.IsEffectivelyEnabled || !e.IsVisible)) + { + e = e.Parent; + } + + if (e is object) + { + FocusManager.Instance?.Focus(e); + } } else { diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index a9a362a7622..2f6df862cfe 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reactive.Disposables; +using Avalonia.Automation.Peers; using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Interactivity; using Avalonia.Media; @@ -168,5 +169,10 @@ protected override sealed Size ArrangeSetBounds(Size size) UpdatePosition(); return ClientSize; } + + protected override AutomationPeer OnCreateAutomationPeer() + { + return new PopupRootAutomationPeer(this); + } } } diff --git a/src/Avalonia.Controls/Primitives/ToggleButton.cs b/src/Avalonia.Controls/Primitives/ToggleButton.cs index 4bdf6db2fcb..148797c53a5 100644 --- a/src/Avalonia.Controls/Primitives/ToggleButton.cs +++ b/src/Avalonia.Controls/Primitives/ToggleButton.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Automation.Peers; using Avalonia.Controls.Metadata; using Avalonia.Data; using Avalonia.Interactivity; @@ -169,6 +170,11 @@ protected virtual void OnIndeterminate(RoutedEventArgs e) RaiseEvent(e); } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new ToggleButtonAutomationPeer(this); + } + private void OnIsCheckedChanged(AvaloniaPropertyChangedEventArgs e) { var newValue = (bool?)e.NewValue; diff --git a/src/Avalonia.Controls/Properties/AssemblyInfo.cs b/src/Avalonia.Controls/Properties/AssemblyInfo.cs index 302a2bbc133..25330614cfa 100644 --- a/src/Avalonia.Controls/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Controls/Properties/AssemblyInfo.cs @@ -6,6 +6,7 @@ [assembly: InternalsVisibleTo("Avalonia.LeakTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Automation")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls.Embedding")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls.Presenters")] diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index 6ca6c1e67b2..73bb827ad9f 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -1,5 +1,6 @@ using System; using System.Reactive.Linq; +using Avalonia.Automation.Peers; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Input; @@ -762,6 +763,11 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) _scrollBarExpandSubscription = SubscribeToScrollBars(e); } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new ScrollViewerAutomationPeer(this); + } + private IDisposable? SubscribeToScrollBars(TemplateAppliedEventArgs e) { static IObservable? GetExpandedObservable(ScrollBar? scrollBar) diff --git a/src/Avalonia.Controls/Slider.cs b/src/Avalonia.Controls/Slider.cs index 87de04f92f5..574638c2bc5 100644 --- a/src/Avalonia.Controls/Slider.cs +++ b/src/Avalonia.Controls/Slider.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Collections; +using Avalonia.Automation.Peers; using Avalonia.Controls.Metadata; using Avalonia.Controls.Mixins; using Avalonia.Controls.Primitives; @@ -8,6 +9,7 @@ using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Utilities; +using Avalonia.Automation; namespace Avalonia.Controls { @@ -105,6 +107,7 @@ static Slider() RoutingStrategies.Bubble); ValueProperty.OverrideMetadata(new DirectPropertyMetadata(enableDataValidation: true)); + AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.Slider); } /// diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index e0ec23b31f7..1fbe9cc4db9 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Linq; using Avalonia.Collections; +using Avalonia.Automation.Peers; using Avalonia.Controls.Generators; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; @@ -9,6 +10,7 @@ using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.VisualTree; +using Avalonia.Automation; namespace Avalonia.Controls { @@ -68,6 +70,7 @@ static TabControl() ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); AffectsMeasure(TabStripPlacementProperty); SelectedItemProperty.Changed.AddClassHandler((x, e) => x.UpdateSelectedContent()); + AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.Tab); } /// diff --git a/src/Avalonia.Controls/TabItem.cs b/src/Avalonia.Controls/TabItem.cs index 593643a1eb1..f68db4743bb 100644 --- a/src/Avalonia.Controls/TabItem.cs +++ b/src/Avalonia.Controls/TabItem.cs @@ -1,3 +1,5 @@ +using Avalonia.Automation; +using Avalonia.Automation.Peers; using Avalonia.Controls.Metadata; using Avalonia.Controls.Mixins; using Avalonia.Controls.Primitives; @@ -31,6 +33,7 @@ static TabItem() PressedMixin.Attach(); FocusableProperty.OverrideDefaultValue(typeof(TabItem), true); DataContextProperty.Changed.AddClassHandler((x, e) => x.UpdateHeader(e)); + AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.TabItem); } /// @@ -53,6 +56,8 @@ public bool IsSelected set { SetValue(IsSelectedProperty, value); } } + protected override AutomationPeer OnCreateAutomationPeer() => new ListItemAutomationPeer(this); + private void UpdateHeader(AvaloniaPropertyChangedEventArgs obj) { if (Header == null) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 9e50a760fb5..9395896fbc6 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -1,13 +1,12 @@ using System; using System.Collections.Generic; -using System.Reactive.Linq; using System.Text; +using Avalonia.Automation.Peers; using Avalonia.Controls.Documents; -using Avalonia.LogicalTree; +using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; -using Avalonia.Layout; using Avalonia.Utilities; namespace Avalonia.Controls @@ -572,6 +571,11 @@ protected override Size ArrangeOverride(Size finalSize) return finalSize; } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new TextBlockAutomationPeer(this); + } + private static bool IsValidMaxLines(int maxLines) => maxLines >= 0; private static bool IsValidLineHeight(double lineHeight) => double.IsNaN(lineHeight) || lineHeight > 0; diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 69e9ca5b921..6ff89cd09b5 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -16,6 +16,7 @@ using Avalonia.Controls.Metadata; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Automation.Peers; namespace Avalonia.Controls { @@ -1189,6 +1190,11 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) e.Pointer.Capture(null); } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new TextBoxAutomationPeer(this); + } + protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value) { if (property == TextProperty) diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index e138a05c7d7..4ea87cdc784 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reactive.Linq; using System.Threading.Tasks; +using Avalonia.Automation.Peers; using Avalonia.Controls.Platform; using Avalonia.Input; using Avalonia.Interactivity; @@ -1016,5 +1017,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs } } } + + protected override AutomationPeer OnCreateAutomationPeer() + { + return new WindowAutomationPeer(this); + } } } diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index cebdd8d8976..d3c940c07c3 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; +using Avalonia.Automation.Peers; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Layout; diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs index 5cae29cafd5..12af602f544 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs @@ -1,5 +1,4 @@ using System; -using System.Reactive.Disposables; using Avalonia.Controls; using Avalonia.Controls.Remote.Server; using Avalonia.Input; diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index 9dcd4d8e873..8fb2c456b2b 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/src/Avalonia.DesignerSupport/Remote/Stubs.cs @@ -3,6 +3,7 @@ using System.IO; using System.Reactive.Disposables; using System.Threading.Tasks; +using Avalonia.Automation.Peers; using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives.PopupPositioning; diff --git a/src/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Avalonia.Headless/HeadlessWindowImpl.cs index 1b582d5775a..c976921bc38 100644 --- a/src/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Avalonia.Headless/HeadlessWindowImpl.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using Avalonia.Automation.Peers; using Avalonia.Controls; using Avalonia.Controls.Platform.Surfaces; using Avalonia.Controls.Primitives.PopupPositioning; diff --git a/src/Avalonia.Input/KeyboardDevice.cs b/src/Avalonia.Input/KeyboardDevice.cs index 438e3fcadb3..3df717b8c45 100644 --- a/src/Avalonia.Input/KeyboardDevice.cs +++ b/src/Avalonia.Input/KeyboardDevice.cs @@ -24,30 +24,7 @@ public class KeyboardDevice : IKeyboardDevice, INotifyPropertyChanged // the source of truth about the input focus is in KeyboardDevice private readonly TextInputMethodManager _textInputManager = new TextInputMethodManager(); - public IInputElement? FocusedElement - { - get - { - return _focusedElement; - } - - private set - { - _focusedElement = value; - - if (_focusedElement != null && _focusedElement.IsAttachedToVisualTree) - { - _focusedRoot = _focusedElement.VisualRoot as IInputRoot; - } - else - { - _focusedRoot = null; - } - - RaisePropertyChanged(); - _textInputManager.SetFocusedElement(value); - } - } + public IInputElement? FocusedElement => _focusedElement; private void ClearFocusWithinAncestors(IInputElement? element) { @@ -162,8 +139,8 @@ public void SetFocusedElement( } SetIsFocusWithin(FocusedElement, element); - - FocusedElement = element; + _focusedElement = element; + _focusedRoot = _focusedElement?.VisualRoot as IInputRoot; interactive?.RaiseEvent(new RoutedEventArgs { @@ -178,6 +155,9 @@ public void SetFocusedElement( NavigationMethod = method, KeyModifiers = keyModifiers, }); + + _textInputManager.SetFocusedElement(element); + RaisePropertyChanged(nameof(FocusedElement)); } } diff --git a/src/Avalonia.Native/Avalonia.Native.csproj b/src/Avalonia.Native/Avalonia.Native.csproj index 9301fd4d91e..70ab38e7861 100644 --- a/src/Avalonia.Native/Avalonia.Native.csproj +++ b/src/Avalonia.Native/Avalonia.Native.csproj @@ -17,6 +17,10 @@ PreserveNewest + + + + diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs new file mode 100644 index 00000000000..6c4e96b31be --- /dev/null +++ b/src/Avalonia.Native/AvnAutomationPeer.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Controls; +using Avalonia.Native.Interop; + +#nullable enable + +namespace Avalonia.Native +{ + internal class AvnAutomationPeer : NativeCallbackBase, IAvnAutomationPeer + { + private static readonly ConditionalWeakTable s_wrappers = new(); + private readonly AutomationPeer _inner; + + private AvnAutomationPeer(AutomationPeer inner) + { + _inner = inner; + _inner.ChildrenChanged += (_, _) => Node?.ChildrenChanged(); + if (inner is WindowBaseAutomationPeer window) + window.FocusChanged += (_, _) => Node?.FocusChanged(); + } + + ~AvnAutomationPeer() => Node?.Dispose(); + + public IAvnAutomationNode? Node { get; private set; } + public IAvnString? AcceleratorKey => _inner.GetAcceleratorKey().ToAvnString(); + public IAvnString? AccessKey => _inner.GetAccessKey().ToAvnString(); + public AvnAutomationControlType AutomationControlType => (AvnAutomationControlType)_inner.GetAutomationControlType(); + public IAvnString? AutomationId => _inner.GetAutomationId().ToAvnString(); + public AvnRect BoundingRectangle => _inner.GetBoundingRectangle().ToAvnRect(); + public IAvnAutomationPeerArray Children => new AvnAutomationPeerArray(_inner.GetChildren()); + public IAvnString ClassName => _inner.GetClassName().ToAvnString(); + public IAvnAutomationPeer? LabeledBy => Wrap(_inner.GetLabeledBy()); + public IAvnString Name => _inner.GetName().ToAvnString(); + public IAvnAutomationPeer? Parent => Wrap(_inner.GetParent()); + + public int HasKeyboardFocus() => _inner.HasKeyboardFocus().AsComBool(); + public int IsContentElement() => _inner.IsContentElement().AsComBool(); + public int IsControlElement() => _inner.IsControlElement().AsComBool(); + public int IsEnabled() => _inner.IsEnabled().AsComBool(); + public int IsKeyboardFocusable() => _inner.IsKeyboardFocusable().AsComBool(); + public void SetFocus() => _inner.SetFocus(); + public int ShowContextMenu() => _inner.ShowContextMenu().AsComBool(); + + public IAvnAutomationPeer? RootPeer + { + get + { + var peer = _inner; + var parent = peer.GetParent(); + + while (peer is not IRootProvider && parent is not null) + { + peer = parent; + parent = peer.GetParent(); + } + + return Wrap(peer); + } + } + + public void SetNode(IAvnAutomationNode node) + { + if (Node is not null) + throw new InvalidOperationException("The AvnAutomationPeer already has a node."); + Node = node; + } + + public int IsRootProvider() => (_inner is IRootProvider).AsComBool(); + + public IAvnWindowBase RootProvider_GetWindow() + { + var window = (WindowBase)((ControlAutomationPeer)_inner).Owner; + return ((WindowBaseImpl)window.PlatformImpl!).Native; + } + + public IAvnAutomationPeer? RootProvider_GetFocus() => Wrap(((IRootProvider)_inner).GetFocus()); + + public IAvnAutomationPeer? RootProvider_GetPeerFromPoint(AvnPoint point) + { + var result = ((IRootProvider)_inner).GetPeerFromPoint(point.ToAvaloniaPoint()); + + if (result is null) + return null; + + // The OSX accessibility APIs expect non-ignored elements when hit-testing. + while (!result.IsControlElement()) + { + var parent = result.GetParent(); + + if (parent is not null) + result = parent; + else + break; + } + + return Wrap(result); + } + + public int IsExpandCollapseProvider() => (_inner is IExpandCollapseProvider).AsComBool(); + + public int ExpandCollapseProvider_GetIsExpanded() => ((IExpandCollapseProvider)_inner).ExpandCollapseState switch + { + ExpandCollapseState.Expanded => 1, + ExpandCollapseState.PartiallyExpanded => 1, + _ => 0, + }; + + public int ExpandCollapseProvider_GetShowsMenu() => ((IExpandCollapseProvider)_inner).ShowsMenu.AsComBool(); + public void ExpandCollapseProvider_Expand() => ((IExpandCollapseProvider)_inner).Expand(); + public void ExpandCollapseProvider_Collapse() => ((IExpandCollapseProvider)_inner).Collapse(); + + public int IsInvokeProvider() => (_inner is IInvokeProvider).AsComBool(); + public void InvokeProvider_Invoke() => ((IInvokeProvider)_inner).Invoke(); + + public int IsRangeValueProvider() => (_inner is IRangeValueProvider).AsComBool(); + public double RangeValueProvider_GetValue() => ((IRangeValueProvider)_inner).Value; + public double RangeValueProvider_GetMinimum() => ((IRangeValueProvider)_inner).Minimum; + public double RangeValueProvider_GetMaximum() => ((IRangeValueProvider)_inner).Maximum; + public double RangeValueProvider_GetSmallChange() => ((IRangeValueProvider)_inner).SmallChange; + public double RangeValueProvider_GetLargeChange() => ((IRangeValueProvider)_inner).LargeChange; + public void RangeValueProvider_SetValue(double value) => ((IRangeValueProvider)_inner).SetValue(value); + + public int IsSelectionItemProvider() => (_inner is ISelectionItemProvider).AsComBool(); + public int SelectionItemProvider_IsSelected() => ((ISelectionItemProvider)_inner).IsSelected.AsComBool(); + + public int IsToggleProvider() => (_inner is IToggleProvider).AsComBool(); + public int ToggleProvider_GetToggleState() => (int)((IToggleProvider)_inner).ToggleState; + public void ToggleProvider_Toggle() => ((IToggleProvider)_inner).Toggle(); + + public int IsValueProvider() => (_inner is IValueProvider).AsComBool(); + public IAvnString ValueProvider_GetValue() => ((IValueProvider)_inner).Value.ToAvnString(); + public void ValueProvider_SetValue(string value) => ((IValueProvider)_inner).SetValue(value); + + [return: NotNullIfNotNull("peer")] + public static AvnAutomationPeer? Wrap(AutomationPeer? peer) + { + return peer is null ? null : s_wrappers.GetValue(peer, x => new(peer)); + } + } + + internal class AvnAutomationPeerArray : NativeCallbackBase, IAvnAutomationPeerArray + { + private readonly AvnAutomationPeer[] _items; + + public AvnAutomationPeerArray(IReadOnlyList items) + { + _items = items.Select(x => AvnAutomationPeer.Wrap(x)).ToArray(); + } + + public uint Count => (uint)_items.Length; + public IAvnAutomationPeer Get(uint index) => _items[index]; + } +} diff --git a/src/Avalonia.Native/AvnString.cs b/src/Avalonia.Native/AvnString.cs index dcd473bae32..1c92bf0eecc 100644 --- a/src/Avalonia.Native/AvnString.cs +++ b/src/Avalonia.Native/AvnString.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using System.Text; namespace Avalonia.Native.Interop { @@ -13,6 +14,53 @@ partial interface IAvnStringArray { string[] ToStringArray(); } + + internal class AvnString : NativeCallbackBase, IAvnString + { + private IntPtr _native; + private int _nativeLen; + + public AvnString(string s) => String = s; + + public string String { get; } + public byte[] Bytes => Encoding.UTF8.GetBytes(String); + + public unsafe void* Pointer() + { + EnsureNative(); + return _native.ToPointer(); + } + + public int Length() + { + EnsureNative(); + return _nativeLen; + } + + protected override void Destroyed() + { + if (_native != IntPtr.Zero) + { + Marshal.FreeHGlobal(_native); + _native = IntPtr.Zero; + } + } + + private unsafe void EnsureNative() + { + if (string.IsNullOrEmpty(String)) + return; + if (_native == IntPtr.Zero) + { + _nativeLen = Encoding.UTF8.GetByteCount(String); + _native = Marshal.AllocHGlobal(_nativeLen + 1); + var ptr = (byte*)_native.ToPointer(); + fixed (char* chars = String) + Encoding.UTF8.GetBytes(chars, String.Length, ptr, _nativeLen); + ptr[_nativeLen] = 0; + } + } + } } namespace Avalonia.Native.Interop.Impl { diff --git a/src/Avalonia.Native/Helpers.cs b/src/Avalonia.Native/Helpers.cs index 564434a04cd..764ff789dcb 100644 --- a/src/Avalonia.Native/Helpers.cs +++ b/src/Avalonia.Native/Helpers.cs @@ -1,4 +1,5 @@ using Avalonia.Native.Interop; +using JetBrains.Annotations; namespace Avalonia.Native { @@ -24,11 +25,21 @@ public static AvnPoint ToAvnPoint(this PixelPoint pt) return new AvnPoint { X = pt.X, Y = pt.Y }; } + public static AvnRect ToAvnRect (this Rect rect) + { + return new AvnRect() { X = rect.X, Y= rect.Y, Height = rect.Height, Width = rect.Width }; + } + public static AvnSize ToAvnSize (this Size size) { return new AvnSize { Height = size.Height, Width = size.Width }; } + public static IAvnString ToAvnString(this string s) + { + return s != null ? new AvnString(s) : null; + } + public static Size ToAvaloniaSize (this AvnSize size) { return new Size(size.Width, size.Height); diff --git a/src/Avalonia.Native/PopupImpl.cs b/src/Avalonia.Native/PopupImpl.cs index 4680a2af176..76d3905b47b 100644 --- a/src/Avalonia.Native/PopupImpl.cs +++ b/src/Avalonia.Native/PopupImpl.cs @@ -7,7 +7,6 @@ namespace Avalonia.Native { class PopupImpl : WindowBaseImpl, IPopupImpl { - private readonly IAvaloniaNativeFactory _factory; private readonly AvaloniaNativePlatformOptions _opts; private readonly AvaloniaNativePlatformOpenGlInterface _glFeature; private readonly IWindowBaseImpl _parent; @@ -15,9 +14,8 @@ class PopupImpl : WindowBaseImpl, IPopupImpl public PopupImpl(IAvaloniaNativeFactory factory, AvaloniaNativePlatformOptions opts, AvaloniaNativePlatformOpenGlInterface glFeature, - IWindowBaseImpl parent) : base(opts, glFeature) + IWindowBaseImpl parent) : base(factory, opts, glFeature) { - _factory = factory; _opts = opts; _glFeature = glFeature; _parent = parent; diff --git a/src/Avalonia.Native/WindowImpl.cs b/src/Avalonia.Native/WindowImpl.cs index f740be44a23..b53d363f44d 100644 --- a/src/Avalonia.Native/WindowImpl.cs +++ b/src/Avalonia.Native/WindowImpl.cs @@ -13,7 +13,6 @@ namespace Avalonia.Native { internal class WindowImpl : WindowBaseImpl, IWindowImpl, ITopLevelImplWithNativeMenuExporter { - private readonly IAvaloniaNativeFactory _factory; private readonly AvaloniaNativePlatformOptions _opts; private readonly AvaloniaNativePlatformOpenGlInterface _glFeature; IAvnWindow _native; @@ -22,9 +21,8 @@ internal class WindowImpl : WindowBaseImpl, IWindowImpl, ITopLevelImplWithNative internal WindowImpl(IAvaloniaNativeFactory factory, AvaloniaNativePlatformOptions opts, - AvaloniaNativePlatformOpenGlInterface glFeature) : base(opts, glFeature) + AvaloniaNativePlatformOpenGlInterface glFeature) : base(factory, opts, glFeature) { - _factory = factory; _opts = opts; _glFeature = glFeature; _doubleClickHelper = new DoubleClickHelper(); diff --git a/src/Avalonia.Native/WindowImplBase.cs b/src/Avalonia.Native/WindowImplBase.cs index eba58c2f85d..735f11bcd50 100644 --- a/src/Avalonia.Native/WindowImplBase.cs +++ b/src/Avalonia.Native/WindowImplBase.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using Avalonia.Automation.Peers; using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Controls.Platform.Surfaces; @@ -46,6 +47,7 @@ public IntPtr GetNSWindowRetained() internal abstract class WindowBaseImpl : IWindowBaseImpl, IFramebufferPlatformSurface, ITopLevelImplWithNativeControlHost { + protected readonly IAvaloniaNativeFactory _factory; protected IInputRoot _inputRoot; IAvnWindowBase _native; private object _syncRoot = new object(); @@ -61,8 +63,10 @@ internal abstract class WindowBaseImpl : IWindowBaseImpl, private NativeControlHostImpl _nativeControlHost; private IGlContext _glContext; - internal WindowBaseImpl(AvaloniaNativePlatformOptions opts, AvaloniaNativePlatformOpenGlInterface glFeature) + internal WindowBaseImpl(IAvaloniaNativeFactory factory, AvaloniaNativePlatformOptions opts, + AvaloniaNativePlatformOpenGlInterface glFeature) { + _factory = factory; _gpu = opts.UseGpu && glFeature != null; _deferredRendering = opts.UseDeferredRendering; @@ -90,6 +94,8 @@ protected void Init(IAvnWindowBase window, IAvnScreens screens, IGlContext glCon Resize(new Size(monitor.WorkingArea.Width * 0.75d, monitor.WorkingArea.Height * 0.7d), PlatformResizeReason.Layout); } + public IAvnWindowBase Native => _native; + public Size ClientSize { get @@ -151,6 +157,11 @@ public ILockedFramebuffer Lock() public IMouseDevice MouseDevice => _mouse; public abstract IPopupImpl CreatePopup(); + public AutomationPeer GetAutomationPeer() + { + return _inputRoot is Control c ? ControlAutomationPeer.CreatePeerForElement(c) : null; + } + protected unsafe class WindowBaseEvents : NativeCallbackBase, IAvnWindowBaseEvents { private readonly WindowBaseImpl _parent; @@ -216,7 +227,6 @@ int IAvnWindowBaseEvents.RawTextInputEvent(uint timeStamp, string text) return _parent.RawTextInputEvent(timeStamp, text).AsComBool(); } - void IAvnWindowBaseEvents.ScalingChanged(double scaling) { _parent._savedScaling = scaling; @@ -256,8 +266,13 @@ public AvnDragDropEffects DragEvent(AvnDragEventType type, AvnPoint position, return (AvnDragDropEffects)args.Effects; } } - } + IAvnAutomationPeer IAvnWindowBaseEvents.AutomationPeer + { + get => AvnAutomationPeer.Wrap(_parent.GetAutomationPeer()); + } + } + public void Activate() { _native?.Activate(); diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index f2b9d4997e2..8092004989b 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -218,6 +218,14 @@ enum SystemDecorations { SystemDecorationsFull = 2, } +enum AvnAutomationProperty +{ + AutomationPeer_BoundingRectangle, + AutomationPeer_ClassName, + AutomationPeer_Name, + RangeValueProvider_Value, +} + struct AvnSize { double Width, Height; @@ -412,6 +420,51 @@ enum AvnPlatformResizeReason ResizeDpiChange, } +enum AvnAutomationControlType +{ + AutomationNone, + AutomationButton, + AutomationCalendar, + AutomationCheckBox, + AutomationComboBox, + AutomationComboBoxItem, + AutomationEdit, + AutomationHyperlink, + AutomationImage, + AutomationListItem, + AutomationList, + AutomationMenu, + AutomationMenuBar, + AutomationMenuItem, + AutomationProgressBar, + AutomationRadioButton, + AutomationScrollBar, + AutomationSlider, + AutomationSpinner, + AutomationStatusBar, + AutomationTab, + AutomationTabItem, + AutomationText, + AutomationToolBar, + AutomationToolTip, + AutomationTree, + AutomationTreeItem, + AutomationCustom, + AutomationGroup, + AutomationThumb, + AutomationDataGrid, + AutomationDataItem, + AutomationDocument, + AutomationSplitButton, + AutomationWindow, + AutomationPane, + AutomationHeader, + AutomationHeaderItem, + AutomationTable, + AutomationTitleBar, + AutomationSeparator, +} + [uuid(809c652e-7396-11d2-9771-00a0c9b4d50c)] interface IAvaloniaNativeFactory : IUnknown { @@ -522,6 +575,7 @@ interface IAvnWindowBaseEvents : IUnknown AvnDragDropEffects DragEvent(AvnDragEventType type, AvnPoint position, AvnInputModifiers modifiers, AvnDragDropEffects effects, IAvnClipboard* clipboard, [intptr]void* dataObjectHandle); + IAvnAutomationPeer* GetAutomationPeer(); } [uuid(1ae178ee-1fcc-447f-b6dd-b7bb727f934c)] @@ -765,3 +819,79 @@ interface IAvnApplicationCommands : IUnknown HRESULT ShowAll(); HRESULT HideOthers(); } + +[uuid(b87016f3-7eec-41de-b385-07844c268dc4)] +interface IAvnAutomationPeer : IUnknown +{ + IAvnAutomationNode* GetNode(); + void SetNode(IAvnAutomationNode* node); + + IAvnString* GetAcceleratorKey(); + IAvnString* GetAccessKey(); + AvnAutomationControlType GetAutomationControlType(); + IAvnString* GetAutomationId(); + AvnRect GetBoundingRectangle(); + IAvnAutomationPeerArray* GetChildren(); + IAvnString* GetClassName(); + IAvnAutomationPeer* GetLabeledBy(); + IAvnString* GetName(); + IAvnAutomationPeer* GetParent(); + bool HasKeyboardFocus(); + bool IsContentElement(); + bool IsControlElement(); + bool IsEnabled(); + bool IsKeyboardFocusable(); + void SetFocus(); + bool ShowContextMenu(); + + IAvnAutomationPeer* GetRootPeer(); + + bool IsRootProvider(); + IAvnWindowBase* RootProvider_GetWindow(); + IAvnAutomationPeer* RootProvider_GetFocus(); + IAvnAutomationPeer* RootProvider_GetPeerFromPoint(AvnPoint point); + + bool IsExpandCollapseProvider(); + bool ExpandCollapseProvider_GetIsExpanded(); + bool ExpandCollapseProvider_GetShowsMenu(); + void ExpandCollapseProvider_Expand(); + void ExpandCollapseProvider_Collapse(); + + bool IsInvokeProvider(); + void InvokeProvider_Invoke(); + + bool IsRangeValueProvider(); + double RangeValueProvider_GetValue(); + double RangeValueProvider_GetMinimum(); + double RangeValueProvider_GetMaximum(); + double RangeValueProvider_GetSmallChange(); + double RangeValueProvider_GetLargeChange(); + void RangeValueProvider_SetValue(double value); + + bool IsSelectionItemProvider(); + bool SelectionItemProvider_IsSelected(); + + bool IsToggleProvider(); + int ToggleProvider_GetToggleState(); + void ToggleProvider_Toggle(); + + bool IsValueProvider(); + IAvnString* ValueProvider_GetValue(); + void ValueProvider_SetValue(char* value); +} + +[uuid(b00af5da-78af-4b33-bfff-4ce13a6239a9)] +interface IAvnAutomationPeerArray : IUnknown +{ + uint GetCount(); + HRESULT Get(uint index, IAvnAutomationPeer**ppv); +} + +[uuid(004dc40b-e435-49dc-bac5-6272ee35382a)] +interface IAvnAutomationNode : IUnknown +{ + void Dispose(); + void ChildrenChanged(); + void PropertyChanged(AvnAutomationProperty property); + void FocusChanged(); +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.ExpandCollapse.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.ExpandCollapse.cs new file mode 100644 index 00000000000..5f3f863493a --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.ExpandCollapse.cs @@ -0,0 +1,19 @@ +using Avalonia.Automation; +using Avalonia.Automation.Provider; +using UIA = Avalonia.Win32.Interop.Automation; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + internal partial class AutomationNode : UIA.IExpandCollapseProvider + { + ExpandCollapseState UIA.IExpandCollapseProvider.ExpandCollapseState + { + get => InvokeSync(x => x.ExpandCollapseState); + } + + void UIA.IExpandCollapseProvider.Expand() => InvokeSync(x => x.Expand()); + void UIA.IExpandCollapseProvider.Collapse() => InvokeSync(x => x.Collapse()); + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.RangeValue.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.RangeValue.cs new file mode 100644 index 00000000000..b91cb76888c --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.RangeValue.cs @@ -0,0 +1,19 @@ +using Avalonia.Automation.Provider; +using UIA = Avalonia.Win32.Interop.Automation; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + internal partial class AutomationNode : UIA.IRangeValueProvider + { + double UIA.IRangeValueProvider.Value => InvokeSync(x => x.Value); + bool UIA.IRangeValueProvider.IsReadOnly => InvokeSync(x => x.IsReadOnly); + double UIA.IRangeValueProvider.Maximum => InvokeSync(x => x.Maximum); + double UIA.IRangeValueProvider.Minimum => InvokeSync(x => x.Minimum); + double UIA.IRangeValueProvider.LargeChange => 1; + double UIA.IRangeValueProvider.SmallChange => 1; + + public void SetValue(double value) => InvokeSync(x => x.SetValue(value)); + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.Scroll.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Scroll.cs new file mode 100644 index 00000000000..4f2d4ae2691 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Scroll.cs @@ -0,0 +1,32 @@ +using Avalonia.Automation.Provider; +using UIA = Avalonia.Win32.Interop.Automation; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + internal partial class AutomationNode : UIA.IScrollProvider, UIA.IScrollItemProvider + { + bool UIA.IScrollProvider.HorizontallyScrollable => InvokeSync(x => x.HorizontallyScrollable); + double UIA.IScrollProvider.HorizontalScrollPercent => InvokeSync(x => x.HorizontalScrollPercent); + double UIA.IScrollProvider.HorizontalViewSize => InvokeSync(x => x.HorizontalViewSize); + bool UIA.IScrollProvider.VerticallyScrollable => InvokeSync(x => x.VerticallyScrollable); + double UIA.IScrollProvider.VerticalScrollPercent => InvokeSync(x => x.VerticalScrollPercent); + double UIA.IScrollProvider.VerticalViewSize => InvokeSync(x => x.VerticalViewSize); + + void UIA.IScrollProvider.Scroll(ScrollAmount horizontalAmount, ScrollAmount verticalAmount) + { + InvokeSync(x => x.Scroll(horizontalAmount, verticalAmount)); + } + + void UIA.IScrollProvider.SetScrollPercent(double horizontalPercent, double verticalPercent) + { + InvokeSync(x => x.SetScrollPercent(horizontalPercent, verticalPercent)); + } + + void UIA.IScrollItemProvider.ScrollIntoView() + { + InvokeSync(() => Peer.BringIntoView()); + } + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.Selection.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Selection.cs new file mode 100644 index 00000000000..61903ab5b08 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Selection.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using UIA = Avalonia.Win32.Interop.Automation; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + internal partial class AutomationNode : UIA.ISelectionProvider, UIA.ISelectionItemProvider + { + bool UIA.ISelectionProvider.CanSelectMultiple => InvokeSync(x => x.CanSelectMultiple); + bool UIA.ISelectionProvider.IsSelectionRequired => InvokeSync(x => x.IsSelectionRequired); + bool UIA.ISelectionItemProvider.IsSelected => InvokeSync(x => x.IsSelected); + + UIA.IRawElementProviderSimple? UIA.ISelectionItemProvider.SelectionContainer + { + get + { + var peer = InvokeSync(x => x.SelectionContainer); + return GetOrCreate(peer as AutomationPeer); + } + } + + UIA.IRawElementProviderSimple[] UIA.ISelectionProvider.GetSelection() + { + var peers = InvokeSync>(x => x.GetSelection()); + return peers?.Select(x => (UIA.IRawElementProviderSimple)GetOrCreate(x)!).ToArray() ?? + Array.Empty(); + } + + void UIA.ISelectionItemProvider.AddToSelection() => InvokeSync(x => x.AddToSelection()); + void UIA.ISelectionItemProvider.RemoveFromSelection() => InvokeSync(x => x.RemoveFromSelection()); + void UIA.ISelectionItemProvider.Select() => InvokeSync(x => x.Select()); + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.Toggle.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Toggle.cs new file mode 100644 index 00000000000..38f4d809461 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Toggle.cs @@ -0,0 +1,13 @@ +using Avalonia.Automation.Provider; +using UIA = Avalonia.Win32.Interop.Automation; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + internal partial class AutomationNode : UIA.IToggleProvider + { + ToggleState UIA.IToggleProvider.ToggleState => InvokeSync(x => x.ToggleState); + void UIA.IToggleProvider.Toggle() => InvokeSync(x => x.Toggle()); + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.Value.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Value.cs new file mode 100644 index 00000000000..34f5dfe0b92 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.Value.cs @@ -0,0 +1,19 @@ +using System.Runtime.InteropServices; +using Avalonia.Automation.Provider; +using UIA = Avalonia.Win32.Interop.Automation; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + internal partial class AutomationNode : UIA.IValueProvider + { + bool UIA.IValueProvider.IsReadOnly => InvokeSync(x => x.IsReadOnly); + string? UIA.IValueProvider.Value => InvokeSync(x => x.Value); + + void UIA.IValueProvider.SetValue([MarshalAs(UnmanagedType.LPWStr)] string? value) + { + InvokeSync(x => x.SetValue(value)); + } + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs new file mode 100644 index 00000000000..70e415aff17 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Threading; +using Avalonia.Win32.Interop.Automation; +using AAP = Avalonia.Automation.Provider; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + [ComVisible(true)] + internal partial class AutomationNode : MarshalByRefObject, + IRawElementProviderSimple, + IRawElementProviderSimple2, + IRawElementProviderFragment, + IRawElementProviderAdviseEvents, + IInvokeProvider + { + private static Dictionary s_propertyMap = new Dictionary() + { + { AutomationElementIdentifiers.BoundingRectangleProperty, UiaPropertyId.BoundingRectangle }, + { AutomationElementIdentifiers.ClassNameProperty, UiaPropertyId.ClassName }, + { AutomationElementIdentifiers.NameProperty, UiaPropertyId.Name }, + { ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty, UiaPropertyId.ExpandCollapseExpandCollapseState }, + { RangeValuePatternIdentifiers.IsReadOnlyProperty, UiaPropertyId.RangeValueIsReadOnly}, + { RangeValuePatternIdentifiers.MaximumProperty, UiaPropertyId.RangeValueMaximum }, + { RangeValuePatternIdentifiers.MinimumProperty, UiaPropertyId.RangeValueMinimum }, + { RangeValuePatternIdentifiers.ValueProperty, UiaPropertyId.RangeValueValue }, + { ScrollPatternIdentifiers.HorizontallyScrollableProperty, UiaPropertyId.ScrollHorizontallyScrollable }, + { ScrollPatternIdentifiers.HorizontalScrollPercentProperty, UiaPropertyId.ScrollHorizontalScrollPercent }, + { ScrollPatternIdentifiers.HorizontalViewSizeProperty, UiaPropertyId.ScrollHorizontalViewSize }, + { ScrollPatternIdentifiers.VerticallyScrollableProperty, UiaPropertyId.ScrollVerticallyScrollable }, + { ScrollPatternIdentifiers.VerticalScrollPercentProperty, UiaPropertyId.ScrollVerticalScrollPercent }, + { ScrollPatternIdentifiers.VerticalViewSizeProperty, UiaPropertyId.ScrollVerticalViewSize }, + { SelectionPatternIdentifiers.CanSelectMultipleProperty, UiaPropertyId.SelectionCanSelectMultiple }, + { SelectionPatternIdentifiers.IsSelectionRequiredProperty, UiaPropertyId.SelectionIsSelectionRequired }, + { SelectionPatternIdentifiers.SelectionProperty, UiaPropertyId.SelectionSelection }, + }; + + private static ConditionalWeakTable s_nodes = + new ConditionalWeakTable(); + + private readonly int[] _runtimeId; + private int _raiseFocusChanged; + private int _raisePropertyChanged; + + public AutomationNode(AutomationPeer peer) + { + _runtimeId = new int[] { 3, GetHashCode() }; + Peer = peer; + s_nodes.Add(peer, this); + } + + public AutomationPeer Peer { get; protected set; } + + public Rect BoundingRectangle + { + get => InvokeSync(() => + { + if (GetRoot() is RootAutomationNode root) + return root.ToScreen(Peer.GetBoundingRectangle()); + return default; + }); + } + + public virtual IRawElementProviderFragmentRoot? FragmentRoot + { + get => InvokeSync(() => GetRoot()) as IRawElementProviderFragmentRoot; + } + + public virtual IRawElementProviderSimple? HostRawElementProvider => null; + public ProviderOptions ProviderOptions => ProviderOptions.ServerSideProvider; + + public void ChildrenChanged() + { + UiaCoreProviderApi.UiaRaiseStructureChangedEvent( + this, + StructureChangeType.ChildrenInvalidated, + null, + 0); + } + + public void PropertyChanged(AutomationProperty property, object? oldValue, object? newValue) + { + if (_raisePropertyChanged > 0 && s_propertyMap.TryGetValue(property, out var id)) + { + UiaCoreProviderApi.UiaRaiseAutomationPropertyChangedEvent(this, (int)id, oldValue, newValue); + } + } + + [return: MarshalAs(UnmanagedType.IUnknown)] + public virtual object? GetPatternProvider(int patternId) + { + AutomationNode? ThisIfPeerImplementsProvider() => Peer.GetProvider() is object ? this : null; + + return (UiaPatternId)patternId switch + { + UiaPatternId.ExpandCollapse => ThisIfPeerImplementsProvider(), + UiaPatternId.Invoke => ThisIfPeerImplementsProvider(), + UiaPatternId.RangeValue => ThisIfPeerImplementsProvider(), + UiaPatternId.Scroll => ThisIfPeerImplementsProvider(), + UiaPatternId.ScrollItem => this, + UiaPatternId.Selection => ThisIfPeerImplementsProvider(), + UiaPatternId.SelectionItem => ThisIfPeerImplementsProvider(), + UiaPatternId.Toggle => ThisIfPeerImplementsProvider(), + UiaPatternId.Value => ThisIfPeerImplementsProvider(), + _ => null, + }; + } + + public virtual object? GetPropertyValue(int propertyId) + { + return (UiaPropertyId)propertyId switch + { + UiaPropertyId.AcceleratorKey => InvokeSync(() => Peer.GetAcceleratorKey()), + UiaPropertyId.AccessKey => InvokeSync(() => Peer.GetAccessKey()), + UiaPropertyId.AutomationId => InvokeSync(() => Peer.GetAutomationId()), + UiaPropertyId.ClassName => InvokeSync(() => Peer.GetClassName()), + UiaPropertyId.ClickablePoint => new[] { BoundingRectangle.Center.X, BoundingRectangle.Center.Y }, + UiaPropertyId.ControlType => InvokeSync(() => ToUiaControlType(Peer.GetAutomationControlType())), + UiaPropertyId.Culture => CultureInfo.CurrentCulture.LCID, + UiaPropertyId.FrameworkId => "Avalonia", + UiaPropertyId.HasKeyboardFocus => InvokeSync(() => Peer.HasKeyboardFocus()), + UiaPropertyId.IsContentElement => InvokeSync(() => Peer.IsContentElement()), + UiaPropertyId.IsControlElement => InvokeSync(() => Peer.IsControlElement()), + UiaPropertyId.IsEnabled => InvokeSync(() => Peer.IsEnabled()), + UiaPropertyId.IsKeyboardFocusable => InvokeSync(() => Peer.IsKeyboardFocusable()), + UiaPropertyId.LocalizedControlType => InvokeSync(() => Peer.GetLocalizedControlType()), + UiaPropertyId.Name => InvokeSync(() => Peer.GetName()), + UiaPropertyId.ProcessId => Process.GetCurrentProcess().Id, + UiaPropertyId.RuntimeId => _runtimeId, + _ => null, + }; + } + + public int[]? GetRuntimeId() => _runtimeId; + + public virtual IRawElementProviderFragment? Navigate(NavigateDirection direction) + { + AutomationNode? GetSibling(int direction) + { + var children = Peer.GetParent()?.GetChildren(); + + for (var i = 0; i < (children?.Count ?? 0); ++i) + { + if (ReferenceEquals(children![i], Peer)) + { + var j = i + direction; + if (j >= 0 && j < children.Count) + return GetOrCreate(children[j]); + } + } + + return null; + } + + return InvokeSync(() => + { + return direction switch + { + NavigateDirection.Parent => GetOrCreate(Peer.GetParent()), + NavigateDirection.NextSibling => GetSibling(1), + NavigateDirection.PreviousSibling => GetSibling(-1), + NavigateDirection.FirstChild => GetOrCreate(Peer.GetChildren().FirstOrDefault()), + NavigateDirection.LastChild => GetOrCreate(Peer.GetChildren().LastOrDefault()), + _ => null, + }; + }) as IRawElementProviderFragment; + } + + public void SetFocus() => InvokeSync(() => Peer.SetFocus()); + + public static AutomationNode? GetOrCreate(AutomationPeer? peer) + { + return peer is null ? null : s_nodes.GetValue(peer, Create); + } + + public static void Release(AutomationPeer peer) => s_nodes.Remove(peer); + + IRawElementProviderSimple[]? IRawElementProviderFragment.GetEmbeddedFragmentRoots() => null; + void IRawElementProviderSimple2.ShowContextMenu() => InvokeSync(() => Peer.ShowContextMenu()); + void IInvokeProvider.Invoke() => InvokeSync((AAP.IInvokeProvider x) => x.Invoke()); + + void IRawElementProviderAdviseEvents.AdviseEventAdded(int eventId, int[] properties) + { + switch ((UiaEventId)eventId) + { + case UiaEventId.AutomationPropertyChanged: + ++_raisePropertyChanged; + break; + case UiaEventId.AutomationFocusChanged: + ++_raiseFocusChanged; + break; + } + } + + void IRawElementProviderAdviseEvents.AdviseEventRemoved(int eventId, int[] properties) + { + switch ((UiaEventId)eventId) + { + case UiaEventId.AutomationPropertyChanged: + --_raisePropertyChanged; + break; + case UiaEventId.AutomationFocusChanged: + --_raiseFocusChanged; + break; + } + } + + protected void InvokeSync(Action action) + { + if (Dispatcher.UIThread.CheckAccess()) + action(); + else + Dispatcher.UIThread.InvokeAsync(action).Wait(); + } + + protected T InvokeSync(Func func) + { + if (Dispatcher.UIThread.CheckAccess()) + return func(); + else + return Dispatcher.UIThread.InvokeAsync(func).Result; + } + + protected void InvokeSync(Action action) + { + if (Peer.GetProvider() is TInterface i) + { + try + { + InvokeSync(() => action(i)); + } + catch (AggregateException e) when (e.InnerException is ElementNotEnabledException) + { + throw new COMException(e.Message, UiaCoreProviderApi.UIA_E_ELEMENTNOTENABLED); + } + } + else + { + throw new NotSupportedException(); + } + } + + protected TResult InvokeSync(Func func) + { + if (Peer.GetProvider() is TInterface i) + { + try + { + return InvokeSync(() => func(i)); + } + catch (AggregateException e) when (e.InnerException is ElementNotEnabledException) + { + throw new COMException(e.Message, UiaCoreProviderApi.UIA_E_ELEMENTNOTENABLED); + } + } + + throw new NotSupportedException(); + } + + protected void RaiseFocusChanged(AutomationNode? focused) + { + if (_raiseFocusChanged > 0) + { + UiaCoreProviderApi.UiaRaiseAutomationEvent( + focused, + (int)UiaEventId.AutomationFocusChanged); + } + } + + private AutomationNode? GetRoot() + { + Dispatcher.UIThread.VerifyAccess(); + + var peer = Peer; + var parent = peer.GetParent(); + + while (peer.GetProvider() is null && parent is object) + { + peer = parent; + parent = peer.GetParent(); + } + + return peer is object ? GetOrCreate(peer) : null; + } + + private static AutomationNode Create(AutomationPeer peer) + { + return peer.GetProvider() is object ? + new RootAutomationNode(peer) : + new AutomationNode(peer); + } + + private static UiaControlTypeId ToUiaControlType(AutomationControlType role) + { + return role switch + { + AutomationControlType.None => UiaControlTypeId.Group, + AutomationControlType.Button => UiaControlTypeId.Button, + AutomationControlType.Calendar => UiaControlTypeId.Calendar, + AutomationControlType.CheckBox => UiaControlTypeId.CheckBox, + AutomationControlType.ComboBox => UiaControlTypeId.ComboBox, + AutomationControlType.ComboBoxItem => UiaControlTypeId.ListItem, + AutomationControlType.Edit => UiaControlTypeId.Edit, + AutomationControlType.Hyperlink => UiaControlTypeId.Hyperlink, + AutomationControlType.Image => UiaControlTypeId.Image, + AutomationControlType.ListItem => UiaControlTypeId.ListItem, + AutomationControlType.List => UiaControlTypeId.List, + AutomationControlType.Menu => UiaControlTypeId.Menu, + AutomationControlType.MenuBar => UiaControlTypeId.MenuBar, + AutomationControlType.MenuItem => UiaControlTypeId.MenuItem, + AutomationControlType.ProgressBar => UiaControlTypeId.ProgressBar, + AutomationControlType.RadioButton => UiaControlTypeId.RadioButton, + AutomationControlType.ScrollBar => UiaControlTypeId.ScrollBar, + AutomationControlType.Slider => UiaControlTypeId.Slider, + AutomationControlType.Spinner => UiaControlTypeId.Spinner, + AutomationControlType.StatusBar => UiaControlTypeId.StatusBar, + AutomationControlType.Tab => UiaControlTypeId.Tab, + AutomationControlType.TabItem => UiaControlTypeId.TabItem, + AutomationControlType.Text => UiaControlTypeId.Text, + AutomationControlType.ToolBar => UiaControlTypeId.ToolBar, + AutomationControlType.ToolTip => UiaControlTypeId.ToolTip, + AutomationControlType.Tree => UiaControlTypeId.Tree, + AutomationControlType.TreeItem => UiaControlTypeId.TreeItem, + AutomationControlType.Custom => UiaControlTypeId.Custom, + AutomationControlType.Group => UiaControlTypeId.Group, + AutomationControlType.Thumb => UiaControlTypeId.Thumb, + AutomationControlType.DataGrid => UiaControlTypeId.DataGrid, + AutomationControlType.DataItem => UiaControlTypeId.DataItem, + AutomationControlType.Document => UiaControlTypeId.Document, + AutomationControlType.SplitButton => UiaControlTypeId.SplitButton, + AutomationControlType.Window => UiaControlTypeId.Window, + AutomationControlType.Pane => UiaControlTypeId.Pane, + AutomationControlType.Header => UiaControlTypeId.Header, + AutomationControlType.HeaderItem => UiaControlTypeId.HeaderItem, + AutomationControlType.Table => UiaControlTypeId.Table, + AutomationControlType.TitleBar => UiaControlTypeId.TitleBar, + AutomationControlType.Separator => UiaControlTypeId.Separator, + _ => UiaControlTypeId.Custom, + }; + } + } +} diff --git a/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs b/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs new file mode 100644 index 00000000000..1085aa1b424 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs @@ -0,0 +1,73 @@ +using System; +using System.Runtime.InteropServices; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Platform; +using Avalonia.Win32.Interop.Automation; + +#nullable enable + +namespace Avalonia.Win32.Automation +{ + internal class RootAutomationNode : AutomationNode, + IRawElementProviderFragmentRoot + { + public RootAutomationNode(AutomationPeer peer) + : base(peer) + { + Peer = base.Peer.GetProvider() ?? throw new AvaloniaInternalException( + "Attempt to create RootAutomationNode from peer which does not implement IRootProvider."); + Peer.FocusChanged += FocusChanged; + } + + public override IRawElementProviderFragmentRoot? FragmentRoot => this; + public new IRootProvider Peer { get; } + public IWindowBaseImpl? WindowImpl => Peer.PlatformImpl as IWindowBaseImpl; + + public IRawElementProviderFragment? ElementProviderFromPoint(double x, double y) + { + if (WindowImpl is null) + return null; + + var p = WindowImpl.PointToClient(new PixelPoint((int)x, (int)y)); + var peer = (WindowBaseAutomationPeer)Peer; + var found = InvokeSync(() => peer.GetPeerFromPoint(p)); + var result = GetOrCreate(found) as IRawElementProviderFragment; + return result; + } + + public IRawElementProviderFragment? GetFocus() + { + var focus = InvokeSync(() => Peer.GetFocus()); + return GetOrCreate(focus); + } + + public void FocusChanged(object sender, EventArgs e) + { + RaiseFocusChanged(GetOrCreate(Peer.GetFocus())); + } + + public Rect ToScreen(Rect rect) + { + if (WindowImpl is null) + return default; + return new PixelRect( + WindowImpl.PointToScreen(rect.TopLeft), + WindowImpl.PointToScreen(rect.BottomRight)) + .ToRect(1); + } + + public override IRawElementProviderSimple? HostRawElementProvider + { + get + { + var handle = WindowImpl?.Handle.Handle ?? IntPtr.Zero; + if (handle == IntPtr.Zero) + return null; + var hr = UiaCoreProviderApi.UiaHostProviderFromHwnd(handle, out var result); + Marshal.ThrowExceptionForHR(hr); + return result; + } + } + } +} diff --git a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj index d727acdc226..ba842757d72 100644 --- a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj +++ b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj @@ -7,6 +7,9 @@ + + + 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;