Fixes and improves several access key (accelerator) related issues (#17292)

* fix accelerator behavior for menu items and labels

* add elements with matching accelerator to test cycling in sub menus

* Add AccessKeyHandler tests for accelerators with more than one match

* Implement accelerator behavior based on WPF handling

* Remove commented code

* Remove OnAccessKey override => handled by DefaultMenuInteractionHandler

* remove obsolete test

* handle OnAccessKeyPressed for selected tab item

* fix unit tests

* use AccessKeyEvent instead of AccessKeyPressedEvent in unit tests

* navigate menu with and without ALT key

* Revert formatting changes in Tests

* Fix AccessKeyHandler comments

* move private types to bottom

* Remove lock statements, optimize removal of AccessKeyRegistrations

* remove call to Dispatcher.UIThread.Post

* simplifiy AccessKeyHandler.SortByHierarchy

* remove unnecessary method AccessKeyHandler.GetTargetsForSender

* regenerate API suppression file

* revert unneeded changes in MenuPage.axaml

* correct formatting changes

* do not sort by hierarchy if too few targets

* make AccessKeyEventArgs internal

* make AccessKeyPressedEventArgs internal

---------

Co-authored-by: Hans Docsek <hans.docsek@gmail.com>
This commit is contained in:
StefanKoell 2024-11-10 11:27:07 +01:00 коммит произвёл GitHub
Родитель 57b4be4b73
Коммит b46126714b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
18 изменённых файлов: 728 добавлений и 210 удалений

Просмотреть файл

@ -91,4 +91,10 @@
<Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
<Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0012</DiagnosticId>
<Target>M:Avalonia.Controls.Button.OnAccessKey(Avalonia.Interactivity.RoutedEventArgs)</Target>
<Left>baseline/netstandard2.0/Avalonia.Controls.dll</Left>
<Right>target/netstandard2.0/Avalonia.Controls.dll</Right>
</Suppression>
</Suppressions>

Просмотреть файл

@ -18,6 +18,9 @@
<TabItem Header="Composition">
<pages:CompositionPage />
</TabItem>
<TabItem Header="Accelerator">
<pages:AcceleratorPage />
</TabItem>
<TabItem Header="Acrylic">
<pages:AcrylicPage />
</TabItem>

Просмотреть файл

@ -0,0 +1,115 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.AcceleratorPage">
<StackPanel Orientation="Vertical" Spacing="4">
<WrapPanel HorizontalAlignment="Left">
<StackPanel>
<Menu>
<MenuItem Header="_First">
<MenuItem Header="Standard _Menu Item" InputGesture="Ctrl+A" />
<MenuItem Header="_Disabled Menu Item" IsEnabled="False" InputGesture="Ctrl+D" />
<Separator />
<MenuItem Header="Menu with Sub _Menu">
<MenuItem Header="Submenu _1" />
<MenuItem Header="Submenu _2 with Submenu">
<MenuItem Header="Submenu Level 2" />
</MenuItem>
<MenuItem Header="Submenu _3 with Submenu Disabled" IsEnabled="False">
<MenuItem Header="Submenu Level 2" />
</MenuItem>
</MenuItem>
<MenuItem Header="Menu Item with _Icon" InputGesture="Ctrl+Shift+B">
<MenuItem.Icon>
<Image Source="/Assets/github_icon.png" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Menu Item with _Checkbox" ToggleType="CheckBox" />
</MenuItem>
<MenuItem Header="_Second">
<MenuItem Header="Second _Menu Item" />
<MenuItem IsChecked="True" Header="Second _Menu toggle item" ToggleType="CheckBox" />
<Separator />
<MenuItem GroupName="A" Header="Radio 1 - group" ToggleType="Radio" />
<MenuItem IsChecked="True" GroupName="A" Header="Radio 2 - group" ToggleType="Radio" />
<MenuItem GroupName="A" Header="Radio 3 - group" ToggleType="Radio">
<MenuItem Header="Radio 4 - group" ToggleType="Radio" GroupName="A" />
<MenuItem Header="Radio 5 - group" ToggleType="Radio" GroupName="A" />
</MenuItem>
<Separator />
<MenuItem Header="Radio 1" ToggleType="Radio" />
<MenuItem IsChecked="True" Header="Radio 2" ToggleType="Radio" />
<MenuItem Header="Radio 3" ToggleType="Radio">
<MenuItem Header="Radio 4" ToggleType="Radio" />
<MenuItem Header="Radio 5" ToggleType="Radio" />
</MenuItem>
</MenuItem>
<MenuItem Header="Thir_d">
<MenuItem Header="About"/>
<MenuItem Header="_Child">
<MenuItem Header="_Grandchild"/>
</MenuItem>
</MenuItem>
</Menu>
</StackPanel>
</WrapPanel>
<StackPanel Spacing="10">
<TextBlock Classes="h2">Accelerator Support</TextBlock>
<TabControl Margin="10" BorderBrush="Gray" BorderThickness="1">
<TabItem Header="_Tab 1">
<StackPanel>
<TextBlock Margin="5">This is tab 1 content</TextBlock>
<Label Name="Tab1Label1" Target="Tab1TextBox1">_Label Tab1Label1</Label>
<TextBox Name="Tab1TextBox1" Margin="5">This is tab 1 content</TextBox>
<Label Name="Tab1Label2" Target="Tab1TextBox2">Label _Tab1Label2</Label>
<TextBox Name="Tab1TextBox2" Margin="5">This is tab 1 content</TextBox>
</StackPanel>
</TabItem>
<TabItem Header="T_ab 2">
<TextBlock Margin="5">This is tab 2 content</TextBlock>
</TabItem>
<TabItem Header="_Tab 3">
</TabItem>
<TabItem Header="_Tab 4">
<TextBlock Margin="5">This is tab 4 content</TextBlock>
</TabItem>
<TabItem Header="_Fab 5">
<TextBlock Margin="5">This is fab 5 content</TextBlock>
</TabItem>
</TabControl>
</StackPanel>
<StackPanel Spacing="10">
<Label Name="Label0">Label with Ac_celerator 'C' and no Target</Label>
<TextBox Name="TextBox0" Text="Some Text"></TextBox>
<Label Name="Label1" Target="TextBox1">_Label with Accelerator 'L'</Label>
<TextBox Name="TextBox1" Text="Some Text"></TextBox>
<Label Name="Label2" Target="TextBox2">La_bel with Accelerator 'B'</Label>
<TextBox Name="TextBox2" Text="Some Text"></TextBox>
<Label Name="Label3" Target="TextBox3">L_abel with Accelerator 'A'</Label>
<TextBox Name="TextBox3" Text="Some Text"></TextBox>
<Label Name="Label4" Target="TextBox4">La_bel with Accelerator 'B'</Label>
<TextBox Name="TextBox4" Text="Some Text"></TextBox>
<Label Name="Label5" Target="TextBox5">_Flabel with Accelerator 'F' (Same as in Menu > File)</Label>
<TextBox Name="TextBox5" Text="Some Text"></TextBox>
</StackPanel>
<StackPanel Spacing="10" Orientation="Horizontal">
<Button Name="Button1">_Button 1</Button>
<Button Name="Button2">_Button 2</Button>
<Button Name="Button3">_Button 3</Button>
</StackPanel>
</StackPanel>
</UserControl>

Просмотреть файл

@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ControlCatalog.Pages
{
public class AcceleratorPage : UserControl
{
public AcceleratorPage()
{
this.InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}

Просмотреть файл

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.VisualTree;
namespace Avalonia.Input
{
@ -11,11 +12,20 @@ namespace Avalonia.Input
/// </summary>
internal class AccessKeyHandler : IAccessKeyHandler
{
/// <summary>
/// Defines the AccessKey attached event.
/// </summary>
public static readonly RoutedEvent<AccessKeyEventArgs> AccessKeyEvent =
RoutedEvent.Register<AccessKeyEventArgs>(
"AccessKey",
RoutingStrategies.Bubble,
typeof(AccessKeyHandler));
/// <summary>
/// Defines the AccessKeyPressed attached event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> AccessKeyPressedEvent =
RoutedEvent.Register<RoutedEventArgs>(
public static readonly RoutedEvent<AccessKeyPressedEventArgs> AccessKeyPressedEvent =
RoutedEvent.Register<AccessKeyPressedEventArgs>(
"AccessKeyPressed",
RoutingStrategies.Bubble,
typeof(AccessKeyHandler));
@ -23,7 +33,9 @@ namespace Avalonia.Input
/// <summary>
/// The registered access keys.
/// </summary>
private readonly List<(string AccessKey, IInputElement Element)> _registered = new();
private readonly List<AccessKeyRegistration> _registrations = [];
protected IReadOnlyList<AccessKeyRegistration> Registrations => _registrations;
/// <summary>
/// The window to which the handler belongs.
@ -48,7 +60,7 @@ namespace Avalonia.Input
/// <summary>
/// Element to restore following AltKey taking focus.
/// </summary>
private IInputElement? _restoreFocusElement;
private WeakReference<IInputElement>? _restoreFocusElementRef;
/// <summary>
/// The window's main menu.
@ -97,6 +109,12 @@ namespace Avalonia.Input
_owner.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble);
_owner.AddHandler(InputElement.KeyUpEvent, OnPreviewKeyUp, RoutingStrategies.Tunnel);
_owner.AddHandler(InputElement.PointerPressedEvent, OnPreviewPointerPressed, RoutingStrategies.Tunnel);
OnSetOwner(owner);
}
protected virtual void OnSetOwner(IInputRoot owner)
{
}
/// <summary>
@ -106,14 +124,19 @@ namespace Avalonia.Input
/// <param name="element">The input element.</param>
public void Register(char accessKey, IInputElement element)
{
var existing = _registered.FirstOrDefault(x => x.Item2 == element);
if (existing != default)
var key = NormalizeKey(accessKey.ToString());
// remove dead elements with matching key
for (var i = _registrations.Count - 1; i >= 0; i--)
{
_registered.Remove(existing);
var registration = _registrations[i];
if (registration.Key == key && registration.GetInputElement() == null)
{
_registrations.RemoveAt(i);
}
}
_registered.Add((accessKey.ToString().ToUpperInvariant(), element));
_registrations.Add(new AccessKeyRegistration(key, new WeakReference<IInputElement>(element)));
}
/// <summary>
@ -122,9 +145,15 @@ namespace Avalonia.Input
/// <param name="element">The input element.</param>
public void Unregister(IInputElement element)
{
foreach (var i in _registered.Where(x => x.Item2 == element).ToList())
// remove element and all dead elements
for (var i = _registrations.Count - 1; i >= 0; i--)
{
_registered.Remove(i);
var registration = _registrations[i];
var inputElement = registration.GetInputElement();
if (inputElement == null || inputElement == element)
{
_registrations.RemoveAt(i);
}
}
}
@ -135,21 +164,29 @@ namespace Avalonia.Input
/// <param name="e">The event args.</param>
protected virtual void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.LeftAlt || e.Key == Key.RightAlt)
// if the owner (IInputRoot) does not have the keyboard focus, ignore all keyboard events
// KeyboardDevice.IsKeyboardFocusWithin in case of a PopupRoot seems to only work once, so we created our own
var isFocusWithinOwner = IsFocusWithinOwner(_owner!);
if (!isFocusWithinOwner)
return;
if (e.Key is Key.LeftAlt or Key.RightAlt)
{
_altIsDown = true;
if (MainMenu == null || !MainMenu.IsOpen)
if (MainMenu is not { IsOpen: true })
{
var focusManager = FocusManager.GetFocusManager(e.Source as IInputElement);
// TODO: Use FocusScopes to store the current element and restore it when context menu is closed.
// Save currently focused input element.
_restoreFocusElement = focusManager?.GetFocusedElement();
var focusedElement = focusManager?.GetFocusedElement();
if (focusedElement is not null)
_restoreFocusElementRef = new WeakReference<IInputElement>(focusedElement);
// When Alt is pressed without a main menu, or with a closed main menu, show
// access key markers in the window (i.e. "_File").
_owner!.ShowAccessKeys = _showingAccessKeys = true;
_owner!.ShowAccessKeys = _showingAccessKeys = isFocusWithinOwner;
}
else
{
@ -157,8 +194,11 @@ namespace Avalonia.Input
CloseMenu();
_ignoreAltUp = true;
_restoreFocusElement?.Focus();
_restoreFocusElement = null;
if (_restoreFocusElementRef?.TryGetTarget(out var restoreElement) ?? false)
{
restoreElement.Focus();
}
_restoreFocusElementRef = null;
}
}
else if (_altIsDown)
@ -174,35 +214,20 @@ namespace Avalonia.Input
/// <param name="e">The event args.</param>
protected virtual void OnKeyDown(object? sender, KeyEventArgs e)
{
bool menuIsOpen = MainMenu?.IsOpen == true;
// if the owner (IInputRoot) does not have the keyboard focus, ignore all keyboard events
// KeyboardDevice.IsKeyboardFocusWithin in case of a PopupRoot seems to only work once, so we created our own
var isFocusWithinOwner = IsFocusWithinOwner(_owner!);
if (!isFocusWithinOwner)
return;
if (e.KeyModifiers.HasAllFlags(KeyModifiers.Alt) && !e.KeyModifiers.HasAllFlags(KeyModifiers.Control) || menuIsOpen)
{
// If any other key is pressed with the Alt key held down, or the main menu is open,
// find all controls who have registered that access key.
var text = e.Key.ToString();
var matches = _registered
.Where(x => string.Equals(x.AccessKey, text, StringComparison.OrdinalIgnoreCase)
&& x.Element.IsEffectivelyVisible
&& x.Element.IsEffectivelyEnabled)
.Select(x => x.Element);
if ((!e.KeyModifiers.HasAllFlags(KeyModifiers.Alt) || e.KeyModifiers.HasAllFlags(KeyModifiers.Control)) &&
MainMenu?.IsOpen != true)
return;
// If the menu is open, only match controls in the menu's visual tree.
if (menuIsOpen)
{
matches = matches.Where(x => x is not null && ((Visual)MainMenu!).IsLogicalAncestorOf((Visual)x));
}
var match = matches.FirstOrDefault();
// If there was a match, raise the AccessKeyPressed event on it.
if (match is not null)
{
match.RaiseEvent(new RoutedEventArgs(AccessKeyPressedEvent));
}
}
e.Handled = ProcessKey(e.Key.ToString(), e.Source as IInputElement);
}
/// <summary>
/// Handles the Alt/F10 keys being released in the window.
/// </summary>
@ -255,5 +280,302 @@ namespace Avalonia.Input
{
_owner!.ShowAccessKeys = false;
}
/// <summary>
/// Processes the given key for the element's targets
/// </summary>
/// <param name="key">The access key to process.</param>
/// <param name="element">The element to get the targets which are in scope.</param>
/// <returns>If there matches <c>true</c>, otherwise <c>false</c>.</returns>
protected bool ProcessKey(string key, IInputElement? element)
{
key = NormalizeKey(key);
var senderInfo = GetTargetForElement(element, key);
// Find the possible targets matching the access key
var targets = SortByHierarchy(GetTargetsForKey(key, element, senderInfo));
var result = ProcessKey(key, targets);
return result != ProcessKeyResult.NoMatch;
}
private static string NormalizeKey(string key) => key.ToUpperInvariant();
private static ProcessKeyResult ProcessKey(string key, List<IInputElement> targets)
{
if (!targets.Any())
return ProcessKeyResult.NoMatch;
var isSingleTarget = true;
var lastWasFocused = false;
IInputElement? effectiveTarget = null;
var chosenIndex = 0;
for (var i = 0; i < targets.Count; i++)
{
var target = targets[i];
if (!IsTargetable(target))
continue;
if (effectiveTarget == null)
{
effectiveTarget = target;
chosenIndex = i;
}
else
{
if (lastWasFocused)
{
effectiveTarget = target;
chosenIndex = i;
}
isSingleTarget = false;
}
lastWasFocused = target.IsFocused;
}
if (effectiveTarget == null)
return ProcessKeyResult.NoMatch;
var args = new AccessKeyEventArgs(key, isMultiple: !isSingleTarget);
effectiveTarget.RaiseEvent(args);
return chosenIndex == targets.Count - 1 ? ProcessKeyResult.LastMatch : ProcessKeyResult.MoreMatches;
}
private List<IInputElement> GetTargetsForKey(string key, IInputElement? sender,
AccessKeyInformation senderInfo)
{
var possibleElements = CopyMatchingAndPurgeDead(key);
if (!possibleElements.Any())
return possibleElements;
var finalTargets = new List<IInputElement>(1);
// Go through all the possible elements, find the interesting candidates
foreach (var element in possibleElements)
{
if (element != sender)
{
if (!IsTargetable(element))
continue;
var elementInfo = GetTargetForElement(element, key);
if (elementInfo.Target == null)
continue;
finalTargets.Add(elementInfo.Target);
}
else
{
// This is the same element that sent the event so it must be in the same scope.
// Just add it to the final targets
if (senderInfo.Target == null)
continue;
finalTargets.Add(senderInfo.Target);
}
}
return finalTargets;
}
private static bool IsTargetable(IInputElement element) =>
element is { IsEffectivelyEnabled: true, IsEffectivelyVisible: true };
private List<IInputElement> CopyMatchingAndPurgeDead(string key)
{
var matches = new List<IInputElement>(_registrations.Count);
// collect live elements with matching key and remove dead elements
for (var i = _registrations.Count - 1; i >= 0; i--)
{
var registration = _registrations[i];
var inputElement = registration.GetInputElement();
if (inputElement != null)
{
if (registration.Key == key)
{
matches.Add(inputElement);
}
}
else
{
_registrations.RemoveAt(i);
}
}
// since we collected the elements when iterating from back to front
// we need to reverse them to ensure the original order
matches.Reverse();
return matches;
}
/// <summary>
/// Returns targeting information for the given element.
/// </summary>
/// <param name="element"></param>
/// <param name="key"></param>
/// <returns>AccessKeyInformation with target for the access key.</returns>
private static AccessKeyInformation GetTargetForElement(IInputElement? element, string key)
{
var info = new AccessKeyInformation();
if (element == null)
return info;
var args = new AccessKeyPressedEventArgs(key);
element.RaiseEvent(args);
info.Target = args.Target;
return info;
}
/// <summary>
/// Checks if the focused element is a descendent of the owner.
/// </summary>
/// <param name="owner">The owner to check.</param>
/// <returns>If focused element is decendant of owner <c>true</c>, otherwise <c>false</c>. </returns>
private static bool IsFocusWithinOwner(IInputRoot owner)
{
var focusedElement = KeyboardDevice.Instance?.FocusedElement;
if (focusedElement is not InputElement inputElement)
return false;
var isAncestorOf = owner is Visual root && root.IsVisualAncestorOf(inputElement);
return isAncestorOf;
}
/// <summary>
/// Sorts the list of targets according to logical ancestors in the hierarchy
/// so that child elements, for example within in the content of a tab,
/// are processed before the next parent item i.e. the next tab item.
/// </summary>
private static List<IInputElement> SortByHierarchy(List<IInputElement> targets)
{
// bail out, if there are no targets to sort
if (targets.Count <= 1)
return targets;
var sorted = new List<IInputElement>(targets.Count);
var queue = new Queue<IInputElement>(targets);
while (queue.Count > 0)
{
var element = queue.Dequeue();
// if the element was already added, do nothing
if (sorted.Contains(element))
continue;
// add the element itself
sorted.Add(element);
// if the element is not a potential parent, do nothing
if (element is not ILogical parentElement)
continue;
// add all descendants of the element
sorted.AddRange(queue
.Where(child => parentElement
.IsLogicalAncestorOf(child as ILogical)));
}
return sorted;
}
private enum ProcessKeyResult
{
NoMatch,
MoreMatches,
LastMatch
}
private struct AccessKeyInformation
{
public IInputElement? Target { get; set; }
}
}
/// <summary>
/// The inputs to an AccessKeyPressedEventHandler
/// </summary>
internal class AccessKeyPressedEventArgs : RoutedEventArgs
{
/// <summary>
/// The constructor for AccessKeyPressed event args
/// </summary>
public AccessKeyPressedEventArgs()
{
RoutedEvent = AccessKeyHandler.AccessKeyPressedEvent;
Key = null;
}
/// <summary>
/// Constructor for AccessKeyPressed event args
/// </summary>
/// <param name="key"></param>
public AccessKeyPressedEventArgs(string key) : this()
{
RoutedEvent = AccessKeyHandler.AccessKeyPressedEvent;
Key = key;
}
/// <summary>
/// Target element for the element that raised this event.
/// </summary>
/// <value></value>
public IInputElement? Target { get; set; }
/// <summary>
/// Key that was pressed
/// </summary>
/// <value></value>
public string? Key { get; }
}
/// <summary>
/// Information pertaining to when the access key associated with an element is pressed
/// </summary>
internal class AccessKeyEventArgs : RoutedEventArgs
{
/// <summary>
/// Constructor
/// </summary>
internal AccessKeyEventArgs(string key, bool isMultiple)
{
RoutedEvent = AccessKeyHandler.AccessKeyEvent;
Key = key;
IsMultiple = isMultiple;
}
/// <summary>
/// The key that was pressed which invoked this access key
/// </summary>
/// <value></value>
public string Key { get; }
/// <summary>
/// Were there other elements which are also invoked by this key
/// </summary>
/// <value></value>
public bool IsMultiple { get; }
}
internal class AccessKeyRegistration
{
private readonly WeakReference<IInputElement> _target;
public string Key { get; }
public AccessKeyRegistration(string key, WeakReference<IInputElement> target)
{
_target = target;
Key = key;
}
public IInputElement? GetInputElement() =>
_target.TryGetTarget(out var target) ? target : null;
}
}

Просмотреть файл

@ -1,17 +1,15 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Data;
using Avalonia.Input.GestureRecognizers;
using Avalonia.Input.TextInput;
using Avalonia.Interactivity;
using Avalonia.Reactive;
using Avalonia.VisualTree;
#nullable enable
namespace Avalonia.Input
{
/// <summary>
@ -231,6 +229,10 @@ namespace Avalonia.Input
PointerPressedEvent.AddClassHandler<InputElement>((x, e) => x.OnGesturePointerPressed(e), handledEventsToo: true);
PointerReleasedEvent.AddClassHandler<InputElement>((x, e) => x.OnGesturePointerReleased(e), handledEventsToo: true);
PointerCaptureLostEvent.AddClassHandler<InputElement>((x, e) => x.OnGesturePointerCaptureLost(e), handledEventsToo: true);
// Access Key Handling
AccessKeyHandler.AccessKeyEvent.AddClassHandler<InputElement>((x, e) => x.OnAccessKey(e));
}
public InputElement()
@ -282,7 +284,7 @@ namespace Avalonia.Input
add { AddHandler(TextInputEvent, value); }
remove { RemoveHandler(TextInputEvent, value); }
}
/// <summary>
/// Occurs when an input element gains input focus and input method is looking for the corresponding client
/// </summary>
@ -346,7 +348,7 @@ namespace Avalonia.Input
add => AddHandler(PointerCaptureLostEvent, value);
remove => RemoveHandler(PointerCaptureLostEvent, value);
}
/// <summary>
/// Occurs when the mouse is scrolled over the control.
/// </summary>
@ -355,7 +357,7 @@ namespace Avalonia.Input
add { AddHandler(PointerWheelChangedEvent, value); }
remove { RemoveHandler(PointerWheelChangedEvent, value); }
}
/// <summary>
/// Occurs when a tap gesture occurs on the control.
/// </summary>
@ -364,7 +366,7 @@ namespace Avalonia.Input
add { AddHandler(TappedEvent, value); }
remove { RemoveHandler(TappedEvent, value); }
}
/// <summary>
/// Occurs when a hold gesture occurs on the control.
/// </summary>
@ -409,7 +411,7 @@ namespace Avalonia.Input
get { return GetValue(CursorProperty); }
set { SetValue(CursorProperty, value); }
}
/// <summary>
/// Gets a value indicating whether keyboard focus is anywhere within the element or its visual tree child elements.
/// </summary>
@ -515,6 +517,17 @@ namespace Avalonia.Input
}
}
/// <summary>
/// This method is used to execute the action on an effective IInputElement when a corresponding access key has been invoked.
/// By default, the Focus() method is invoked with the NavigationMethod.Tab to indicate a visual focus adorner.
/// Overwrite this method if other methods or additional functionality is needed when an item should receive the focus.
/// </summary>
/// <param name="e">AccessKeyEventArgs are passed on to indicate if there are multiple matches or not.</param>
protected virtual void OnAccessKey(RoutedEventArgs e)
{
Focus(NavigationMethod.Tab);
}
/// <inheritdoc/>
protected override void OnAttachedToVisualTreeCore(VisualTreeAttachmentEventArgs e)
{

Просмотреть файл

@ -104,7 +104,7 @@ namespace Avalonia.Controls
static Button()
{
FocusableProperty.OverrideDefaultValue(typeof(Button), true);
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<Button>((lbl, args) => lbl.OnAccessKey(args));
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<Button>(OnAccessKeyPressed);
}
/// <summary>
@ -199,7 +199,7 @@ namespace Avalonia.Controls
/// <inheritdoc/>
protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute;
/// <inheritdoc/>
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
@ -278,7 +278,18 @@ namespace Avalonia.Controls
}
}
protected virtual void OnAccessKey(RoutedEventArgs e) => OnClick();
/// <inheritdoc />
protected override void OnAccessKey(RoutedEventArgs e)
{
if (e is AccessKeyEventArgs { IsMultiple: true })
{
base.OnAccessKey(e);
}
else
{
OnClick();
}
}
/// <inheritdoc/>
protected override void OnKeyDown(KeyEventArgs e)
@ -563,6 +574,14 @@ namespace Avalonia.Controls
internal void PerformClick() => OnClick();
private static void OnAccessKeyPressed(Button sender, AccessKeyPressedEventArgs e)
{
if (e.Handled || e.Target is not null)
return;
e.Target = sender;
e.Handled = true;
}
/// <summary>
/// Called when the <see cref="ICommand.CanExecuteChanged"/> event fires.
/// </summary>

