From 2185ce04e7609f9741c229ccbdebcba339043947 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 13 Feb 2026 09:18:59 +0000 Subject: [PATCH] Use KeySymbol for AccessKey (#20662) * Change AccessText.AccessKey to a string * Use KeySymbol for AccessKey * Update API suppressions * Fix tests --- api/Avalonia.nupkg.xml | 12 ++++ src/Avalonia.Base/Input/AccessKeyHandler.cs | 24 +++---- src/Avalonia.Base/Input/IAccessKeyHandler.cs | 2 +- .../Peers/MenuItemAutomationPeer.cs | 2 +- .../Primitives/AccessText.cs | 14 ++-- .../Input/AccessKeyHandlerTests.cs | 69 ++++++++++++++----- .../ButtonTests.cs | 21 +++--- .../TabControlTests.cs | 18 ++--- 8 files changed, 106 insertions(+), 56 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index e6a73822e9..838d2bd70b 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1219,6 +1219,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.AccessText.get_AccessKey + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.OverlayPopupHost.ConfigurePosition(Avalonia.Visual,Avalonia.Controls.PlacementMode,Avalonia.Point,Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor,Avalonia.Controls.Primitives.PopupPositioning.PopupGravity,Avalonia.Controls.Primitives.PopupPositioning.PopupPositionerConstraintAdjustment,System.Nullable{Avalonia.Rect}) @@ -2131,6 +2137,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Primitives.AccessText.get_AccessKey + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Primitives.OverlayPopupHost.ConfigurePosition(Avalonia.Visual,Avalonia.Controls.PlacementMode,Avalonia.Point,Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor,Avalonia.Controls.Primitives.PopupPositioning.PopupGravity,Avalonia.Controls.Primitives.PopupPositioning.PopupPositionerConstraintAdjustment,System.Nullable{Avalonia.Rect}) diff --git a/src/Avalonia.Base/Input/AccessKeyHandler.cs b/src/Avalonia.Base/Input/AccessKeyHandler.cs index 96a4847db8..758e28e07f 100644 --- a/src/Avalonia.Base/Input/AccessKeyHandler.cs +++ b/src/Avalonia.Base/Input/AccessKeyHandler.cs @@ -122,9 +122,11 @@ namespace Avalonia.Input /// /// The access key. /// The input element. - public void Register(char accessKey, IInputElement element) + public void Register(string accessKey, IInputElement element) { - var key = NormalizeKey(accessKey.ToString()); + ArgumentException.ThrowIfNullOrEmpty(accessKey); + + var key = NormalizeKey(accessKey); // remove dead elements with matching key for (var i = _registrations.Count - 1; i >= 0; i--) @@ -224,7 +226,7 @@ namespace Avalonia.Input MainMenu?.IsOpen != true) return; - e.Handled = ProcessKey(e.Key.ToString(), e.Source as IInputElement); + e.Handled = ProcessKey(e.KeySymbol, e.Source as IInputElement); } @@ -287,8 +289,11 @@ namespace Avalonia.Input /// The access key to process. /// The element to get the targets which are in scope. /// If there matches true, otherwise false. - protected bool ProcessKey(string key, IInputElement? element) + protected bool ProcessKey(string? key, IInputElement? element) { + if (string.IsNullOrEmpty(key)) + return false; + key = NormalizeKey(key); var senderInfo = GetTargetForElement(element, key); // Find the possible targets matching the access key @@ -503,20 +508,11 @@ namespace Avalonia.Input /// internal class AccessKeyPressedEventArgs : RoutedEventArgs { - /// - /// The constructor for AccessKeyPressed event args - /// - public AccessKeyPressedEventArgs() - { - RoutedEvent = AccessKeyHandler.AccessKeyPressedEvent; - Key = null; - } - /// /// Constructor for AccessKeyPressed event args /// /// - public AccessKeyPressedEventArgs(string key) : this() + public AccessKeyPressedEventArgs(string key) { RoutedEvent = AccessKeyHandler.AccessKeyPressedEvent; Key = key; diff --git a/src/Avalonia.Base/Input/IAccessKeyHandler.cs b/src/Avalonia.Base/Input/IAccessKeyHandler.cs index aaad93eb23..418fa61f05 100644 --- a/src/Avalonia.Base/Input/IAccessKeyHandler.cs +++ b/src/Avalonia.Base/Input/IAccessKeyHandler.cs @@ -26,7 +26,7 @@ namespace Avalonia.Input /// /// The access key. /// The input element. - void Register(char accessKey, IInputElement element); + void Register(string accessKey, IInputElement element); /// /// Unregisters the access keys associated with the input element. diff --git a/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs index c98c5c9a22..5ecfb29afc 100644 --- a/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/MenuItemAutomationPeer.cs @@ -20,7 +20,7 @@ namespace Avalonia.Automation.Peers { if (Owner.HeaderPresenter?.Child is AccessText accessText) { - result = accessText.AccessKey.ToString(); + result = accessText.AccessKey; } } diff --git a/src/Avalonia.Controls/Primitives/AccessText.cs b/src/Avalonia.Controls/Primitives/AccessText.cs index c5658fcd23..7f59c9e570 100644 --- a/src/Avalonia.Controls/Primitives/AccessText.cs +++ b/src/Avalonia.Controls/Primitives/AccessText.cs @@ -4,6 +4,7 @@ using Avalonia.Reactive; using Avalonia.Media; using Avalonia.Media.TextFormatting; using System; +using System.Text; namespace Avalonia.Controls.Primitives { @@ -42,7 +43,7 @@ namespace Avalonia.Controls.Primitives /// /// Gets the access key. /// - public char AccessKey + public string? AccessKey { get; private set; @@ -93,7 +94,7 @@ namespace Avalonia.Controls.Primitives base.OnAttachedToVisualTree(e); _accessKeys = (e.Root as TopLevel)?.AccessKeyHandler; - if (_accessKeys != null && AccessKey != 0) + if (_accessKeys != null && !string.IsNullOrEmpty(AccessKey)) { _accessKeys.Register(AccessKey, this); } @@ -104,7 +105,7 @@ namespace Avalonia.Controls.Primitives { base.OnDetachedFromVisualTree(e); - if (_accessKeys != null && AccessKey != 0) + if (_accessKeys != null && !string.IsNullOrEmpty(AccessKey)) { _accessKeys.Unregister(this); _accessKeys = null; @@ -153,7 +154,7 @@ namespace Avalonia.Controls.Primitives /// The new text. private void TextChanged(string? text) { - var key = (char)0; + string? key = null; if (text != null) { @@ -161,13 +162,14 @@ namespace Avalonia.Controls.Primitives if (underscore != -1 && underscore < text.Length - 1) { - key = text[underscore + 1]; + var rune = Rune.GetRuneAt(text, underscore + 1); + key = rune.ToString(); } } AccessKey = key; - if (_accessKeys != null && AccessKey != 0) + if (_accessKeys != null && !string.IsNullOrEmpty(AccessKey)) { _accessKeys.Register(AccessKey, this); } diff --git a/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs b/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs index 6cd8650011..9443e22855 100644 --- a/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/AccessKeyHandlerTests.cs @@ -21,8 +21,8 @@ namespace Avalonia.Base.UnitTests.Input root.KeyUp += (s, e) => events.Add($"KeyUp {e.Key}"); KeyDown(root, Key.LeftAlt); - KeyDown(root, Key.A, KeyModifiers.Alt); - KeyUp(root, Key.A, KeyModifiers.Alt); + KeyDown(root, Key.A, "a", KeyModifiers.Alt); + KeyUp(root, Key.A, "a", KeyModifiers.Alt); KeyUp(root, Key.LeftAlt); Assert.Equal(new[] @@ -48,8 +48,8 @@ namespace Avalonia.Base.UnitTests.Input root.KeyUp += (s, e) => events.Add($"KeyUp {e.Key}"); KeyDown(root, Key.LeftAlt); - KeyDown(root, Key.A, KeyModifiers.Alt); - KeyUp(root, Key.A, KeyModifiers.Alt); + KeyDown(root, Key.A, "a", KeyModifiers.Alt); + KeyUp(root, Key.A, "a", KeyModifiers.Alt); KeyUp(root, Key.LeftAlt); Assert.Equal(new[] @@ -122,13 +122,13 @@ namespace Avalonia.Base.UnitTests.Input var events = new List(); target.SetOwner(root); - target.Register('A', button); + target.Register("A", button); root.KeyDown += (s, e) => events.Add($"KeyDown {e.Key}"); root.KeyUp += (s, e) => events.Add($"KeyUp {e.Key}"); KeyDown(root, Key.LeftAlt); - KeyDown(root, Key.A, KeyModifiers.Alt); - KeyUp(root, Key.A, KeyModifiers.Alt); + KeyDown(root, Key.A, "a", KeyModifiers.Alt); + KeyUp(root, Key.A, "a", KeyModifiers.Alt); KeyUp(root, Key.LeftAlt); // This differs from WPF which doesn't raise the `A` key event, but matches UWP. @@ -141,8 +141,12 @@ namespace Avalonia.Base.UnitTests.Input }, events); } - [Fact] - public void Should_Raise_AccessKey_For_Registered_Access_Key() + [Theory] + [InlineData("A", Key.A, "a")] + [InlineData("A", Key.Q, "a")] + [InlineData("é", Key.D2, "é")] + [InlineData("2", Key.D2, "2")] + public void Should_Raise_AccessKey_For_Registered_Access_Key_Matching_KeySymbol(string registered, Key key, string keySymbol) { using (UnitTestApplication.Start(TestServices.RealFocus)) { @@ -154,22 +158,51 @@ namespace Avalonia.Base.UnitTests.Input KeyboardDevice.Instance?.SetFocusedElement(button, NavigationMethod.Unspecified, KeyModifiers.None); target.SetOwner(root); - target.Register('A', button); + target.Register(registered, button); button.AddHandler(AccessKeyHandler.AccessKeyEvent, (s, e) => ++raised); KeyDown(root, Key.LeftAlt); Assert.Equal(0, raised); - KeyDown(root, Key.A, KeyModifiers.Alt); + KeyDown(root, key, keySymbol, KeyModifiers.Alt); Assert.Equal(1, raised); - KeyUp(root, Key.A, KeyModifiers.Alt); + KeyUp(root, key, keySymbol, KeyModifiers.Alt); KeyUp(root, Key.LeftAlt); Assert.Equal(1, raised); } } + [Fact] + public void Should_Not_Raise_AccessKey_For_Registered_Access_Key_Not_Matching_KeySymbol() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var button = new Button(); + var root = new TestRoot(button); + var target = new AccessKeyHandler(); + var raised = 0; + + KeyboardDevice.Instance?.SetFocusedElement(button, NavigationMethod.Unspecified, KeyModifiers.None); + + target.SetOwner(root); + target.Register("A", button); + button.AddHandler(AccessKeyHandler.AccessKeyEvent, (_, _) => ++raised); + + KeyDown(root, Key.LeftAlt); + Assert.Equal(0, raised); + + KeyDown(root, Key.A, "q", KeyModifiers.Alt); + Assert.Equal(0, raised); + + KeyUp(root, Key.A, "q", KeyModifiers.Alt); + KeyUp(root, Key.LeftAlt); + + Assert.Equal(0, raised); + } + } + [Theory] [InlineData(false, 0)] [InlineData(true, 1)] @@ -185,16 +218,16 @@ namespace Avalonia.Base.UnitTests.Input KeyboardDevice.Instance?.SetFocusedElement(button, NavigationMethod.Unspecified, KeyModifiers.None); target.SetOwner(root); - target.Register('A', button); + target.Register("A", button); button.AddHandler(AccessKeyHandler.AccessKeyEvent, (s, e) => ++raised); KeyDown(root, Key.LeftAlt); Assert.Equal(0, raised); - KeyDown(root, Key.A, KeyModifiers.Alt); + KeyDown(root, Key.A, "a", KeyModifiers.Alt); Assert.Equal(expected, raised); - KeyUp(root, Key.A, KeyModifiers.Alt); + KeyUp(root, Key.A, "a", KeyModifiers.Alt); KeyUp(root, Key.LeftAlt); Assert.Equal(expected, raised); } @@ -223,22 +256,24 @@ namespace Avalonia.Base.UnitTests.Input } } - private static void KeyDown(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None) + private static void KeyDown(IInputElement target, Key key, string? keySymbol = null, KeyModifiers modifiers = KeyModifiers.None) { target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, + KeySymbol = keySymbol, KeyModifiers = modifiers, }); } - private static void KeyUp(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None) + private static void KeyUp(IInputElement target, Key key, string? keySymbol = null, KeyModifiers modifiers = KeyModifiers.None) { target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyUpEvent, Key = key, + KeySymbol = keySymbol, KeyModifiers = modifiers, }); } diff --git a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs index c8b985afda..661791a6e4 100644 --- a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs @@ -345,16 +345,17 @@ namespace Avalonia.Controls.UnitTests Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded, TestContext.Current.CancellationToken); - var accessKey = Key.A; + const Key accessKey = Key.A; + const string accessKeySymbol = "a"; target.CommandParameter = true; - RaiseAccessKey(root, accessKey); + RaiseAccessKey(root, accessKey, accessKeySymbol); Assert.Equal(1, raised); target.CommandParameter = false; - RaiseAccessKey(root, accessKey); + RaiseAccessKey(root, accessKey, accessKeySymbol); Assert.Equal(1, raised); @@ -379,30 +380,32 @@ namespace Avalonia.Controls.UnitTests return topLevel; } - static void RaiseAccessKey(IInputElement target, Key accessKey) + static void RaiseAccessKey(IInputElement target, Key accessKey, string keySymbol) { KeyDown(target, Key.LeftAlt); - KeyDown(target, accessKey, KeyModifiers.Alt); - KeyUp(target, accessKey, KeyModifiers.Alt); - KeyUp(target, Key.LeftAlt); + KeyDown(target, accessKey, keySymbol, KeyModifiers.Alt); + KeyUp(target, accessKey, keySymbol, KeyModifiers.Alt); + KeyUp(target, Key.LeftAlt, null); } - static void KeyDown(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None) + static void KeyDown(IInputElement target, Key key, string? keySymbol = null, KeyModifiers modifiers = KeyModifiers.None) { target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, + KeySymbol = keySymbol, KeyModifiers = modifiers, }); } - static void KeyUp(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None) + static void KeyUp(IInputElement target, Key key, string? keySymbol = null, KeyModifiers modifiers = KeyModifiers.None) { target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyUpEvent, Key = key, + KeySymbol = keySymbol, KeyModifiers = modifiers, }); } diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 3ea591d361..a4cdf592cc 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -718,10 +718,10 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(Key.A, 1)] - [InlineData(Key.L, 2)] - [InlineData(Key.D, 0)] - public void Should_TabControl_Recognizes_AccessKey(Key accessKey, int selectedTabIndex) + [InlineData(Key.A, "a", 1)] + [InlineData(Key.L, "l", 2)] + [InlineData(Key.D, "d", 0)] + public void Should_TabControl_Recognizes_AccessKey(Key accessKey, string accessKeySymbol, int selectedTabIndex) { var ah = new AccessKeyHandler(); var kd = new KeyboardDevice(); @@ -760,8 +760,8 @@ namespace Avalonia.Controls.UnitTests ApplyTemplate(tabControl); KeyDown(root, Key.LeftAlt); - KeyDown(root, accessKey, KeyModifiers.Alt); - KeyUp(root, accessKey, KeyModifiers.Alt); + KeyDown(root, accessKey, accessKeySymbol, KeyModifiers.Alt); + KeyUp(root, accessKey, accessKeySymbol, KeyModifiers.Alt); KeyUp(root, Key.LeftAlt); Assert.Equal(selectedTabIndex, tabControl.SelectedIndex); @@ -788,22 +788,24 @@ namespace Avalonia.Controls.UnitTests return topLevel; } - static void KeyDown(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None) + static void KeyDown(IInputElement target, Key key, string? keySymbol = null, KeyModifiers modifiers = KeyModifiers.None) { target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, + KeySymbol = keySymbol, KeyModifiers = modifiers, }); } - static void KeyUp(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None) + static void KeyUp(IInputElement target, Key key, string? keySymbol = null, KeyModifiers modifiers = KeyModifiers.None) { target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyUpEvent, Key = key, + KeySymbol = keySymbol, KeyModifiers = modifiers, }); }