diff --git a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj index 7921b608b65..191bdf32d0f 100644 --- a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj +++ b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj @@ -1,6 +1,6 @@  - net7.0-android + net8.0-android 21 Exe enable diff --git a/samples/ControlCatalog.Android/MainActivity.cs b/samples/ControlCatalog.Android/MainActivity.cs index fce568b15e7..dcc20661496 100644 --- a/samples/ControlCatalog.Android/MainActivity.cs +++ b/samples/ControlCatalog.Android/MainActivity.cs @@ -4,11 +4,16 @@ using Avalonia.Android; using static Android.Content.Intent; +// leanback and touchscreen are required for the Android TV. +[assembly: UsesFeature("android.software.leanback", Required = false)] +[assembly: UsesFeature("android.hardware.touchscreen", Required = false)] + namespace ControlCatalog.Android { [Activity(Label = "ControlCatalog.Android", Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, Exported = true, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] - // IntentFilter are here to test IActivatableApplicationLifetime - [IntentFilter(new [] { ActionView }, Categories = new [] { CategoryDefault, CategoryBrowsable }, DataScheme = "avln" )] + // CategoryBrowsable and DataScheme are required for Protocol activation. + // CategoryLeanbackLauncher is required for Android TV. + [IntentFilter(new [] { ActionView }, Categories = new [] { CategoryDefault, CategoryBrowsable, CategoryLeanbackLauncher }, DataScheme = "avln" )] public class MainActivity : AvaloniaMainActivity { protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index e19b563fb79..ad3d0c7d37e 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -95,6 +95,9 @@ + + + diff --git a/samples/ControlCatalog/Pages/ComboBoxPage.xaml b/samples/ControlCatalog/Pages/ComboBoxPage.xaml index f3f6cfe0afc..eca2c4762c8 100644 --- a/samples/ControlCatalog/Pages/ComboBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ComboBoxPage.xaml @@ -33,13 +33,13 @@ - + Hello World - + diff --git a/samples/ControlCatalog/Pages/FocusPage.xaml b/samples/ControlCatalog/Pages/FocusPage.xaml new file mode 100644 index 00000000000..f4bad7d138d --- /dev/null +++ b/samples/ControlCatalog/Pages/FocusPage.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + Enabled + Disabled + + + + + + + Projection + NavigationDirectionDistance + RectilinearDistance + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/FocusPage.xaml.cs b/samples/ControlCatalog/Pages/FocusPage.xaml.cs new file mode 100644 index 00000000000..2cc8067885e --- /dev/null +++ b/samples/ControlCatalog/Pages/FocusPage.xaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace ControlCatalog.Pages; + +public partial class FocusPage : UserControl +{ + public FocusPage() + { + AvaloniaXamlLoader.Load(this); + } +} + diff --git a/src/Android/Avalonia.Android/Platform/Input/AndroidKeyboardDevice.cs b/src/Android/Avalonia.Android/Platform/Input/AndroidKeyboardDevice.cs index a416e05b52d..08d71fc67c4 100644 --- a/src/Android/Avalonia.Android/Platform/Input/AndroidKeyboardDevice.cs +++ b/src/Android/Avalonia.Android/Platform/Input/AndroidKeyboardDevice.cs @@ -31,10 +31,6 @@ internal class AndroidKeyboardDevice : KeyboardDevice, IKeyboardDevice { Keycode.PageDown, Key.PageDown }, { Keycode.MoveEnd, Key.End }, { Keycode.MoveHome, Key.Home }, - { Keycode.DpadLeft, Key.Left }, - { Keycode.DpadUp, Key.Up }, - { Keycode.DpadRight, Key.Right }, - { Keycode.DpadDown, Key.Down }, // { Keycode.ButtonSelect?, Key.Select }, // { Keycode.print?, Key.Print }, //{ Keycode.execute?, Key.Execute }, @@ -209,7 +205,15 @@ internal class AndroidKeyboardDevice : KeyboardDevice, IKeyboardDevice //{ Keycode.?, Key.DbeEnterDialogConversionMode } //{ Keycode.?, Key.OemClear } //{ Keycode.?, Key.DeadCharProcessed } - { Keycode.Backslash, Key.OemBackslash } + { Keycode.Backslash, Key.OemBackslash }, + + // Loosely mapping DPad keys to Avalonia keys + { Keycode.Back, Key.Escape }, + { Keycode.DpadCenter, Key.Space }, + { Keycode.DpadLeft, Key.Left }, + { Keycode.DpadUp, Key.Up }, + { Keycode.DpadRight, Key.Right }, + { Keycode.DpadDown, Key.Down } }; internal static Key ConvertKey(Keycode key) diff --git a/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs b/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs index eed8069a935..84f77571977 100644 --- a/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs +++ b/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs @@ -129,15 +129,20 @@ private static RawInputModifiers GetModifierKeys(KeyEvent e) private KeyDeviceType GetKeyDeviceType(KeyEvent e) { var source = e.Device?.Sources ?? InputSourceType.Unknown; - if (source is InputSourceType.Joystick or - InputSourceType.ClassJoystick or - InputSourceType.Gamepad) - return KeyDeviceType.Gamepad; - if (source == InputSourceType.Dpad && e.Device?.KeyboardType == InputKeyboardType.NonAlphabetic) + // Remote controller reports itself as "DPad | Keyboard", which is confusing, + // so we need to double-check KeyboardType as well. + + if (source.HasAnyFlag(InputSourceType.Dpad) + && e.Device?.KeyboardType == InputKeyboardType.NonAlphabetic) return KeyDeviceType.Remote; - return KeyDeviceType.Keyboard; + // ReSharper disable BitwiseOperatorOnEnumWithoutFlags - it IS flags enum under the hood. + if (source.HasAnyFlag(InputSourceType.Joystick | InputSourceType.Gamepad)) + return KeyDeviceType.Gamepad; + // ReSharper restore BitwiseOperatorOnEnumWithoutFlags + + return KeyDeviceType.Keyboard; // fallback to the keyboard, if unknown. } public void Dispose() diff --git a/src/Avalonia.Base/Controls/IInternalScroller.cs b/src/Avalonia.Base/Controls/IInternalScroller.cs new file mode 100644 index 00000000000..226626731b4 --- /dev/null +++ b/src/Avalonia.Base/Controls/IInternalScroller.cs @@ -0,0 +1,11 @@ +using System.Runtime.CompilerServices; + +namespace Avalonia.Controls.Primitives; + +// TODO12: Integrate with existing IScrollable interface, breaking change +internal interface IInternalScroller +{ + bool CanHorizontallyScroll { get; } + + bool CanVerticallyScroll { get; } +} diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index b3440aaf9ad..cee17e4dd15 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -164,7 +164,7 @@ internal bool TryMoveFocus(NavigationDirection direction) /// /// The element. /// True if the element can be focused. - private static bool CanFocus(IInputElement e) => e.Focusable && e.IsEffectivelyEnabled && IsVisible(e); + internal static bool CanFocus(IInputElement e) => e.Focusable && e.IsEffectivelyEnabled && IsVisible(e); /// /// Gets the focus scope of the specified control, traversing popups. diff --git a/src/Avalonia.Base/Input/IPointer.cs b/src/Avalonia.Base/Input/IPointer.cs index 050adbabaa0..9071d77997b 100644 --- a/src/Avalonia.Base/Input/IPointer.cs +++ b/src/Avalonia.Base/Input/IPointer.cs @@ -54,8 +54,19 @@ public interface IPointer /// public enum PointerType { + /// + /// The input device is a mouse. + /// Mouse, + + /// + /// The input device is a touch. + /// Touch, + + /// + /// The input device is a pen. + /// Pen } } diff --git a/src/Avalonia.Base/Input/KeyDeviceType.cs b/src/Avalonia.Base/Input/KeyDeviceType.cs index 4e16e787f33..7304432944e 100644 --- a/src/Avalonia.Base/Input/KeyDeviceType.cs +++ b/src/Avalonia.Base/Input/KeyDeviceType.cs @@ -4,12 +4,25 @@ using System.Text; using System.Threading.Tasks; -namespace Avalonia.Input +namespace Avalonia.Input; + +/// +/// Enumerates key device types. +/// +public enum KeyDeviceType { - public enum KeyDeviceType - { - Keyboard, - Gamepad, - Remote - } + /// + /// The input device is a keyboard. + /// + Keyboard, + + /// + /// The input device is a gamepad. + /// + Gamepad, + + /// + /// The input device is a remote control. + /// + Remote } diff --git a/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs b/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs index d979acbdf43..3444a88abac 100644 --- a/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs +++ b/src/Avalonia.Base/Input/KeyboardNavigationHandler.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Avalonia.Input.Navigation; +using Avalonia.Input; using Avalonia.Metadata; using Avalonia.VisualTree; @@ -16,7 +17,7 @@ public sealed class KeyboardNavigationHandler : IKeyboardNavigationHandler /// The window to which the handler belongs. /// private IInputRoot? _owner; - + /// /// Sets the owner of the keyboard navigation handler. /// @@ -29,7 +30,7 @@ public void SetOwner(IInputRoot owner) { if (_owner != null) { - throw new InvalidOperationException("AccessKeyHandler owner has already been set."); + throw new InvalidOperationException($"{nameof(KeyboardNavigationHandler)} owner has already been set."); } _owner = owner ?? throw new ArgumentNullException(nameof(owner)); @@ -50,22 +51,48 @@ public void SetOwner(IInputRoot owner) NavigationDirection direction) { element = element ?? throw new ArgumentNullException(nameof(element)); + return GetNextPrivate(element, null, direction, null); + } + + private static IInputElement? GetNextPrivate( + IInputElement? element, + IInputRoot? owner, + NavigationDirection direction, + KeyDeviceType? keyDeviceType) + { + var elementOrOwner = element ?? owner ?? throw new ArgumentNullException(nameof(owner)); // If there's a custom keyboard navigation handler as an ancestor, use that. var custom = (element as Visual)?.FindAncestorOfType(true); - if (custom is not null && HandlePreCustomNavigation(custom, element, direction, out var ce)) + if (custom is not null && HandlePreCustomNavigation(custom, elementOrOwner, direction, out var ce)) return ce; - var result = direction switch + IInputElement? result; + if (direction is NavigationDirection.Next) + { + result = TabNavigation.GetNextTab(elementOrOwner, false); + } + else if (direction is NavigationDirection.Previous) + { + result = TabNavigation.GetPrevTab(elementOrOwner, null, false); + } + else if (direction is NavigationDirection.Up or NavigationDirection.Down + or NavigationDirection.Left or NavigationDirection.Right) + { + // HACK: a window should always have some element focused, + // it seems to be a difference between UWP and Avalonia focus manager implementations. + result = element is null + ? TabNavigation.GetNextTab(elementOrOwner, true) + : XYFocus.TryDirectionalFocus(direction, element, owner, null, keyDeviceType); + } + else { - NavigationDirection.Next => TabNavigation.GetNextTab(element, false), - NavigationDirection.Previous => TabNavigation.GetPrevTab(element, null, false), - _ => throw new NotSupportedException(), - }; + throw new ArgumentOutOfRangeException(nameof(direction), direction, null); + } // If there wasn't a custom navigation handler as an ancestor of the current element, // but there is one as an ancestor of the new element, use that. - if (custom is null && HandlePostCustomNavigation(element, result, direction, out ce)) + if (custom is null && HandlePostCustomNavigation(elementOrOwner, result, direction, out ce)) return ce; return result; @@ -82,20 +109,23 @@ public void Move( NavigationDirection direction, KeyModifiers keyModifiers = KeyModifiers.None) { - if (element is null && _owner is null) - { - return; - } + MovePrivate(element, direction, keyModifiers, null); + } - var next = GetNext(element ?? _owner!, direction); + // TODO12: remove MovePrivate, and make Move return boolean. Or even remove whole KeyboardNavigationHandler. + private bool MovePrivate(IInputElement? element, NavigationDirection direction, KeyModifiers keyModifiers, KeyDeviceType? deviceType) + { + var next = GetNextPrivate(element, _owner, direction, deviceType); if (next != null) { var method = direction == NavigationDirection.Next || direction == NavigationDirection.Previous ? - NavigationMethod.Tab : NavigationMethod.Directional; - next.Focus(method, keyModifiers); + NavigationMethod.Tab : NavigationMethod.Directional; + return next.Focus(method, keyModifiers); } + + return false; } /// @@ -110,8 +140,20 @@ void OnKeyDown(object? sender, KeyEventArgs e) var current = FocusManager.GetFocusManager(e.Source as IInputElement)?.GetFocusedElement(); var direction = (e.KeyModifiers & KeyModifiers.Shift) == 0 ? NavigationDirection.Next : NavigationDirection.Previous; - Move(current, direction, e.KeyModifiers); - e.Handled = true; + e.Handled = MovePrivate(current, direction, e.KeyModifiers, e.KeyDeviceType); + } + else if (e.Key is Key.Left or Key.Right or Key.Up or Key.Down) + { + var current = FocusManager.GetFocusManager(e.Source as IInputElement)?.GetFocusedElement(); + var direction = e.Key switch + { + Key.Left => NavigationDirection.Left, + Key.Right => NavigationDirection.Right, + Key.Up => NavigationDirection.Up, + Key.Down => NavigationDirection.Down, + _ => throw new ArgumentOutOfRangeException() + }; + e.Handled = MovePrivate(current, direction, e.KeyModifiers, e.KeyDeviceType); } } diff --git a/src/Avalonia.Base/Input/Navigation/FocusExtensions.cs b/src/Avalonia.Base/Input/Navigation/FocusExtensions.cs deleted file mode 100644 index 3cd4ca0d761..00000000000 --- a/src/Avalonia.Base/Input/Navigation/FocusExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Avalonia.Input.Navigation -{ - /// - /// Provides extension methods relating to control focus. - /// - internal static class FocusExtensions - { - /// - /// Checks if the specified element can be focused. - /// - /// The element. - /// True if the element can be focused. - public static bool CanFocus(this IInputElement e) - { - var visible = (e as Visual)?.IsVisible ?? true; - return e.Focusable && e.IsEffectivelyEnabled && visible; - } - - /// - /// Checks if descendants of the specified element can be focused. - /// - /// The element. - /// True if descendants of the element can be focused. - public static bool CanFocusDescendants(this IInputElement e) - { - var visible = (e as Visual)?.IsVisible ?? true; - return e.IsEffectivelyEnabled && visible; - } - } -} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.Bubbling.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.Bubbling.cs new file mode 100644 index 00000000000..671eb80d3ff --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.Bubbling.cs @@ -0,0 +1,165 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input.Navigation; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace Avalonia.Input; + +public partial class XYFocus +{ + private static InputElement? GetDirectionOverride( + InputElement element, + InputElement? searchRoot, + NavigationDirection direction, + bool ignoreFocusabililty = false) + { + var index = GetXYFocusPropertyIndex(element, direction); + + if (index != null) + { + var overrideElement = element.GetValue(index) as InputElement; + + if (overrideElement != null) + { + if ((!ignoreFocusabililty && !FocusManager.CanFocus(overrideElement))) + return null; + + // If an override was specified but it is located outside the searchRoot, don't use it as the candidate. + if (searchRoot != null && + !searchRoot.IsVisualAncestorOf(overrideElement)) + return null; + + return overrideElement; + } + } + + return null; + } + + private static InputElement? TryXYFocusBubble( + InputElement element, + InputElement? candidate, + InputElement? searchRoot, + NavigationDirection direction) + { + if (candidate == null) + return null; + + var nextFocusableElement = candidate; + var directionOverrideRoot = GetDirectionOverrideRoot(element, searchRoot, direction); + + if (directionOverrideRoot != null) + { + var isAncestor = directionOverrideRoot.IsVisualAncestorOf(candidate); + if (!isAncestor) + { + nextFocusableElement = GetDirectionOverride(directionOverrideRoot, searchRoot, direction) + ?? nextFocusableElement; + } + } + + return nextFocusableElement; + } + + private static InputElement? GetDirectionOverrideRoot( + InputElement element, + InputElement? searchRoot, + NavigationDirection direction) + { + var root = element; + + while (root != null && GetDirectionOverride(root, searchRoot, direction) == null) + { + root = root.GetVisualParent() as InputElement; + } + + return root; + } + + private static XYFocusNavigationStrategy GetStrategy( + InputElement element, + NavigationDirection direction, + XYFocusNavigationStrategy? navigationStrategyOverride) + { + var isAutoOverride = navigationStrategyOverride == XYFocusNavigationStrategy.Auto; + + if (navigationStrategyOverride.HasValue && !isAutoOverride) + { + // We can cast just by offsetting values because we have ensured that the XYFocusStrategy enums offset as expected + return (XYFocusNavigationStrategy)(int)(navigationStrategyOverride.Value - 1); + } + else if (isAutoOverride && element.GetVisualParent() is InputElement parent) + { + // Skip the element if we have an auto override and look at its parent's strategy + element = parent; + } + + var index = GetXYFocusNavigationStrategyPropertyIndex(element, direction); + if (index is not null) + { + var current = element; + while (current != null && current.GetValue(index) is XYFocusNavigationStrategy mode) + { + if (mode != XYFocusNavigationStrategy.Auto) + { + return mode; + } + + current = current.GetVisualParent() as InputElement; + } + } + + return XYFocusNavigationStrategy.Projection; + } + + private static AvaloniaProperty? GetXYFocusPropertyIndex( + InputElement element, + NavigationDirection direction) + { + if (element.FlowDirection == FlowDirection.RightToLeft) + { + if (direction == NavigationDirection.Left) direction = NavigationDirection.Right; + else if (direction == NavigationDirection.Right) direction = NavigationDirection.Left; + } + + switch (direction) + { + case NavigationDirection.Left: + return LeftProperty; + case NavigationDirection.Right: + return RightProperty; + case NavigationDirection.Up: + return UpProperty; + case NavigationDirection.Down: + return DownProperty; + } + + return null; + } + + private static AvaloniaProperty? GetXYFocusNavigationStrategyPropertyIndex( + InputElement element, + NavigationDirection direction) + { + if (element.FlowDirection == FlowDirection.RightToLeft) + { + if (direction == NavigationDirection.Left) direction = NavigationDirection.Right; + else if (direction == NavigationDirection.Right) direction = NavigationDirection.Left; + } + + switch (direction) + { + case NavigationDirection.Left: + return LeftNavigationStrategyProperty; + case NavigationDirection.Right: + return RightNavigationStrategyProperty; + case NavigationDirection.Up: + return UpNavigationStrategyProperty; + case NavigationDirection.Down: + return DownNavigationStrategyProperty; + } + + return null; + } +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs new file mode 100644 index 00000000000..9ee77f26b17 --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using Avalonia.Collections.Pooled; +using Avalonia.Controls.Primitives; +using Avalonia.VisualTree; + +namespace Avalonia.Input; + +public partial class XYFocus +{ + private static void FindElements( + PooledList focusList, + InputElement startRoot, + InputElement? currentElement, + InputElement? activeScroller, + bool ignoreClipping, + KeyDeviceType? inputKeyDeviceType) + { + var isScrolling = (activeScroller != null); + var collection = startRoot.VisualChildren; + + var kidCount = collection.Count; + + for (var i = 0; i < kidCount; i++) + { + var child = collection[i] as InputElement; + + if (child == null) + continue; + + var isEngagementEnabledButNotEngaged = GetIsFocusEngagementEnabled(child) && !GetIsFocusEngaged(child); + + if (child != currentElement + && IsValidCandidate(child, inputKeyDeviceType) + && GetBoundsForRanking(child, ignoreClipping) is {} bounds) + { + if (isScrolling) + { + if (IsCandidateParticipatingInScroll(child, activeScroller) || + !IsOccluded(child, bounds) || + IsCandidateChildOfAncestorScroller(child, activeScroller)) + { + focusList.Add(new XYFocusParams(child, bounds)); + } + } + else + { + focusList.Add(new XYFocusParams(child, bounds)); + } + } + + if (IsValidFocusSubtree(child) && !isEngagementEnabledButNotEngaged) + { + FindElements(focusList, child, currentElement, activeScroller, ignoreClipping, inputKeyDeviceType); + } + } + } + + private static bool IsValidFocusSubtree(InputElement candidate) + { + // We don't need to check for effective values, as we've already checked parents of this subtree on previous steps. + return candidate.IsVisible && + candidate.IsEnabled; + } + + private static bool IsValidCandidate(InputElement candidate, KeyDeviceType? inputKeyDeviceType) + { + return candidate.Focusable && candidate.IsEnabled && candidate.IsVisible + // Only allow candidate focus, if original key device type could focus it. + && XYFocusHelpers.IsAllowedXYNavigationMode(candidate, inputKeyDeviceType); + } + + /// Check if candidate's direct scroller is the same as active focused scroller. + private static bool IsCandidateParticipatingInScroll(InputElement candidate, InputElement? activeScroller) + { + if (activeScroller == null) + { + return false; + } + + var closestScroller = candidate.FindAncestorOfType(true); + return ReferenceEquals(closestScroller, activeScroller); + } + + /// Check if there is a common parent scroller for both candidate and active scroller. + private static bool IsCandidateChildOfAncestorScroller(InputElement candidate, InputElement? activeScroller) + { + if (activeScroller == null) + { + return false; + } + + var parent = activeScroller.Parent; + while (parent != null) + { + if (parent is IInternalScroller and Visual visual + && visual.IsVisualAncestorOf(candidate)) + { + return true; + } + parent = parent.Parent; + } + return false; + } + + private static bool IsOccluded(InputElement element, Rect elementBounds) + { + // if (element is CHyperlink hyperlink) + // { + // element = hyperlink.GetContainingFrameworkElement(); + // } + + var root = (InputElement)element.GetVisualRoot()!; + + // Check if the element is within the visible area of the window + var visibleBounds = new Rect(0, 0, root.Bounds.Width, root.Bounds.Height); + + return !visibleBounds.Intersects(elementBounds); + } + + private static Rect? GetBoundsForRanking(InputElement element, bool ignoreClipping) + { + if (element.GetTransformedBounds() is { } bounds) + { + return ignoreClipping + ? bounds.Bounds.TransformToAABB(bounds.Transform) + : bounds.Clip; + } + + return null; + } +} + diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs new file mode 100644 index 00000000000..867e80f1762 --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs @@ -0,0 +1,438 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Avalonia.Collections.Pooled; +using Avalonia.Controls.Primitives; +using Avalonia.Input.Navigation; +using Avalonia.Media; +using Avalonia.Utilities; +using Avalonia.VisualTree; + +namespace Avalonia.Input; + +internal record XYFocusParams(InputElement Element, Rect Bounds) +{ + public double Score { get; set; } +} + +public partial class XYFocus +{ + internal XYFocus() + { + + } + + private XYFocusAlgorithms.XYFocusManifolds mManifolds = new(); + private PooledList _pooledCandidates = new(); + + private static readonly XYFocus _instance = new(); + + internal XYFocusAlgorithms.XYFocusManifolds ResetManifolds() + { + mManifolds.Reset(); + return mManifolds; + } + + internal void SetManifoldsFromBounds(Rect bounds) + { + mManifolds.VManifold = (bounds.Left, bounds.Right); + mManifolds.HManifold = (bounds.Top, bounds.Bottom); + } + + internal void UpdateManifolds( + NavigationDirection direction, + Rect elementBounds, + InputElement candidate, + bool ignoreClipping) + { + var candidateBounds = GetBoundsForRanking(candidate, ignoreClipping)!.Value; + XYFocusAlgorithms.UpdateManifolds(direction, elementBounds, candidateBounds, mManifolds); + } + + internal static InputElement? TryDirectionalFocus( + NavigationDirection direction, + IInputElement element, + IInputElement? owner, + InputElement? engagedControl, + KeyDeviceType? keyDeviceType) + { + /* + * UWP/WinUI Behavior is a bit different with handling of manifolds. + * In WinUI SetManifolds is called with Hint boundaries of the currently focused element. + * And once again UpdateManifolds is called after successfully completed focus operation. + * Guaranteeing that Projection navigation algorithm (the only one that actually respects manifolds) + * will respect manifolds these manifolds with higher coefficient. + * Note, it's not quite clear from WinUI source code in which scenario + * these manifolds would differ from currently focused elements boundaries. + * The only possible situation is when XYFocusOptions.FocusedElementBounds has custom value, + * and current element boundaries are ignored. Possibly, it is used by their internal testing (not open-sourced)? + * So, for Avalonia I have added this GetNextFocusableElement method that simplifies algorithm a little, + * by forcing current elements boundaries to the manifolds always. + * + * Also, with using static GetNextFocusableElement and self-managed manifolds, we don't need XYFocus instance object anymore. + * + * This method also hides initialization of some XYFocusOptions properties. + * Keep in mind, UWP gives much more flexibility with focus than Avalonia currently does, so some properties are ignored. + */ + + if (!(element is InputElement inputElement)) + { + // TODO: handle non-Visual IInputElement implementations, like TextElement, when we support that. + return null; + } + + if (!XYFocusHelpers.IsAllowedXYNavigationMode(inputElement, keyDeviceType)) + { + return null; + } + + if (!(GetBoundsForRanking(inputElement, true) is { } bounds)) + { + return null; + } + + _instance.SetManifoldsFromBounds(bounds); + + return _instance.GetNextFocusableElement(direction, inputElement, engagedControl, true, new XYFocusOptions + { + KeyDeviceType = keyDeviceType, + FocusedElementBounds = bounds, + UpdateManifold = true, + SearchRoot = owner as InputElement ?? inputElement.GetVisualRoot() as InputElement + }); + } + + internal InputElement? GetNextFocusableElement( + NavigationDirection direction, + InputElement? element, + InputElement? engagedControl, + bool updateManifolds, + XYFocusOptions xyFocusOptions) + { + if (element == null) return null; + + var root = (InputElement)element.GetVisualRoot()!; + var isRightToLeft = element.FlowDirection == FlowDirection.RightToLeft; + var mode = GetStrategy(element, direction, xyFocusOptions.NavigationStrategyOverride); + + Rect rootBounds; + + var focusedElementBounds = xyFocusOptions.FocusedElementBounds ?? + throw new InvalidOperationException("FocusedElementBounds needs to be set"); + + var nextFocusableElement = GetDirectionOverride(element, xyFocusOptions.SearchRoot, direction, true); + + if (nextFocusableElement != null) + { + return nextFocusableElement; + } + + var activeScroller = GetActiveScrollerForScroll(direction, element); + var isProcessingInputForScroll = (activeScroller != null); + + if (xyFocusOptions.FocusHintRectangle != null) + { + focusedElementBounds = xyFocusOptions.FocusHintRectangle.Value; + element = null; + } + + if (engagedControl != null) + { + rootBounds = GetBoundsForRanking(engagedControl, xyFocusOptions.IgnoreClipping) ?? root.Bounds; + } + else if (xyFocusOptions.SearchRoot != null) + { + rootBounds = GetBoundsForRanking(xyFocusOptions.SearchRoot, xyFocusOptions.IgnoreClipping) ?? root.Bounds; + } + else + { + rootBounds = GetBoundsForRanking(root, xyFocusOptions.IgnoreClipping) ?? root.Bounds; + } + + var candidateList = _pooledCandidates; + try + { + GetAllValidFocusableChildren(candidateList, root, direction, element, engagedControl, + xyFocusOptions.SearchRoot, activeScroller, xyFocusOptions.IgnoreClipping, + xyFocusOptions.KeyDeviceType); + + if (candidateList.Count > 0) + { + var maxRootBoundsDistance = + Math.Max(rootBounds.Right - rootBounds.Left, rootBounds.Bottom - rootBounds.Top); + maxRootBoundsDistance = Math.Max(maxRootBoundsDistance, + GetMaxRootBoundsDistance(candidateList, focusedElementBounds, direction, + xyFocusOptions.IgnoreClipping)); + + RankElements(candidateList, direction, focusedElementBounds, maxRootBoundsDistance, mode, + xyFocusOptions.ExclusionRect, xyFocusOptions.IgnoreClipping, xyFocusOptions.IgnoreCone); + + var ignoreOcclusivity = xyFocusOptions.IgnoreOcclusivity || isProcessingInputForScroll; + + // Choose the best candidate, after testing for occlusivity, if we're currently scrolling, the test has been done already, skip it. + nextFocusableElement = ChooseBestFocusableElementFromList(candidateList, direction, + focusedElementBounds, + xyFocusOptions.IgnoreClipping, ignoreOcclusivity, isRightToLeft, + xyFocusOptions.UpdateManifold && updateManifolds); + if (element is not null) + { + nextFocusableElement = TryXYFocusBubble(element, nextFocusableElement, xyFocusOptions.SearchRoot, + direction); + } + } + } + finally + { + _pooledCandidates.Clear(); + } + + return nextFocusableElement; + } + + private InputElement? ChooseBestFocusableElementFromList( + PooledList scoreList, + NavigationDirection direction, + Rect bounds, + bool ignoreClipping, + bool ignoreOcclusivity, + bool isRightToLeft, + bool updateManifolds) + { + InputElement? bestElement = null; + + scoreList.Sort((elementA, elementB) => + { + if (elementA!.Element == elementB!.Element) + { + return 0; + } + + var compared = elementB.Score.CompareTo(elementA.Score); + if (compared == 0) + { + var firstBounds = elementA.Bounds; + var secondBounds = elementB.Bounds; + + if (firstBounds == secondBounds) + { + return 0; + } + else if (direction == NavigationDirection.Up || direction == NavigationDirection.Down) + { + if (isRightToLeft) + { + return secondBounds.Left.CompareTo(firstBounds.Left); + } + + return firstBounds.Left.CompareTo(secondBounds.Left); + } + else + { + return firstBounds.Top.CompareTo(secondBounds.Top); + } + } + + return compared; + }); + + foreach (var param in scoreList) + { + if (param.Score <= 0) + { + break; + } + + var boundsForOccTesting = + ignoreClipping ? GetBoundsForRanking(param.Element, false)!.Value : param.Bounds; + + // Don't check for occlusivity if we've already covered occlusivity scenarios for scrollable content or have been asked + // to ignore occlusivity by the caller. + if (Math.Abs(param.Bounds.X - double.MaxValue) > MathUtilities.DoubleEpsilon && + (ignoreOcclusivity || !IsOccluded(param.Element, boundsForOccTesting))) + { + bestElement = param.Element; + + if (updateManifolds) + { + // Update the manifolds with the newly selected focus + XYFocusAlgorithms.UpdateManifolds(direction, bounds, param.Bounds, mManifolds); + } + + break; + } + } + + return bestElement; + } + + private void GetAllValidFocusableChildren( + PooledList candidateList, + InputElement startRoot, + NavigationDirection direction, + InputElement? currentElement, + InputElement? engagedControl, + InputElement? searchScope, + InputElement? activeScroller, + bool ignoreClipping, + KeyDeviceType? inputKeyDeviceType) + { + var rootForTreeWalk = startRoot; + + // If asked to scope the search within the given container, honor it without any exceptions + if (searchScope != null) + { + rootForTreeWalk = searchScope; + } + + if (engagedControl == null) + { + FindElements(candidateList, rootForTreeWalk, currentElement, activeScroller, ignoreClipping, + inputKeyDeviceType); + } + else + { + // Only run through this when you are an engaged element. Being an engaged element means that you should only + // look at the children of the engaged element and any children of popups that were opened during engagement + FindElements(candidateList, engagedControl, currentElement, activeScroller, ignoreClipping, + inputKeyDeviceType); + + // Iterate through the popups and add their children to the list + // TODO: Avalonia, missing Popup API + // var popupChildrenDuringEngagement = CPopupRoot.GetPopupChildrenOpenedDuringEngagement(engagedControl); + // foreach (var popup in popupChildrenDuringEngagement) + // { + // var subCandidateList = FindElements(popup, currentElement, activeScroller, + // ignoreClipping, shouldConsiderXYFocusKeyboardNavigation); + // candidateList.AddRange(subCandidateList); + // } + + if (currentElement != engagedControl + && GetBoundsForRanking(engagedControl, ignoreClipping) is {} bounds) + { + candidateList.Add(new XYFocusParams(engagedControl, bounds)); + } + } + } + + private void RankElements( + IList candidateList, + NavigationDirection direction, + Rect bounds, + double maxRootBoundsDistance, + XYFocusNavigationStrategy mode, + Rect? exclusionRect, + bool ignoreClipping, + bool ignoreCone) + { + var exclusionBounds = new Rect(); + if (exclusionRect != null) + { + exclusionBounds = exclusionRect.Value; + } + + foreach (var candidate in candidateList) + { + var candidateBounds = candidate.Bounds; + + if (!(exclusionBounds.Intersects(candidateBounds) || exclusionBounds.Contains(candidateBounds))) + { + if (mode == XYFocusNavigationStrategy.Projection && + XYFocusAlgorithms.ShouldCandidateBeConsideredForRanking(bounds, candidateBounds, maxRootBoundsDistance, + direction, exclusionBounds, ignoreCone)) + { + candidate.Score = XYFocusAlgorithms.GetScoreProjection(direction, bounds, candidateBounds, mManifolds, maxRootBoundsDistance); + } + else if (mode == XYFocusNavigationStrategy.NavigationDirectionDistance || + mode == XYFocusNavigationStrategy.RectilinearDistance) + { + candidate.Score = XYFocusAlgorithms.GetScoreProximity(direction, bounds, candidateBounds, + maxRootBoundsDistance, mode == XYFocusNavigationStrategy.RectilinearDistance); + } + } + } + } + + private double GetMaxRootBoundsDistance( + IList list, + Rect bounds, + NavigationDirection direction, + bool ignoreClipping) + { + var maxElement = list[0]; + var maxValue = double.MinValue; + + foreach (var param in list) + { + var candidateBounds = param.Bounds; + var value = direction switch + { + NavigationDirection.Left => candidateBounds.Left, + NavigationDirection.Right => candidateBounds.Right, + NavigationDirection.Up => candidateBounds.Top, + NavigationDirection.Down => candidateBounds.Bottom, + _ => 0 + }; + + if (value > maxValue) + { + maxValue = value; + maxElement = param; + } + } + + var maxBounds = maxElement.Bounds; + return direction switch + { + NavigationDirection.Left => Math.Abs(maxBounds.Right - bounds.Left), + NavigationDirection.Right => Math.Abs(bounds.Right - maxBounds.Left), + NavigationDirection.Up => Math.Abs(bounds.Bottom - maxBounds.Top), + NavigationDirection.Down => Math.Abs(maxBounds.Bottom - bounds.Top), + _ => 0, + }; + } + + private InputElement? GetActiveScrollerForScroll( + NavigationDirection direction, + InputElement focusedElement) + { + InputElement? parent = null; + // var textElement = focusedElement as TextElement; + // if (textElement != null) + // { + // parent = textElement.GetContainingFrameworkElement(); + // } + // else + { + parent = focusedElement; + } + + while (parent != null) + { + var element = parent; + if (element is IInternalScroller scrollable) + { + var isHorizontallyScrollable = scrollable.CanHorizontallyScroll; + var isVerticallyScrollable = scrollable.CanVerticallyScroll; + + var isHorizontallyScrollableForDirection = + direction is NavigationDirection.Left or NavigationDirection.Right + && isHorizontallyScrollable; + var isVerticallyScrollableForDirection = + direction is NavigationDirection.Up or NavigationDirection.Down + && isVerticallyScrollable; + + Debug.Assert(!(isHorizontallyScrollableForDirection && isVerticallyScrollableForDirection)); + + if (isHorizontallyScrollableForDirection || isVerticallyScrollableForDirection) + { + return element; + } + } + + parent = parent.VisualParent as InputElement; + } + + return null; + } +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs new file mode 100644 index 00000000000..e33ce3e4c65 --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.Properties.cs @@ -0,0 +1,108 @@ +using Avalonia.Input; + +namespace Avalonia.Input; + +public partial class XYFocus +{ + public static readonly AttachedProperty DownProperty = + AvaloniaProperty.RegisterAttached("Down"); + + public static void SetDown(InputElement obj, InputElement value) => obj.SetValue(DownProperty, value); + public static InputElement GetDown(InputElement obj) => obj.GetValue(DownProperty); + + public static readonly AttachedProperty LeftProperty = + AvaloniaProperty.RegisterAttached("Left"); + + public static void SetLeft(InputElement obj, InputElement value) => obj.SetValue(LeftProperty, value); + public static InputElement GetLeft(InputElement obj) => obj.GetValue(LeftProperty); + + public static readonly AttachedProperty RightProperty = + AvaloniaProperty.RegisterAttached("Right"); + + public static void SetRight(InputElement obj, InputElement value) => + obj.SetValue(RightProperty, value); + + public static InputElement GetRight(InputElement obj) => obj.GetValue(RightProperty); + + public static readonly AttachedProperty UpProperty = + AvaloniaProperty.RegisterAttached("Up"); + + public static void SetUp(InputElement obj, InputElement value) => obj.SetValue(UpProperty, value); + public static InputElement GetUp(InputElement obj) => obj.GetValue(UpProperty); + + public static readonly AttachedProperty DownNavigationStrategyProperty = + AvaloniaProperty.RegisterAttached( + "DownNavigationStrategy", inherits: true); + + public static void SetDownNavigationStrategy(InputElement obj, XYFocusNavigationStrategy value) => + obj.SetValue(DownNavigationStrategyProperty, value); + + public static XYFocusNavigationStrategy GetDownNavigationStrategy(InputElement obj) => + obj.GetValue(DownNavigationStrategyProperty); + + public static readonly AttachedProperty UpNavigationStrategyProperty = + AvaloniaProperty.RegisterAttached( + "UpNavigationStrategy", inherits: true); + + public static void SetUpNavigationStrategy(InputElement obj, XYFocusNavigationStrategy value) => + obj.SetValue(UpNavigationStrategyProperty, value); + + public static XYFocusNavigationStrategy GetUpNavigationStrategy(InputElement obj) => + obj.GetValue(UpNavigationStrategyProperty); + + public static readonly AttachedProperty LeftNavigationStrategyProperty = + AvaloniaProperty.RegisterAttached( + "LeftNavigationStrategy", inherits: true); + + public static void SetLeftNavigationStrategy(InputElement obj, XYFocusNavigationStrategy value) => + obj.SetValue(LeftNavigationStrategyProperty, value); + + public static XYFocusNavigationStrategy GetLeftNavigationStrategy(InputElement obj) => + obj.GetValue(LeftNavigationStrategyProperty); + + + public static readonly AttachedProperty RightNavigationStrategyProperty = + AvaloniaProperty.RegisterAttached( + "RightNavigationStrategy", inherits: true); + + public static void SetRightNavigationStrategy(InputElement obj, XYFocusNavigationStrategy value) => + obj.SetValue(RightNavigationStrategyProperty, value); + + public static XYFocusNavigationStrategy GetRightNavigationStrategy(InputElement obj) => + obj.GetValue(RightNavigationStrategyProperty); + + public static readonly AttachedProperty NavigationModesProperty = + AvaloniaProperty.RegisterAttached( + "NavigationModes", XYFocusNavigationModes.Gamepad | XYFocusNavigationModes.Remote, inherits: true); + + public static void SetNavigationModes(InputElement obj, XYFocusNavigationModes value) => + obj.SetValue(NavigationModesProperty, value); + + public static XYFocusNavigationModes GetNavigationModes(InputElement obj) => + obj.GetValue(NavigationModesProperty); + + internal static readonly AttachedProperty IsFocusEngagementEnabledProperty = + AvaloniaProperty.RegisterAttached("IsFocusEngagementEnabled"); + + internal static void SetIsFocusEngagementEnabled(InputElement obj, bool value) => obj.SetValue(IsFocusEngagementEnabledProperty, value); + internal static bool GetIsFocusEngagementEnabled(InputElement obj) => obj.GetValue(IsFocusEngagementEnabledProperty); + + internal static readonly AttachedProperty IsFocusEngagedProperty = + AvaloniaProperty.RegisterAttached("IsFocusEngaged", coerce: IsFocusEngagedCoerce); + + private static bool IsFocusEngagedCoerce(AvaloniaObject sender, bool value) + { + return value && sender is InputElement inputElement && GetIsFocusEngagementEnabled(inputElement); + } + + internal static void SetIsFocusEngaged(Visual obj, bool value) => obj.SetValue(IsFocusEngagedProperty, value); + internal static bool GetIsFocusEngaged(Visual obj) => obj.GetValue(IsFocusEngagedProperty); + + static XYFocus() + { + IsFocusEngagedProperty.Changed.AddClassHandler((s, args) => + { + // if () + }); + } +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusAlgorithms.cs b/src/Avalonia.Base/Input/Navigation/XYFocusAlgorithms.cs new file mode 100644 index 00000000000..d14fc4d64f1 --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocusAlgorithms.cs @@ -0,0 +1,376 @@ +using System; +using System.Numerics; +using Avalonia.Utilities; + +namespace Avalonia.Input.Navigation; + +internal static class XYFocusAlgorithms +{ + private const double InShadowThreshold = 0.25; + private const double InShadowThresholdForSecondaryAxis = 0.02; + private const double ConeAngle = Math.PI / 4; + + private const double PrimaryAxisDistanceWeight = 15; + private const double SecondaryAxisDistanceWeight = 1; + private const double PercentInManifoldShadowWeight = 10000; + private const double PercentInShadowWeight = 50; + + public static double GetScoreProximity( + NavigationDirection direction, + Rect bounds, + Rect candidateBounds, + double maxDistance, + bool considerSecondaryAxis) + { + double score = 0; + + var primaryAxisDistance = CalculatePrimaryAxisDistance(direction, bounds, candidateBounds); + var secondaryAxisDistance = CalculateSecondaryAxisDistance(direction, bounds, candidateBounds); + + if (primaryAxisDistance >= 0) + { + // We do not want to use the secondary axis if the candidate is within the shadow of the element + (double, double) potential; + (double, double) reference; + + if (direction == NavigationDirection.Left || direction == NavigationDirection.Right) + { + reference = (bounds.Top, bounds.Bottom); + potential = (candidateBounds.Top, candidateBounds.Bottom); + } + else + { + reference = (bounds.Left, bounds.Right); + potential = (candidateBounds.Left, candidateBounds.Right); + } + + if (!considerSecondaryAxis || CalculatePercentInShadow(reference, potential) != 0) + { + secondaryAxisDistance = 0; + } + + score = maxDistance - (primaryAxisDistance + secondaryAxisDistance); + } + + return score; + } + + public static double GetScoreProjection( + NavigationDirection direction, + Rect bounds, + Rect candidateBounds, + XYFocusManifolds manifolds, + double maxDistance) + { + double score = 0; + double primaryAxisDistance; + double secondaryAxisDistance; + double percentInManifoldShadow = 0; + double percentInShadow; + + (double, double) potential; + (double, double) reference; + (double, double) currentManifold; + + if (direction == NavigationDirection.Left || direction == NavigationDirection.Right) + { + reference = (bounds.Top, bounds.Bottom); + currentManifold = manifolds.HManifold; + potential = (candidateBounds.Top, candidateBounds.Bottom); + } + else + { + reference = (bounds.Left, bounds.Right); + currentManifold = manifolds.VManifold; + potential = (candidateBounds.Left, candidateBounds.Right); + } + + primaryAxisDistance = CalculatePrimaryAxisDistance(direction, bounds, candidateBounds); + secondaryAxisDistance = CalculateSecondaryAxisDistance(direction, bounds, candidateBounds); + + if (primaryAxisDistance >= 0) + { + percentInShadow = CalculatePercentInShadow(reference, potential); + + if (percentInShadow >= InShadowThresholdForSecondaryAxis) + { + percentInManifoldShadow = CalculatePercentInShadow(currentManifold, potential); + secondaryAxisDistance = maxDistance; + } + + // The score needs to be a positive number so we make these distances positive numbers + primaryAxisDistance = maxDistance - primaryAxisDistance; + secondaryAxisDistance = maxDistance - secondaryAxisDistance; + + if (percentInShadow >= InShadowThreshold) + { + percentInShadow = 1; + primaryAxisDistance = primaryAxisDistance * 2; + } + + // Potential elements in the shadow get a multiplier to their final score + score = CalculateScore(percentInShadow, primaryAxisDistance, secondaryAxisDistance, + percentInManifoldShadow); + } + + return score; + } + + public static void UpdateManifolds( + NavigationDirection direction, + Rect bounds, + Rect newFocusBounds, + XYFocusManifolds manifolds) + { + var (vManifold, hManifold) = (manifolds.VManifold, manifolds.HManifold); + + if (vManifold.Right < 0) + { + vManifold = (bounds.Left, bounds.Right); + } + + if (hManifold.Bottom < 0) + { + hManifold = (bounds.Top, bounds.Bottom); + } + + if (direction == NavigationDirection.Left || direction == NavigationDirection.Right) + { + hManifold = ( + Math.Max(Math.Max(newFocusBounds.Top, bounds.Top), hManifold.Top), + Math.Min(Math.Min(newFocusBounds.Bottom, bounds.Bottom), hManifold.Bottom)); + + // It's possible to get into a situation where the newFocusedElement to the right / left has no overlap with the current edge. + if (hManifold.Bottom <= hManifold.Top) + { + hManifold = (newFocusBounds.Top, newFocusBounds.Bottom); + } + + vManifold = (newFocusBounds.Left, newFocusBounds.Right); + } + else if (direction == NavigationDirection.Up || direction == NavigationDirection.Down) + { + vManifold = ( + Math.Max(Math.Max(newFocusBounds.Left, bounds.Left), vManifold.Left), + Math.Min(Math.Min(newFocusBounds.Right, bounds.Right), vManifold.Right)); + + // It's possible to get into a situation where the newFocusedElement to the right / left has no overlap with the current edge. + if (vManifold.Right <= vManifold.Left) + { + vManifold = (newFocusBounds.Left, newFocusBounds.Right); + } + + hManifold = (newFocusBounds.Top, newFocusBounds.Bottom); + } + + (manifolds.VManifold, manifolds.HManifold) = (vManifold, hManifold); + } + + private static double CalculateScore( + double percentInShadow, + double primaryAxisDistance, + double secondaryAxisDistance, + double percentInManifoldShadow) + { + var score = (percentInShadow * PercentInShadowWeight) + + (primaryAxisDistance * PrimaryAxisDistanceWeight) + + (secondaryAxisDistance * SecondaryAxisDistanceWeight) + + (percentInManifoldShadow * PercentInManifoldShadowWeight); + + return score; + } + + public static bool ShouldCandidateBeConsideredForRanking( + Rect bounds, + Rect candidateBounds, + double maxDistance, + NavigationDirection direction, + Rect exclusionRect, + bool ignoreCone) + { + // Consider a candidate only if: + // 1. It doesn't have an empty rect as its bounds + // 2. It doesn't contain the currently focused element + // 3. Its bounds don't intersect with the rect we were asked to avoid looking into (Exclusion Rect) + // 4. Its bounds aren't contained in the rect we were asked to avoid looking into (Exclusion Rect) + if (candidateBounds.IsEmpty() || + candidateBounds.Contains(bounds) || + exclusionRect.Intersects(candidateBounds) || + exclusionRect.Contains(candidateBounds)) + { + return false; + } + + // We've decided to disable the use of the cone for vertical navigation. + if (ignoreCone || direction == NavigationDirection.Down || direction == NavigationDirection.Up) { return true; } + + Vector originTop = new(0, (float)bounds.Top); + Vector originBottom = new(0, (float)bounds.Bottom); + + var candidateAsPoints = new Vector[] + { + candidateBounds.TopLeft, + candidateBounds.BottomLeft, + candidateBounds.BottomRight, + candidateBounds.TopRight + }; + + // We make the maxDistance twice the normal distance to ensure that all the elements are encapsulated inside the cone. This + // also aids in scenarios where the original max distance is still less than one of the points (due to the angles) + maxDistance = maxDistance * 2; + + Span cone = stackalloc Vector[4]; + // Note: our y-axis is inverted + if (direction == NavigationDirection.Left) + { + // We want to start the origin one pixel to the left to cover overlapping scenarios where the end of a candidate element + // could be overlapping with the origin (before the shift) + originTop = new Vector(bounds.Left - 1, originTop.Y); + originBottom = new Vector(bounds.Left - 1, originBottom.Y); + + // We have two angles. Find a point (for each angle) on the line and rotate based on the direction + var rotation = Math.PI; // 180 degrees + var sides = new Vector[] + { + new( + (originTop.X + maxDistance * Math.Cos(rotation + ConeAngle)), + (originTop.Y + maxDistance * Math.Sin(rotation + ConeAngle))), + new( + (originBottom.X + maxDistance * Math.Cos(rotation - ConeAngle)), + (originBottom.Y + maxDistance * Math.Sin(rotation - ConeAngle))) + }; + + // Order points in counterclockwise direction + cone[0] = originTop; + cone[1] = sides[0]; + cone[2] = sides[1]; + cone[3] = originBottom; + } + else if (direction == NavigationDirection.Right) + { + // We want to start the origin one pixel to the right to cover overlapping scenarios where the end of a candidate element + // could be overlapping with the origin (before the shift) + originTop = new Vector(bounds.Right + 1, originTop.Y); + originBottom = new Vector(bounds.Right + 1, originBottom.Y); + + // We have two angles. Find a point (for each angle) on the line and rotate based on the direction + double rotation = 0; + var sides = new Vector[] + { + new( + (originTop.X + maxDistance * Math.Cos(rotation + ConeAngle)), + (originTop.Y + maxDistance * Math.Sin(rotation + ConeAngle))), + new( + (originBottom.X + maxDistance * Math.Cos(rotation - ConeAngle)), + (originBottom.Y + maxDistance * Math.Sin(rotation - ConeAngle))) + }; + + // Order points in counterclockwise direction + cone[0] = originBottom; + cone[1] = sides[0]; + cone[2] = sides[1]; + cone[3] = originTop; + } + + // There are three scenarios we should check that will allow us to know whether we should consider the candidate element. + // 1) The candidate element and the vision cone intersect + // 2) The candidate element is completely inside the vision cone + // 3) The vision cone is completely inside the bounds of the candidate element (unlikely) + + return MathUtilities.DoPolygonsIntersect(4, cone, 4, candidateAsPoints) + || MathUtilities.IsEntirelyContained(4, candidateAsPoints, 4, cone) + || MathUtilities.IsEntirelyContained(4, cone, 4, candidateAsPoints); + } + + private static double CalculatePrimaryAxisDistance( + NavigationDirection direction, + Rect bounds, + Rect candidateBounds) + { + double primaryAxisDistance = -1; + var isOverlapping = bounds.Intersects(candidateBounds); + + if (bounds == candidateBounds) return -1; // We shouldn't be calculating the distance from ourselves + + if (direction == NavigationDirection.Left + && (candidateBounds.Right <= bounds.Left || (isOverlapping && candidateBounds.Left <= bounds.Left))) + primaryAxisDistance = Math.Abs(bounds.Left - candidateBounds.Right); + else if (direction == NavigationDirection.Right + && (candidateBounds.Left >= bounds.Right || (isOverlapping && candidateBounds.Right >= bounds.Right))) + primaryAxisDistance = Math.Abs(candidateBounds.Left - bounds.Right); + else if (direction == NavigationDirection.Up + && (candidateBounds.Bottom <= bounds.Top || (isOverlapping && candidateBounds.Top <= bounds.Top))) + primaryAxisDistance = Math.Abs(bounds.Top - candidateBounds.Bottom); + else if (direction == NavigationDirection.Down + && (candidateBounds.Top >= bounds.Bottom || (isOverlapping && candidateBounds.Bottom >= bounds.Bottom))) + primaryAxisDistance = Math.Abs(candidateBounds.Top - bounds.Bottom); + + return primaryAxisDistance; + } + + private static double CalculateSecondaryAxisDistance( + NavigationDirection direction, + Rect bounds, + Rect candidateBounds) + { + double secondaryAxisDistance; + + if (direction == NavigationDirection.Left || direction == NavigationDirection.Right) + // calculate secondary axis distance for the case where the element is not in the shadow + secondaryAxisDistance = candidateBounds.Top < bounds.Top ? + Math.Abs(bounds.Top - candidateBounds.Bottom) : + Math.Abs(candidateBounds.Top - bounds.Bottom); + else + // calculate secondary axis distance for the case where the element is not in the shadow + secondaryAxisDistance = candidateBounds.Left < bounds.Left ? + Math.Abs(bounds.Left - candidateBounds.Right) : + Math.Abs(candidateBounds.Left - bounds.Right); + + return secondaryAxisDistance; + } + + /// Calculates the percentage of the potential element that is in the shadow of the reference element. + /// In other words, this method calculates percentage overlap of two elements ranges (top+bottom or left+right). + private static double CalculatePercentInShadow( + (double first, double second) referenceManifold, + (double first, double second) potentialManifold) + { + if (referenceManifold.first > potentialManifold.second || referenceManifold.second <= potentialManifold.first) + // Potential is not in the reference's shadow. + return 0; + + var shadow = Math.Min(referenceManifold.second, potentialManifold.second) - + Math.Max(referenceManifold.first, potentialManifold.first); + shadow = Math.Abs(shadow); + + var potentialEdgeLength = Math.Abs(potentialManifold.second - potentialManifold.first); + var referenceEdgeLength = Math.Abs(referenceManifold.second - referenceManifold.first); + + var comparisonEdgeLength = referenceEdgeLength; + + if (comparisonEdgeLength >= potentialEdgeLength) comparisonEdgeLength = potentialEdgeLength; + + double percentInShadow = 1; + + if (comparisonEdgeLength != 0) percentInShadow = Math.Min(shadow / comparisonEdgeLength, 1.0); + + return percentInShadow; + } + + internal class XYFocusManifolds + { + public (double Left, double Right) VManifold { get; set; } + public (double Top, double Bottom) HManifold { get; set; } + + public XYFocusManifolds() + { + Reset(); + } + + public void Reset() + { + VManifold = (-1.0, -1.0); + HManifold = (-1.0, -1.0); + } + } +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusHelpers.cs b/src/Avalonia.Base/Input/Navigation/XYFocusHelpers.cs new file mode 100644 index 00000000000..1914f6f6c63 --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocusHelpers.cs @@ -0,0 +1,23 @@ +using System; + +namespace Avalonia.Input; + +internal static class XYFocusHelpers +{ + internal static bool IsAllowedXYNavigationMode(this InputElement visual, KeyDeviceType? keyDeviceType) + { + return IsAllowedXYNavigationMode(XYFocus.GetNavigationModes(visual), keyDeviceType); + } + + private static bool IsAllowedXYNavigationMode(XYFocusNavigationModes modes, KeyDeviceType? keyDeviceType) + { + return keyDeviceType switch + { + null => true, // programmatic input, allow any subtree. + KeyDeviceType.Keyboard => modes.HasFlag(XYFocusNavigationModes.Keyboard), + KeyDeviceType.Gamepad => modes.HasFlag(XYFocusNavigationModes.Gamepad), + KeyDeviceType.Remote => modes.HasFlag(XYFocusNavigationModes.Remote), + _ => throw new ArgumentOutOfRangeException(nameof(keyDeviceType), keyDeviceType, null) + }; + } +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs b/src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs new file mode 100644 index 00000000000..2105e0f7aa1 --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocusNavigationModes.cs @@ -0,0 +1,38 @@ +using System; + +namespace Avalonia.Input; + +/// +/// Specifies the 2D directional navigation behavior when using different key devices. +/// +/// +/// See . +/// +[Flags] +public enum XYFocusNavigationModes +{ + /// + /// Any key device XY navigation is disabled. + /// + Disabled = 0, + + /// + /// Keyboard arrow keys can be used for 2D directional navigation. + /// + Keyboard = 1, + + /// + /// Gamepad controller DPad keys can be used for 2D directional navigation. + /// + Gamepad = 2, + + /// + /// Remote controller DPad keys can be used for 2D directional navigation. + /// + Remote = 4, + + /// + /// All key device XY navigation is disabled. + /// + Enabled = Gamepad | Remote | Keyboard +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusNavigationStrategy.cs b/src/Avalonia.Base/Input/Navigation/XYFocusNavigationStrategy.cs new file mode 100644 index 00000000000..f706078179d --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocusNavigationStrategy.cs @@ -0,0 +1,41 @@ +namespace Avalonia.Input; + +/// +/// Specifies the disambiguation strategy used for navigating between multiple candidate targets using +/// , , +/// , and . +/// +public enum XYFocusNavigationStrategy +{ + /// + /// Indicates that navigation strategy is inherited from the element's ancestors. If all ancestors have a value of Auto, the fallback strategy is Projection. + /// + Auto, + + /// + /// Indicates that focus moves to the first element encountered when projecting the edge of the currently focused element in the direction of navigation. + /// + Projection = 1, + + /// + /// Indicates that focus moves to the element closest to the axis of the navigation direction. + /// + /// + /// The edge of the bounding rect corresponding to the navigation direction is extended and projected to identify candidate targets. The first element encountered is identified as the target. In the case of multiple candidates, the closest element is identified as the target. If there are still multiple candidates, the topmost/leftmost element is identified as the candidate. + /// + NavigationDirectionDistance = 2, + + /// + /// Indicates that focus moves to the closest element based on the shortest 2D distance (Manhattan metric). + /// + /// + /// This distance is calculated by adding the primary distance and the secondary distance of each potential candidate. In the case of a tie: + /// - The first element to the left is selected if the navigation direction is up or down + /// - The first element to the top is selected if the navigation direction is left or right + /// Here we show how focus moves from A to B based on rectilinear distance. + /// - Distance (A, B, Down) = 10 + 0 = 10 + /// - Distance (A, C, Down) = 0 + 30 = 30 + /// - Distance (A, D, Down) 30 + 0 = 30 + /// + RectilinearDistance = 3 +} diff --git a/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs b/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs new file mode 100644 index 00000000000..4bfcb225027 --- /dev/null +++ b/src/Avalonia.Base/Input/Navigation/XYFocusOptions.cs @@ -0,0 +1,17 @@ +namespace Avalonia.Input.Navigation; + +internal class XYFocusOptions +{ + public InputElement? SearchRoot { get; set; } + public Rect ExclusionRect { get; set; } + public Rect? FocusHintRectangle { get; set; } + public Rect? FocusedElementBounds { get; set; } + public XYFocusNavigationStrategy? NavigationStrategyOverride { get; set; } + public bool IgnoreClipping { get; set; } = true; + public bool IgnoreCone { get; set; } + public KeyDeviceType? KeyDeviceType { get; set; } + public bool ConsiderEngagement { get; set; } = true; + public bool UpdateManifold { get; set; } = true; + public bool UpdateManifoldsFromFocusHintRect { get; set; } + public bool IgnoreOcclusivity { get; set; } +} diff --git a/src/Avalonia.Base/Utilities/MathUtilities.cs b/src/Avalonia.Base/Utilities/MathUtilities.cs index feb1097b5f3..604a9c62117 100644 --- a/src/Avalonia.Base/Utilities/MathUtilities.cs +++ b/src/Avalonia.Base/Utilities/MathUtilities.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; namespace Avalonia.Utilities { @@ -358,7 +359,95 @@ public static (double min, double max) GetMinMaxFromDelta(double initialValue, d { return GetMinMax(initialValue, initialValue + delta); } + +#if !BUILDTASK + public static int WhichPolygonSideIntersects( + uint cPoly, + ReadOnlySpan pPtPoly, + Vector ptCurrent, + Vector vecEdge) + { + uint nPositive = 0; + uint nNegative = 0; + uint nZero = 0; + + var vecEdgeNormal = new Point(-vecEdge.Y, vecEdge.X); + + for (var i = 0; i < cPoly; i++) + { + var vecCurrent = ptCurrent - pPtPoly[i]; + var rDot = Vector.Dot(vecCurrent, vecEdgeNormal); + + if (rDot > 0.0f) + { + nPositive++; + } + else if (rDot < 0.0f) + { + nNegative++; + } + else + { + nZero++; + } + if ((nPositive > 0 && nNegative > 0) || (nZero > 0)) + { + return 0; + } + } + + return nPositive > 0 ? 1 : -1; + } + + public static bool DoPolygonsIntersect( + uint cPolyA, + ReadOnlySpan pPtPolyA, + uint cPolyB, + ReadOnlySpan pPtPolyB) + { + for (var i = 0; i < cPolyA; i++) + { + var vecEdge = pPtPolyA[(int)((i + 1) % cPolyA)] - pPtPolyA[i]; + if (WhichPolygonSideIntersects(cPolyB, pPtPolyB, pPtPolyA[i], vecEdge) < 0) + { + return false; + } + } + + for (var i = 0; i < cPolyB; i++) + { + var vecEdge = pPtPolyB[(int)((i + 1) % cPolyB)] - pPtPolyB[i]; + if (WhichPolygonSideIntersects(cPolyA, pPtPolyA, pPtPolyB[i], vecEdge) < 0) + { + return false; + } + } + + return true; + } + + public static bool IsEntirelyContained( + uint cPolyA, + ReadOnlySpan pPtPolyA, + uint cPolyB, + ReadOnlySpan pPtPolyB) + { + for (var i = 0; i < cPolyB; i++) + { + var vecEdge = pPtPolyB[(i + 1) % (int)cPolyB] - pPtPolyB[i]; + if (WhichPolygonSideIntersects(cPolyA, pPtPolyA, pPtPolyB[i], vecEdge) <= 0) + { + // The whole of the polygon is entirely on the outside of the edge, + // so we can never intersect + return false; + } + } + + return true; + } +#endif + private static void ThrowCannotBeGreaterThanException(T min, T max) { throw new ArgumentException($"{min} cannot be greater than {max}."); diff --git a/src/Avalonia.Base/VisualTree/VisualExtensions.cs b/src/Avalonia.Base/VisualTree/VisualExtensions.cs index e244323ed97..670d879f29e 100644 --- a/src/Avalonia.Base/VisualTree/VisualExtensions.cs +++ b/src/Avalonia.Base/VisualTree/VisualExtensions.cs @@ -209,6 +209,11 @@ public static IEnumerable GetSelfAndVisualAncestors(this Visual visual) public static TransformedBounds? GetTransformedBounds(this Visual visual) { + if (visual is null) + { + throw new ArgumentNullException(nameof(visual)); + } + Rect clip = default; var transform = Matrix.Identity; diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs index 68a81edafbe..778fc032a9e 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -711,7 +711,9 @@ protected override void OnKeyDown(KeyEventArgs e) else { // The drop down is not open, the Down key will toggle it open. - if (e.Key == Key.Down) + // Ignore key buttons, if they are used for XY focus. + if (e.Key == Key.Down + && !XYFocusHelpers.IsAllowedXYNavigationMode(this, e.KeyDeviceType)) { SetCurrentValue(IsDropDownOpenProperty, true); e.Handled = true; diff --git a/src/Avalonia.Controls/ButtonSpinner.cs b/src/Avalonia.Controls/ButtonSpinner.cs index 36c219684e1..5330320e1b9 100644 --- a/src/Avalonia.Controls/ButtonSpinner.cs +++ b/src/Avalonia.Controls/ButtonSpinner.cs @@ -157,6 +157,12 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) /// protected override void OnKeyDown(KeyEventArgs e) { + // If XY navigation is enabled - do not spin with arrow keys, instead use spinner buttons. + if (this.IsAllowedXYNavigationMode(e.KeyDeviceType)) + { + return; + } + switch (e.Key) { case Key.Up: diff --git a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs index d37b51bee55..b570b2c4ff0 100644 --- a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs +++ b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs @@ -432,13 +432,14 @@ protected override void OnKeyUp(KeyEventArgs e) { var key = e.Key; - if ((key == Key.Space || key == Key.Enter) && IsEffectivelyEnabled) // Key.GamepadA is not currently supported + if ((key == Key.Space || key == Key.Enter) && IsEffectivelyEnabled) { // Since the TextBox is used for direct date entry, // it isn't supported to open the popup/flyout using these keys. // Other controls open the popup/flyout here. } - else if (key == Key.Down && e.KeyModifiers.HasAllFlags(KeyModifiers.Alt) && IsEffectivelyEnabled) + else if (key == Key.Down && e.KeyModifiers.HasAllFlags(KeyModifiers.Alt) && IsEffectivelyEnabled + && !XYFocusHelpers.IsAllowedXYNavigationMode(this, e.KeyDeviceType)) { // It is only possible to open the popup using these keys. // This is important as the down key is handled by calendar. diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index bd05d4947cc..f33455f8408 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -211,17 +211,17 @@ protected override void OnKeyDown(KeyEventArgs e) SetCurrentValue(IsDropDownOpenProperty, false); e.Handled = true; } - else if (!IsDropDownOpen) + // Ignore key buttons, if they are used for XY focus. + else if (!IsDropDownOpen + && !XYFocusHelpers.IsAllowedXYNavigationMode(this, e.KeyDeviceType)) { if (e.Key == Key.Down) { - SelectNext(); - e.Handled = true; + e.Handled = SelectNext(); } else if (e.Key == Key.Up) { - SelectPrevious(); - e.Handled = true; + e.Handled = SelectPrevious(); } } // This part of code is needed just to acquire initial focus, subsequent focus navigation will be done by ItemsControl. @@ -247,12 +247,7 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { if (IsFocused) { - if (e.Delta.Y < 0) - SelectNext(); - else - SelectPrevious(); - - e.Handled = true; + e.Handled = e.Delta.Y < 0 ? SelectNext() : SelectPrevious(); } } else @@ -481,10 +476,10 @@ private void SelectFocusedItem() } } - private void SelectNext() => MoveSelection(SelectedIndex, 1, WrapSelection); - private void SelectPrevious() => MoveSelection(SelectedIndex, -1, WrapSelection); + private bool SelectNext() => MoveSelection(SelectedIndex, 1, WrapSelection); + private bool SelectPrevious() => MoveSelection(SelectedIndex, -1, WrapSelection); - private void MoveSelection(int startIndex, int step, bool wrap) + private bool MoveSelection(int startIndex, int step, bool wrap) { static bool IsSelectable(object? o) => (o as AvaloniaObject)?.GetValue(IsEnabledProperty) ?? true; @@ -503,7 +498,7 @@ private void MoveSelection(int startIndex, int step, bool wrap) } else { - return; + return false; } } @@ -513,9 +508,11 @@ private void MoveSelection(int startIndex, int step, bool wrap) if (IsSelectable(item) && IsSelectable(container)) { SelectedIndex = i; - break; + return true; } } + + return false; } /// diff --git a/src/Avalonia.Controls/GridSplitter.cs b/src/Avalonia.Controls/GridSplitter.cs index b9e77ca6dbd..ee615a26f87 100644 --- a/src/Avalonia.Controls/GridSplitter.cs +++ b/src/Avalonia.Controls/GridSplitter.cs @@ -60,6 +60,7 @@ public class GridSplitter : Thumb private static readonly Cursor s_rowSplitterCursor = new Cursor(StandardCursorType.SizeNorthSouth); private ResizeData? _resizeData; + private bool _isFocusEngaged; /// /// Indicates whether the Splitter resizes the Columns, Rows, or Both. @@ -449,11 +450,17 @@ protected override void OnDragCompleted(VectorEventArgs e) protected override void OnKeyDown(KeyEventArgs e) { - Key key = e.Key; + var usingXyNavigation = this.IsAllowedXYNavigationMode(e.KeyDeviceType); + var allowArrowKeys = _isFocusEngaged || !usingXyNavigation; - switch (key) + switch (e.Key) { + case Key.Enter when usingXyNavigation: + _isFocusEngaged = !_isFocusEngaged; + e.Handled = true; + break; case Key.Escape: + _isFocusEngaged = false; if (_resizeData != null) { CancelResize(); @@ -462,16 +469,16 @@ protected override void OnKeyDown(KeyEventArgs e) break; - case Key.Left: + case Key.Left when allowArrowKeys: e.Handled = KeyboardMoveSplitter(-KeyboardIncrement, 0); break; - case Key.Right: + case Key.Right when allowArrowKeys: e.Handled = KeyboardMoveSplitter(KeyboardIncrement, 0); break; - case Key.Up: + case Key.Up when allowArrowKeys: e.Handled = KeyboardMoveSplitter(0, -KeyboardIncrement); break; - case Key.Down: + case Key.Down when allowArrowKeys: e.Handled = KeyboardMoveSplitter(0, KeyboardIncrement); break; } diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index 3921e0f0723..3152eec2dbd 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -14,7 +14,7 @@ namespace Avalonia.Controls /// [TemplatePart("PART_HorizontalScrollBar", typeof(ScrollBar))] [TemplatePart("PART_VerticalScrollBar", typeof(ScrollBar))] - public class ScrollViewer : ContentControl, IScrollable, IScrollAnchorProvider + public class ScrollViewer : ContentControl, IScrollable, IScrollAnchorProvider, IInternalScroller { /// /// Defines the property. @@ -284,6 +284,8 @@ protected bool CanHorizontallyScroll get => HorizontalScrollBarVisibility != ScrollBarVisibility.Disabled; } + bool IInternalScroller.CanHorizontallyScroll => CanHorizontallyScroll; + /// /// Gets a value indicating whether the viewer can scroll vertically. /// @@ -292,6 +294,8 @@ protected bool CanVerticallyScroll get => VerticalScrollBarVisibility != ScrollBarVisibility.Disabled; } + bool IInternalScroller.CanVerticallyScroll => CanVerticallyScroll; + /// public Control? CurrentAnchor => (Presenter as IScrollAnchorProvider)?.CurrentAnchor; diff --git a/src/Avalonia.Controls/Slider.cs b/src/Avalonia.Controls/Slider.cs index 70e2737fe25..6fbfd6f41b1 100644 --- a/src/Avalonia.Controls/Slider.cs +++ b/src/Avalonia.Controls/Slider.cs @@ -87,6 +87,7 @@ public class Slider : RangeBase // Slider required parts private bool _isDragging; + private bool _isFocusEngaged; private Track? _track; private Button? _decreaseButton; private Button? _increaseButton; @@ -233,17 +234,29 @@ protected override void OnKeyDown(KeyEventArgs e) if (e.Handled || e.KeyModifiers != KeyModifiers.None) return; + var usingXyNavigation = this.IsAllowedXYNavigationMode(e.KeyDeviceType); + var allowArrowKeys = _isFocusEngaged || !usingXyNavigation; + var handled = true; switch (e.Key) { - case Key.Down: - case Key.Left: + case Key.Enter when usingXyNavigation: + _isFocusEngaged = !_isFocusEngaged; + handled = true; + break; + case Key.Escape when usingXyNavigation: + _isFocusEngaged = false; + handled = true; + break; + + case Key.Down when allowArrowKeys: + case Key.Left when allowArrowKeys: MoveToNextTick(IsDirectionReversed ? SmallChange : -SmallChange); break; - case Key.Up: - case Key.Right: + case Key.Up when allowArrowKeys: + case Key.Right when allowArrowKeys: MoveToNextTick(IsDirectionReversed ? -SmallChange : SmallChange); break; diff --git a/src/Avalonia.Controls/SplitButton/SplitButton.cs b/src/Avalonia.Controls/SplitButton/SplitButton.cs index c421fdc581a..fb0c9e717ff 100644 --- a/src/Avalonia.Controls/SplitButton/SplitButton.cs +++ b/src/Avalonia.Controls/SplitButton/SplitButton.cs @@ -309,7 +309,7 @@ protected override void OnKeyDown(KeyEventArgs e) { var key = e.Key; - if (key == Key.Space || key == Key.Enter) // Key.GamepadA is not currently supported + if (key == Key.Space || key == Key.Enter) { _isKeyboardPressed = true; UpdatePseudoClasses(); @@ -323,7 +323,7 @@ protected override void OnKeyUp(KeyEventArgs e) { var key = e.Key; - if (key == Key.Space || key == Key.Enter) // Key.GamepadA is not currently supported + if (key == Key.Space || key == Key.Enter) { _isKeyboardPressed = false; UpdatePseudoClasses(); @@ -335,7 +335,8 @@ protected override void OnKeyUp(KeyEventArgs e) e.Handled = true; } } - else if (key == Key.Down && e.KeyModifiers.HasAllFlags(KeyModifiers.Alt) && IsEffectivelyEnabled) + else if (key == Key.Down && e.KeyModifiers.HasAllFlags(KeyModifiers.Alt) && IsEffectivelyEnabled + && !XYFocusHelpers.IsAllowedXYNavigationMode(this, e.KeyDeviceType)) { OpenFlyout(); e.Handled = true; diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index cdc0fb8cf4c..ce6936b3006 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -1327,13 +1327,19 @@ protected override void OnKeyDown(KeyEventArgs e) case Key.Left: selection = DetectSelection(); MoveHorizontal(-1, hasWholeWordModifiers, selection, true); - movement = true; + if (caretIndex != _presenter.CaretIndex) + { + movement = true; + } break; case Key.Right: selection = DetectSelection(); MoveHorizontal(1, hasWholeWordModifiers, selection, true); - movement = true; + if (caretIndex != _presenter.CaretIndex) + { + movement = true; + } break; case Key.Up: diff --git a/src/Tizen/Avalonia.Tizen/Platform/Input/TizenKeyboardDevice.cs b/src/Tizen/Avalonia.Tizen/Platform/Input/TizenKeyboardDevice.cs index 1046134c430..126c4e11e2f 100644 --- a/src/Tizen/Avalonia.Tizen/Platform/Input/TizenKeyboardDevice.cs +++ b/src/Tizen/Avalonia.Tizen/Platform/Input/TizenKeyboardDevice.cs @@ -46,7 +46,7 @@ internal class TizenKeyboardDevice : KeyboardDevice, IKeyboardDevice { "XF86PowerOff", Key.Sleep }, { "XF86PlayBack", Key.MediaPlayPause }, { "XF86Home", Key.MediaHome }, - { "XF86Back", Key.Back }, + { "XF86Back", Key.Escape }, // Back button should be mapped as Esc { "XF86Exit", Key.Cancel }, { "Shift_L", Key.LeftShift }, diff --git a/tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs b/tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs new file mode 100644 index 00000000000..1b8238f4b78 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Input/KeyboardNavigationTests_XY.cs @@ -0,0 +1,421 @@ +using System; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Input; + +public class KeyboardNavigationTests_XY : ScopedTestBase +{ + private static (Canvas canvas, Button[] buttons) CreateXYTestLayout() + { + // 111 + // 111 + // 111 + // 2 + // 3 + // + // 4 + Button x1, x2, x3, x4; + var canvas = new Canvas + { + Width = 500, + Children = + { + (x1 = new Button + { + Content = "A", + [Canvas.LeftProperty] = 50, [Canvas.TopProperty] = 0, Width = 150, Height = 150, + }), + (x2 = new Button + { + Content = "B", + [Canvas.LeftProperty] = 400, [Canvas.TopProperty] = 150, Width = 50, Height = 50, + }), + (x3 = new Button + { + Content = "C", + [Canvas.LeftProperty] = 0, [Canvas.TopProperty] = 200, Width = 50, Height = 50, + }), + (x4 = new Button + { + Content = "D", + [Canvas.LeftProperty] = 100, [Canvas.TopProperty] = 300, Width = 50, Height = 50, + }) + } + }; + + return (canvas, new[] { x1, x2, x3, x4 }); + } + + [Theory] + [InlineData(1, NavigationDirection.Down, 4)] + [InlineData(1, NavigationDirection.Up, -1)] + [InlineData(1, NavigationDirection.Left, -1)] + [InlineData(1, NavigationDirection.Right, 2)] + // TODO: [InlineData(2, NavigationDirection.Down, 4)] Actual: 3 + // TODO: [InlineData(2, NavigationDirection.Up, -1)] Actual 1 + [InlineData(2, NavigationDirection.Left, 1)] + [InlineData(2, NavigationDirection.Right, -1)] + [InlineData(3, NavigationDirection.Down, 4)] + // TODO: [InlineData(3, NavigationDirection.Up, 1)] Actual: 2 + [InlineData(3, NavigationDirection.Left, -1)] + // TODO: [InlineData(3, NavigationDirection.Right, 4)] Actual: 1 + [InlineData(4, NavigationDirection.Down, -1)] + [InlineData(4, NavigationDirection.Up, 1)] + [InlineData(4, NavigationDirection.Left, 3)] + [InlineData(4, NavigationDirection.Right, 2)] + public void Projection_Focus_Depending_On_Direction(int from, NavigationDirection direction, int to) + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var (canvas, buttons) = CreateXYTestLayout(); + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = canvas + }; + window.Show(); + + var fromButton = buttons[from - 1]; + fromButton.SetValue(XYFocus.UpNavigationStrategyProperty, XYFocusNavigationStrategy.Projection); + fromButton.SetValue(XYFocus.LeftNavigationStrategyProperty, XYFocusNavigationStrategy.Projection); + fromButton.SetValue(XYFocus.RightNavigationStrategyProperty, XYFocusNavigationStrategy.Projection); + fromButton.SetValue(XYFocus.DownNavigationStrategyProperty, XYFocusNavigationStrategy.Projection); + + var result = KeyboardNavigationHandler.GetNext(fromButton, direction) as Button; + + Assert.Equal(to, result == null ? -1 : Array.IndexOf(buttons, result) + 1); + } + + [Theory] + [InlineData(1, NavigationDirection.Down, 3)] + [InlineData(1, NavigationDirection.Up, -1)] + [InlineData(1, NavigationDirection.Left, 3)] + [InlineData(1, NavigationDirection.Right, 2)] + [InlineData(2, NavigationDirection.Down, 3)] + [InlineData(2, NavigationDirection.Up, 1)] + [InlineData(2, NavigationDirection.Left, 1)] + [InlineData(2, NavigationDirection.Right, -1)] + [InlineData(3, NavigationDirection.Down, 4)] + [InlineData(3, NavigationDirection.Up, 1)] + [InlineData(3, NavigationDirection.Left, -1)] + [InlineData(3, NavigationDirection.Right, 1)] + [InlineData(4, NavigationDirection.Down, -1)] + [InlineData(4, NavigationDirection.Up, 3)] + [InlineData(4, NavigationDirection.Left, 3)] + [InlineData(4, NavigationDirection.Right, 2)] + public void RectilinearDistance_Focus_Depending_On_Direction(int from, NavigationDirection direction, int to) + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var (canvas, buttons) = CreateXYTestLayout(); + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = canvas + }; + window.Show(); + + var fromButton = buttons[from - 1]; + fromButton.SetValue(XYFocus.UpNavigationStrategyProperty, XYFocusNavigationStrategy.RectilinearDistance); + fromButton.SetValue(XYFocus.LeftNavigationStrategyProperty, XYFocusNavigationStrategy.RectilinearDistance); + fromButton.SetValue(XYFocus.RightNavigationStrategyProperty, XYFocusNavigationStrategy.RectilinearDistance); + fromButton.SetValue(XYFocus.DownNavigationStrategyProperty, XYFocusNavigationStrategy.RectilinearDistance); + + var result = KeyboardNavigationHandler.GetNext(fromButton, direction) as Button; + + Assert.Equal(to, result == null ? -1 : Array.IndexOf(buttons, result) + 1); + } + + [Theory] + [InlineData(1, NavigationDirection.Down, 2)] + [InlineData(1, NavigationDirection.Up, -1)] + [InlineData(1, NavigationDirection.Left, 3)] + [InlineData(1, NavigationDirection.Right, 2)] + [InlineData(2, NavigationDirection.Down, 3)] + [InlineData(2, NavigationDirection.Up, 1)] + [InlineData(2, NavigationDirection.Left, 1)] + [InlineData(2, NavigationDirection.Right, -1)] + [InlineData(3, NavigationDirection.Down, 4)] + [InlineData(3, NavigationDirection.Up, 2)] + [InlineData(3, NavigationDirection.Left, -1)] + [InlineData(3, NavigationDirection.Right, 1)] + [InlineData(4, NavigationDirection.Down, -1)] + [InlineData(4, NavigationDirection.Up, 3)] + [InlineData(4, NavigationDirection.Left, 3)] + [InlineData(4, NavigationDirection.Right, 2)] + public void NavigationDirectionDistance_Focus_Depending_On_Direction(int from, NavigationDirection direction, int to) + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var (canvas, buttons) = CreateXYTestLayout(); + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = canvas + }; + window.Show(); + + var fromButton = buttons[from - 1]; + fromButton.SetValue(XYFocus.UpNavigationStrategyProperty, XYFocusNavigationStrategy.NavigationDirectionDistance); + fromButton.SetValue(XYFocus.LeftNavigationStrategyProperty, XYFocusNavigationStrategy.NavigationDirectionDistance); + fromButton.SetValue(XYFocus.RightNavigationStrategyProperty, XYFocusNavigationStrategy.NavigationDirectionDistance); + fromButton.SetValue(XYFocus.DownNavigationStrategyProperty, XYFocusNavigationStrategy.NavigationDirectionDistance); + + var result = KeyboardNavigationHandler.GetNext(fromButton, direction) as Button; + + Assert.Equal(to, result == null ? -1 : Array.IndexOf(buttons, result) + 1); + } + + [Fact] + public void Uses_XY_Directional_Overrides() + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var left = new Button(); + var right = new Button(); + var up = new Button(); + var down = new Button(); + var center = new Button + { + [XYFocus.LeftProperty] = left, + [XYFocus.RightProperty] = right, + [XYFocus.UpProperty] = up, + [XYFocus.DownProperty] = down, + }; + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = new Canvas + { + Children = + { + left, right, up, down, center + } + } + }; + window.Show(); + + Assert.Equal(left, KeyboardNavigationHandler.GetNext(center, NavigationDirection.Left)); + Assert.Equal(right, KeyboardNavigationHandler.GetNext(center, NavigationDirection.Right)); + Assert.Equal(up, KeyboardNavigationHandler.GetNext(center, NavigationDirection.Up)); + Assert.Equal(down, KeyboardNavigationHandler.GetNext(center, NavigationDirection.Down)); + } + + [Fact] + public void XY_Directional_Override_Discarded_If_Not_Part_Of_The_Same_Root() + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var left = new Button(); + var center = new Button + { + [XYFocus.LeftProperty] = left + }; + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = center + }; + window.Show(); + + Assert.Null(KeyboardNavigationHandler.GetNext(center, NavigationDirection.Left)); + } + + [Fact] + public void Parent_Can_Override_Navigation_When_Directional_Is_Set() + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + // With double stack panel layout we have something like this: + // [ [ EXPECTED, CURRENT ] CANDIDATE ] + // Where normally from Current focus would go to the Candidate. + // But since we set `XYFocus.Right` on nested StackPanel, it should be used instead. + // But ONLY if Candidate isn't part of that nested StackPanel (it isn't). + + var current = new Button(); + var candidate = new Button(); + var expectedOverride = new Button(); + var parent = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { expectedOverride, current }, + [XYFocus.RightProperty] = expectedOverride, + // Property value to simplify test. + [XYFocus.RightNavigationStrategyProperty] = XYFocusNavigationStrategy.RectilinearDistance + }; + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { parent, candidate } + } + }; + window.Show(); + + Assert.Equal(expectedOverride, KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right)); + } + + [Fact] + public void Clipped_Element_Should_Not_Be_Focused() + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var current = new Button() { Height = 20 }; + var candidate = new Button() { Height = 20 }; + var parent = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 20, + Children = { current, candidate } + }; + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = parent, + Height = 30 + }; + window.Show(); + + Assert.Null(KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down)); + } + + [Fact] + public void Clipped_Element_Should_Not_Focused_If_Inside_Of_ScrollViewer() + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var current = new Button() { Height = 20 }; + var candidate = new Button() { Height = 20 }; + var parent = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 20, + Children = { current, candidate } + }; + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = new ScrollViewer + { + Content = parent + }, + Height = 30 + }; + window.Show(); + + Assert.Equal(candidate, KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down)); + } + + [Theory] + [InlineData(Key.Left, NavigationDirection.Left)] + [InlineData(Key.Right, NavigationDirection.Right)] + [InlineData(Key.Up, NavigationDirection.Up)] + [InlineData(Key.Down, NavigationDirection.Down)] + public void Arrow_Key_Should_Focus_Element(Key key, NavigationDirection direction) + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var candidate = new Button(); + var current = new Button(); + current[direction switch + { + NavigationDirection.Left => XYFocus.LeftProperty, + NavigationDirection.Right => XYFocus.RightProperty, + NavigationDirection.Up => XYFocus.UpProperty, + NavigationDirection.Down => XYFocus.DownProperty, + _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null) + }] = candidate; + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = new Canvas + { + Children = { current, candidate } + } + }; + window.Show(); + Assert.True(current.Focus()); + + var args = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, Source = current }; + window.RaiseEvent(args); + + Assert.Equal(candidate, FocusManager.GetFocusManager(current)!.GetFocusedElement()); + Assert.True(args.Handled); + } + + [Theory] + [InlineData(Key.Left, NavigationDirection.Left)] + [InlineData(Key.Right, NavigationDirection.Right)] + [InlineData(Key.Up, NavigationDirection.Up)] + [InlineData(Key.Down, NavigationDirection.Down)] + public void Arrow_Key_Should_Not_Be_Handled_If_No_Focus(Key key, NavigationDirection direction) + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var current = new Button(); + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = new Canvas + { + Children = { current } + } + }; + window.Show(); + Assert.True(current.Focus()); + + var args = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, Source = current }; + window.RaiseEvent(args); + + Assert.Equal(current, FocusManager.GetFocusManager(current)!.GetFocusedElement()); + Assert.False(args.Handled); + } + + [Fact] + public void Can_Focus_Child_Of_Current_Focused() + { + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var candidate = new Button() { Height = 20, Width = 20 }; + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = candidate, + Height = 30 + }; + window.Show(); + + Assert.Null(KeyboardNavigationHandler.GetNext(window, NavigationDirection.Down)); + } + + [Fact] + public void Can_Focus_Any_Element_If_Nothing_Was_Focused() + { + // In the future we might auto-focus any element, but for now XY algorithm should be aware of Avalonia specifics. + using var _ = UnitTestApplication.Start(TestServices.FocusableWindow); + + var candidate = new Button(); + var window = new Window + { + [XYFocus.NavigationModesProperty] = XYFocusNavigationModes.Enabled, + Content = new Canvas + { + Children = { candidate } + } + }; + window.Show(); + + Assert.Null(FocusManager.GetFocusManager(window)!.GetFocusedElement()); + + var args = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down, Source = window }; + window.RaiseEvent(args); + + Assert.Equal(candidate, FocusManager.GetFocusManager(window)!.GetFocusedElement()); + } +} diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 514add01ec1..40a7c21dbd3 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -1213,7 +1213,7 @@ public static IDisposable Start() focusManager: new FocusManager(), fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), - keyboardNavigation: new KeyboardNavigationHandler(), + keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), renderInterface: new HeadlessPlatformRenderInterface(), textShaperImpl: new HeadlessTextShaperStub())); diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs index 55450c3b4fa..56f131b410a 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs @@ -891,7 +891,7 @@ public void Keys_Allow_Undo(Key key, KeyModifiers modifiers) private static TestServices FocusServices => TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), - keyboardNavigation: new KeyboardNavigationHandler(), + keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), renderInterface: new HeadlessPlatformRenderInterface(), fontManagerImpl: new HeadlessFontManagerStub(), diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index babcb6ff23d..368da442ef0 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -1349,7 +1349,7 @@ public static IDisposable Start() focusManager: new FocusManager(), fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), - keyboardNavigation: new KeyboardNavigationHandler(), + keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), renderInterface: new HeadlessPlatformRenderInterface(), textShaperImpl: new HeadlessTextShaperStub())); diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 00d13c6cc70..f908f6bff46 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -1411,7 +1411,7 @@ public void Should_Throw_ArgumentOutOfRange() private static TestServices FocusServices => TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), - keyboardNavigation: new KeyboardNavigationHandler(), + keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), standardCursorFactory: Mock.Of(), textShaperImpl: new HeadlessTextShaperStub(), diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 47280b859dc..38e41a38704 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -1797,7 +1797,7 @@ private IDisposable Start() focusManager: new FocusManager(), fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), - keyboardNavigation: new KeyboardNavigationHandler(), + keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), renderInterface: new HeadlessPlatformRenderInterface(), textShaperImpl: new HeadlessTextShaperStub())); diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index 95a1436ac9d..12c7aee4e95 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -49,13 +49,28 @@ public class TestServices public static readonly TestServices RealFocus = new TestServices( focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), - keyboardNavigation: new KeyboardNavigationHandler(), + keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), assetLoader: new StandardAssetLoader(), renderInterface: new HeadlessPlatformRenderInterface(), fontManagerImpl: new HeadlessFontManagerStub(), textShaperImpl: new HeadlessTextShaperStub()); + public static readonly TestServices FocusableWindow = new TestServices( + focusManager: new FocusManager(), + keyboardDevice: () => new KeyboardDevice(), + keyboardNavigation: () => new KeyboardNavigationHandler(), + inputManager: new InputManager(), + assetLoader: new StandardAssetLoader(), + platform: new StandardRuntimePlatform(), + renderInterface: new HeadlessPlatformRenderInterface(), + standardCursorFactory: new HeadlessCursorFactoryStub(), + theme: () => CreateSimpleTheme(), + dispatcherImpl: new NullDispatcherImpl(), + fontManagerImpl: new HeadlessFontManagerStub(), + textShaperImpl: new HeadlessTextShaperStub(), + windowingPlatform: new MockWindowingPlatform()); + public static readonly TestServices TextServices = new TestServices( assetLoader: new StandardAssetLoader(), renderInterface: new HeadlessPlatformRenderInterface(), @@ -67,7 +82,7 @@ public TestServices( IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, - IKeyboardNavigationHandler keyboardNavigation = null, + Func keyboardNavigation = null, Func mouseDevice = null, IRuntimePlatform platform = null, IPlatformRenderInterface renderInterface = null, @@ -103,7 +118,7 @@ internal TestServices( IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, - IKeyboardNavigationHandler keyboardNavigation = null, + Func keyboardNavigation = null, Func mouseDevice = null, IRuntimePlatform platform = null, IPlatformRenderInterface renderInterface = null, @@ -127,7 +142,7 @@ internal TestServices( public IFocusManager FocusManager { get; } internal IGlobalClock GlobalClock { get; set; } public Func KeyboardDevice { get; } - public IKeyboardNavigationHandler KeyboardNavigation { get; } + public Func KeyboardNavigation { get; } public Func MouseDevice { get; } public IRuntimePlatform Platform { get; } public IPlatformRenderInterface RenderInterface { get; } @@ -144,7 +159,7 @@ internal TestServices With( IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, - IKeyboardNavigationHandler keyboardNavigation = null, + Func keyboardNavigation = null, Func mouseDevice = null, IRuntimePlatform platform = null, IPlatformRenderInterface renderInterface = null, diff --git a/tests/Avalonia.UnitTests/UnitTestApplication.cs b/tests/Avalonia.UnitTests/UnitTestApplication.cs index 5f7f3f20ecc..18ccff75f0b 100644 --- a/tests/Avalonia.UnitTests/UnitTestApplication.cs +++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs @@ -68,8 +68,8 @@ public override void RegisterServices() .BindToSelf(this) .Bind().ToConstant(Services.InputManager) .Bind().ToConstant(Services.KeyboardDevice?.Invoke()) - .Bind().ToConstant(Services.KeyboardNavigation) .Bind().ToConstant(Services.MouseDevice?.Invoke()) + .Bind().ToFunc(Services.KeyboardNavigation ?? (() => null)) .Bind().ToConstant(Services.Platform) .Bind().ToConstant(Services.RenderInterface) .Bind().ToConstant(Services.FontManagerImpl)