Просмотреть файл

@ -28,9 +28,8 @@ namespace Avalonia.Controls
static Label()
{
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<Label>((lbl, args) => lbl.LabelActivated(args));
// IsTabStopProperty.OverrideDefaultValue<Label>(false)
FocusableProperty.OverrideDefaultValue<Label>(false);
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<Label>(OnAccessKeyPressed);
IsTabStopProperty.OverrideDefaultValue<Label>(false);
}
/// <summary>
@ -40,13 +39,11 @@ namespace Avalonia.Controls
{
}
/// <summary>
/// Method which focuses <see cref="Target"/> input element
/// </summary>
private void LabelActivated(RoutedEventArgs args)
/// <inheritdoc />
protected override void OnAccessKey(RoutedEventArgs e)
{
Target?.Focus();
args.Handled = Target != null;
LabelActivated(e);
}
/// <summary>
@ -62,9 +59,25 @@ namespace Avalonia.Controls
base.OnPointerPressed(e);
}
/// <inheritdoc />
protected override AutomationPeer OnCreateAutomationPeer()
{
return new LabelAutomationPeer(this);
}
private void LabelActivated(RoutedEventArgs e)
{
Target?.Focus();
e.Handled = Target != null;
}
private static void OnAccessKeyPressed(Label label, AccessKeyPressedEventArgs e)
{
if (e is not { Handled: false, Target: null })
return;
e.Target = label.Target;
e.Handled = true;
}
}
}

