diff --git a/samples/IntegrationTestApp/MainWindow.axaml b/samples/IntegrationTestApp/MainWindow.axaml index 0ccca5d4cc..c72d45783c 100644 --- a/samples/IntegrationTestApp/MainWindow.axaml +++ b/samples/IntegrationTestApp/MainWindow.axaml @@ -5,56 +5,64 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="IntegrationTestApp.MainWindow" Title="IntegrationTestApp"> - - - - TextBlockWithName - - TextBlockWithNameAndAutomationId - - Label for TextBox - - Foo - - - - - - - - - - + + + + + + + + + + TextBlockWithName + + TextBlockWithNameAndAutomationId + + Label for TextBox + + Foo + + + + + + + + + + + - - - Unchecked - Checked - ThreeState - - + + + Unchecked + Checked + ThreeState + + - - - - Foo - Bar - - - Foo - Bar - - - Foo - Bar - - - - + + + + Foo + Bar + + + Foo + Bar + + + Foo + Bar + + + + + diff --git a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs index 8a30661359..4c517fcd71 100644 --- a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Globalization; using Avalonia.Automation.Platform; @@ -75,6 +76,17 @@ namespace Avalonia.Automation.Peers /// 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. /// @@ -208,6 +220,8 @@ namespace Avalonia.Automation.Peers } protected abstract void BringIntoViewCore(); + protected abstract string? GetAcceleratorKeyCore(); + protected abstract string? GetAccessKeyCore(); protected abstract AutomationControlType GetAutomationControlTypeCore(); protected abstract string? GetAutomationIdCore(); protected abstract Rect GetBoundingRectangleCore(); diff --git a/src/Avalonia.Controls/Automation/Peers/ButtonAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ButtonAutomationPeer.cs index 89c80e1144..66e5bd7b49 100644 --- a/src/Avalonia.Controls/Automation/Peers/ButtonAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ButtonAutomationPeer.cs @@ -13,13 +13,27 @@ namespace Avalonia.Automation.Peers : base(factory, 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; diff --git a/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs index 721272c83e..d11946b27e 100644 --- a/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ComboBoxAutomationPeer.cs @@ -92,6 +92,8 @@ namespace Avalonia.Automation.Peers } } + 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; diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index e68bf2ea47..cba5b4d79c 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs @@ -148,6 +148,8 @@ namespace Avalonia.Automation.Peers return true; } + protected override string? GetAcceleratorKeyCore() => AutomationProperties.GetAcceleratorKey(Owner); + protected override string? GetAccessKeyCore() => AutomationProperties.GetAccessKey(Owner); protected override string? GetAutomationIdCore() => AutomationProperties.GetAutomationId(Owner) ?? Owner.Name; protected override Rect GetBoundingRectangleCore() => GetBounds(Owner.TransformedBounds); protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Custom; diff --git a/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs index b1f7774fc9..b9f032ef0d 100644 --- a/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs @@ -1,5 +1,6 @@ using Avalonia.Automation.Platform; using Avalonia.Controls; +using Avalonia.Controls.Primitives; #nullable enable @@ -14,6 +15,33 @@ namespace Avalonia.Automation.Peers 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; @@ -23,14 +51,9 @@ namespace Avalonia.Automation.Peers { var result = base.GetNameCore(); - if (result is null && Owner.HeaderPresenter.Child is TextBlock text) - { - result = text.Text; - } - - if (result is null) + if (result is null && Owner.Header is string header) { - result = Owner.Header?.ToString(); + result = AccessText.RemoveAccessKeyMarker(header); } return result; diff --git a/src/Avalonia.Controls/Primitives/AccessText.cs b/src/Avalonia.Controls/Primitives/AccessText.cs index 7a5e6ce426..061fd359f1 100644 --- a/src/Avalonia.Controls/Primitives/AccessText.cs +++ b/src/Avalonia.Controls/Primitives/AccessText.cs @@ -1,4 +1,6 @@ using System; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Platform; using Avalonia.Input; using Avalonia.Media; using Avalonia.Media.TextFormatting; @@ -76,6 +78,37 @@ namespace Avalonia.Controls.Primitives } } + internal static string RemoveAccessKeyMarker(string text) + { + if (!string.IsNullOrEmpty(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); + } + return text; + } + + private static int FindAccessKeyMarker(string text) + { + var length = text.Length; + var startIndex = 0; + while (startIndex < length) + { + int index = text.IndexOf('_', startIndex); + if (index == -1) + return -1; + if (index + 1 < length && text[index + 1] != '_') + return index; + startIndex = index + 2; + } + + return -1; + } + /// /// Get the pixel location relative to the top-left of the layout box given the text position. /// @@ -180,6 +213,11 @@ namespace Avalonia.Controls.Primitives } } + protected override AutomationPeer OnCreateAutomationPeer(IAutomationNodeFactory factory) + { + return new NoneAutomationPeer(factory, this); + } + /// /// Returns a string with the first underscore stripped. /// diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs index 3757958cca..8a5488b1f1 100644 --- a/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs @@ -116,6 +116,8 @@ namespace Avalonia.Win32.Automation { 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 }, diff --git a/tests/Avalonia.IntegrationTests.Win32/ButtonTests.cs b/tests/Avalonia.IntegrationTests.Win32/ButtonTests.cs index b0d300e9fe..21c2b1a7e3 100644 --- a/tests/Avalonia.IntegrationTests.Win32/ButtonTests.cs +++ b/tests/Avalonia.IntegrationTests.Win32/ButtonTests.cs @@ -42,5 +42,13 @@ namespace Avalonia.IntegrationTests.Win32 Assert.Equal("Button with TextBlock", button.Text); } + + [Fact] + public void ButtonWithAcceleratorKey() + { + var button = _session.FindElementByAccessibilityId("ButtonWithAcceleratorKey"); + + Assert.Equal("Ctrl+B", button.GetAttribute("AcceleratorKey")); + } } } diff --git a/tests/Avalonia.IntegrationTests.Win32/MenuTests.cs b/tests/Avalonia.IntegrationTests.Win32/MenuTests.cs new file mode 100644 index 0000000000..3d93afec12 --- /dev/null +++ b/tests/Avalonia.IntegrationTests.Win32/MenuTests.cs @@ -0,0 +1,31 @@ +using OpenQA.Selenium.Appium.Windows; +using Xunit; + +namespace Avalonia.IntegrationTests.Win32 +{ + [Collection("Default")] + public class MenuTests + { + private WindowsDriver _session; + + public MenuTests(TestAppFixture fixture) => _session = fixture.Session; + + [Fact] + public void File() + { + var fileMenu = _session.FindElementByAccessibilityId("FileMenu"); + + Assert.Equal("File", fileMenu.Text); + } + + [Fact] + public void Open() + { + var fileMenu = _session.FindElementByAccessibilityId("FileMenu"); + fileMenu.Click(); + + var openMenu = fileMenu.FindElementByAccessibilityId("OpenMenu"); + Assert.Equal("Ctrl+O", openMenu.GetAttribute("AcceleratorKey")); + } + } +}