Просмотреть файл

@ -40,8 +40,9 @@ namespace Avalonia.Controls
KeyboardNavigationMode.Once);
AutomationProperties.AccessibilityViewProperty.OverrideDefaultValue<Menu>(AccessibilityView.Control);
AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue<Menu>(AutomationControlType.Menu);
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<Menu>(OnAccessKeyPressed);
}
/// <inheritdoc/>
public override void Close()
{
@ -104,5 +105,14 @@ namespace Avalonia.Controls
if ((element as MenuItem)?.ItemContainerTheme == ItemContainerTheme)
element.ClearValue(ItemContainerThemeProperty);
}
private static void OnAccessKeyPressed(Menu sender, AccessKeyPressedEventArgs e)
{
if (e.Handled || e.Source is not StyledElement target)
return;
e.Target = DefaultMenuInteractionHandler.GetMenuItemCore(target);
e.Handled = true;
}
}
}

Просмотреть файл

@ -143,6 +143,7 @@ namespace Avalonia.Controls
ClickEvent.AddClassHandler<MenuItem>((x, e) => x.OnClick(e));
SubmenuOpenedEvent.AddClassHandler<MenuItem>((x, e) => x.OnSubmenuOpened(e));
AutomationProperties.IsOffscreenBehaviorProperty.OverrideDefaultValue<MenuItem>(IsOffscreenBehavior.FromClip);
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<MenuItem>(OnAccessKeyPressed);
}
public MenuItem()
@ -565,7 +566,7 @@ namespace Avalonia.Controls
}
}
}
/// <summary>
/// Closes all submenus of the menu item.
/// </summary>
@ -603,6 +604,15 @@ namespace Avalonia.Controls
}
}
private static void OnAccessKeyPressed(MenuItem sender, AccessKeyPressedEventArgs e)
{
if (e is not { Handled: false, Target: null })
return;
e.Target = sender;
e.Handled = true;
}
/// <summary>
/// Called when the <see cref="CommandParameter"/> property changes.

Просмотреть файл

@ -1,82 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Input;
using Avalonia.Interactivity;
namespace Avalonia.Controls
{
/// <summary>
/// Handles access keys within a <see cref="MenuItem"/>
/// </summary>
internal class MenuItemAccessKeyHandler : IAccessKeyHandler
internal class MenuItemAccessKeyHandler : AccessKeyHandler
{
/// <summary>
/// The registered access keys.
/// </summary>
private readonly List<(string AccessKey, IInputElement Element)> _registered = new();
/// <summary>
/// The window to which the handler belongs.
/// </summary>
private IInputRoot? _owner;
/// <summary>
/// Gets or sets the window's main menu.
/// </summary>
/// <remarks>
/// This property is ignored as a menu item cannot have a main menu.
/// </remarks>
public IMainMenu? MainMenu { get; set; }
/// <summary>
/// Sets the owner of the access key handler.
/// </summary>
/// <param name="owner">The owner.</param>
/// <remarks>
/// This method can only be called once, typically by the owner itself on creation.
/// </remarks>
public void SetOwner(IInputRoot owner)
protected override void OnSetOwner(IInputRoot owner)
{
_ = owner ?? throw new ArgumentNullException(nameof(owner));
if (_owner != null)
{
throw new InvalidOperationException("AccessKeyHandler owner has already been set.");
}
_owner = owner;
_owner.AddHandler(InputElement.TextInputEvent, OnTextInput);
}
/// <summary>
/// Registers an input element to be associated with an access key.
/// </summary>
/// <param name="accessKey">The access key.</param>
/// <param name="element">The input element.</param>
public void Register(char accessKey, IInputElement element)
{
var existing = _registered.FirstOrDefault(x => x.Item2 == element);
if (existing != default)
{
_registered.Remove(existing);
}
_registered.Add((accessKey.ToString().ToUpperInvariant(), element));
}
/// <summary>
/// Unregisters the access keys associated with the input element.
/// </summary>
/// <param name="element">The input element.</param>
public void Unregister(IInputElement element)
{
foreach (var i in _registered.Where(x => x.Item2 == element).ToList())
{
_registered.Remove(i);
}
owner.AddHandler(InputElement.TextInputEvent, OnTextInput);
}
/// <summary>
@ -86,17 +21,17 @@ namespace Avalonia.Controls
/// <param name="e">The event args.</param>
protected virtual void OnTextInput(object? sender, TextInputEventArgs e)
{
if (!string.IsNullOrWhiteSpace(e.Text))
{
var text = e.Text;
var focus = _registered
.FirstOrDefault(x => string.Equals(x.AccessKey, text, StringComparison.OrdinalIgnoreCase)
&& x.Element.IsEffectivelyVisible).Element;
if (string.IsNullOrWhiteSpace(e.Text))
return;
var key = e.Text;
var registration = Registrations
.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
if (registration == null)
return;
focus?.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent));
e.Handled = true;
}
e.Handled = ProcessKey(key, registration.GetInputElement());
}
}
}

Просмотреть файл

@ -80,12 +80,16 @@ namespace Avalonia.Controls.Platform
protected internal virtual void AccessKeyPressed(object? sender, RoutedEventArgs e)
{
var item = GetMenuItemCore(e.Source as Control);
if (item is null)
return;
if (item == null)
if (e is AccessKeyEventArgs { IsMultiple: true })
{
// in case we have multiple matches, only focus item and bail
item.Focus(NavigationMethod.Tab);
return;
}
if (item.HasSubMenu && item.IsEffectivelyEnabled)
{
Open(item, true);
@ -290,7 +294,7 @@ namespace Avalonia.Controls.Platform
Menu.KeyDown += KeyDown;
Menu.PointerPressed += PointerPressed;
Menu.PointerReleased += PointerReleased;
Menu.AddHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed);
Menu.AddHandler(AccessKeyHandler.AccessKeyEvent, AccessKeyPressed);
Menu.AddHandler(MenuBase.OpenedEvent, MenuOpened);
Menu.AddHandler(MenuItem.PointerEnteredItemEvent, PointerEntered);
Menu.AddHandler(MenuItem.PointerExitedItemEvent, PointerExited);
@ -332,7 +336,7 @@ namespace Avalonia.Controls.Platform
Menu.KeyDown -= KeyDown;
Menu.PointerPressed -= PointerPressed;
Menu.PointerReleased -= PointerReleased;
Menu.RemoveHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed);
Menu.RemoveHandler(AccessKeyHandler.AccessKeyEvent, AccessKeyPressed);
Menu.RemoveHandler(MenuBase.OpenedEvent, MenuOpened);
Menu.RemoveHandler(MenuItem.PointerEnteredItemEvent, PointerEntered);
Menu.RemoveHandler(MenuItem.PointerExitedItemEvent, PointerExited);

Просмотреть файл

@ -40,7 +40,7 @@ namespace Avalonia.Controls
DataContextProperty.Changed.AddClassHandler<TabItem>((x, e) => x.UpdateHeader(e));
AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue<TabItem>(AutomationControlType.TabItem);
AutomationProperties.IsOffscreenBehaviorProperty.OverrideDefaultValue<TabItem>(IsOffscreenBehavior.FromClip);
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<TabItem>((tabItem, args) => tabItem.TabItemActivated(args));
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<TabItem>(OnAccessKeyPressed);
}
/// <summary>
@ -61,13 +61,31 @@ namespace Avalonia.Controls
set => SetValue(IsSelectedProperty, value);
}
/// <inheritdoc />
protected override void OnAccessKey(RoutedEventArgs e)
{
Focus();
SetCurrentValue(IsSelectedProperty, true);
e.Handled = true;
}
protected override AutomationPeer OnCreateAutomationPeer() => new ListItemAutomationPeer(this);
[Obsolete("Owner manages its children properties by itself")]
protected void SubscribeToOwnerProperties(AvaloniaObject owner)
{
}
private static void OnAccessKeyPressed(TabItem tabItem, AccessKeyPressedEventArgs e)
{
if (e.Handled || (e.Target != null && tabItem.IsSelected))
return;
e.Target = tabItem;
e.Handled = true;
}
private void UpdateHeader(AvaloniaPropertyChangedEventArgs obj)
{
if (Header == null)
@ -95,11 +113,5 @@ namespace Avalonia.Controls
}
}
}
private void TabItemActivated(RoutedEventArgs args)
{
SetCurrentValue(IsSelectedProperty, true);
args.Handled = true;
}
}
}

Просмотреть файл

@ -142,68 +142,85 @@ namespace Avalonia.Base.UnitTests.Input
}
[Fact]
public void Should_Raise_AccessKeyPressed_For_Registered_Access_Key()
public void Should_Raise_AccessKey_For_Registered_Access_Key()
{
var button = new Button();
var root = new TestRoot(button);
var target = new AccessKeyHandler();
var raised = 0;
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var button = new Button();
var root = new TestRoot(button);
var target = new AccessKeyHandler();
var raised = 0;
target.SetOwner(root);
target.Register('A', button);
button.AddHandler(AccessKeyHandler.AccessKeyPressedEvent, (s, e) => ++raised);
KeyboardDevice.Instance?.SetFocusedElement(button, NavigationMethod.Unspecified, KeyModifiers.None);
KeyDown(root, Key.LeftAlt);
Assert.Equal(0, raised);
target.SetOwner(root);
target.Register('A', button);
button.AddHandler(AccessKeyHandler.AccessKeyEvent, (s, e) => ++raised);
KeyDown(root, Key.A, KeyModifiers.Alt);
Assert.Equal(1, raised);
KeyDown(root, Key.LeftAlt);
Assert.Equal(0, raised);
KeyUp(root, Key.A, KeyModifiers.Alt);
KeyUp(root, Key.LeftAlt);
KeyDown(root, Key.A, KeyModifiers.Alt);
Assert.Equal(1, raised);
Assert.Equal(1, raised);
KeyUp(root, Key.A, KeyModifiers.Alt);
KeyUp(root, Key.LeftAlt);
Assert.Equal(1, raised);
}
}
[Fact]
public void Should_Not_Raise_AccessKeyPressed_For_Registered_Access_Key_When_Not_Effectively_Enabled()
[Theory]
[InlineData(false, 0)]
[InlineData(true, 1)]
public void Should_Raise_AccessKey_For_Registered_Access_Key_When_Effectively_Enabled(bool enabled, int expected)
{
var button = new Button();
var root = new TestRoot(button) { IsEnabled = false };
var target = new AccessKeyHandler();
var raised = 0;
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var button = new Button();
var root = new TestRoot(button) { IsEnabled = enabled };
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, (s, e) => ++raised);
target.SetOwner(root);
target.Register('A', button);
button.AddHandler(AccessKeyHandler.AccessKeyPressedEvent, (s, e) => ++raised);
KeyDown(root, Key.LeftAlt);
Assert.Equal(0, raised);
KeyDown(root, Key.LeftAlt);
Assert.Equal(0, raised);
KeyDown(root, Key.A, KeyModifiers.Alt);
Assert.Equal(expected, raised);
KeyDown(root, Key.A, KeyModifiers.Alt);
Assert.Equal(0, raised);
KeyUp(root, Key.A, KeyModifiers.Alt);
KeyUp(root, Key.LeftAlt);
Assert.Equal(0, raised);
KeyUp(root, Key.A, KeyModifiers.Alt);
KeyUp(root, Key.LeftAlt);
Assert.Equal(expected, raised);
}
}
[Fact]
public void Should_Open_MainMenu_On_Alt_KeyUp()
{
var root = new TestRoot();
var target = new AccessKeyHandler();
var menu = new Mock<IMainMenu>();
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var target = new AccessKeyHandler();
var menu = new FakeMenu();
var root = new TestRoot(menu);
target.SetOwner(root);
target.MainMenu = menu.Object;
KeyboardDevice.Instance?.SetFocusedElement(menu, NavigationMethod.Unspecified,
KeyModifiers.None);
KeyDown(root, Key.LeftAlt);
menu.Verify(x => x.Open(), Times.Never);
target.SetOwner(root);
target.MainMenu = menu;
KeyUp(root, Key.LeftAlt);
menu.Verify(x => x.Open(), Times.Once);
KeyDown(root, Key.LeftAlt);
Assert.Equal(0, menu.TimesOpenCalled);
KeyUp(root, Key.LeftAlt);
Assert.Equal(1, menu.TimesOpenCalled);
}
}
private static void KeyDown(IInputElement target, Key key, KeyModifiers modifiers = KeyModifiers.None)
@ -225,5 +242,15 @@ namespace Avalonia.Base.UnitTests.Input
KeyModifiers = modifiers,
});
}
class FakeMenu : Menu
{
public int TimesOpenCalled { get; set; }
public override void Open()
{
TimesOpenCalled++;
}
}
}
}

Просмотреть файл

@ -1,7 +1,5 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.UnitTests;
using Xunit;
@ -177,10 +175,10 @@ namespace Avalonia.Base.UnitTests.Input
};
target.Focus();
target.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent));
target.RaiseEvent(new AccessKeyEventArgs("b1", false));
Assert.False(target.IsEnabled);
Assert.False(target.IsFocused);
target1.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent));
target1.RaiseEvent(new AccessKeyEventArgs("b2", false));
Assert.True(target.IsEnabled);
Assert.False(target.IsFocused);
}

Просмотреть файл

@ -293,9 +293,9 @@ namespace Avalonia.Controls.UnitTests
var raised = 0;
target.Click += (s, e) => ++raised;
target.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent));
target.RaiseEvent(new AccessKeyEventArgs("b", false));
Assert.Equal(1, raised);
}
@ -304,7 +304,12 @@ namespace Avalonia.Controls.UnitTests
{
var raised = 0;
var ah = new AccessKeyHandler();
using var app = UnitTestApplication.Start(TestServices.StyledWindow.With(accessKeyHandler: ah));
var kd = new KeyboardDevice();
using var app = UnitTestApplication.Start(TestServices.StyledWindow
.With(
accessKeyHandler: ah,
keyboardDevice: () => kd)
);
var impl = CreateMockTopLevelImpl();
var command = new TestCommand(p => p is bool value && value, _ => raised++);
@ -329,6 +334,8 @@ namespace Avalonia.Controls.UnitTests
})
},
};
kd.SetFocusedElement(target, NavigationMethod.Unspecified, KeyModifiers.None);
root.ApplyTemplate();
root.Presenter.UpdateChild();
@ -349,7 +356,7 @@ namespace Avalonia.Controls.UnitTests
RaiseAccessKey(root, accessKey);
Assert.Equal(1, raised);
static FuncControlTemplate<TestTopLevel> CreateTemplate()
{
return new FuncControlTemplate<TestTopLevel>((x, scope) =>
@ -409,7 +416,7 @@ namespace Avalonia.Controls.UnitTests
target.IsEnabled = false;
target.Click += (s, e) => ++raised;
target.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent));
target.RaiseEvent(new AccessKeyEventArgs("b", false));
Assert.Equal(0, raised);
}

Просмотреть файл

@ -724,7 +724,12 @@ namespace Avalonia.Controls.UnitTests
public void Should_TabControl_Recognizes_AccessKey(Key accessKey, int selectedTabIndex)
{
var ah = new AccessKeyHandler();
using (UnitTestApplication.Start(TestServices.StyledWindow.With(accessKeyHandler: ah)))
var kd = new KeyboardDevice();
using (UnitTestApplication.Start(TestServices.StyledWindow
.With(
accessKeyHandler: ah,
keyboardDevice: () => kd)
))
{
var impl = CreateMockTopLevelImpl();
@ -742,7 +747,8 @@ namespace Avalonia.Controls.UnitTests
new TabItem { Header = "_Disabled", IsEnabled = false },
}
};
kd.SetFocusedElement((TabItem)tabControl.Items[selectedTabIndex], NavigationMethod.Unspecified, KeyModifiers.None);
var root = new TestTopLevel(impl.Object)
{
Template = CreateTemplate(),

Просмотреть файл

@ -17,7 +17,7 @@ namespace Avalonia.Markup.UnitTests.Data
DataContext = vm,
[!Button.CommandProperty] = new Binding("MyMethod"),
};
target.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent));
target.RaiseEvent(new AccessKeyEventArgs("b", false));
Assert.False(vm.IsSet);
}