Browse Source
* Init * Remove XY navigation cache as it's no use * Use pooled collection for XY navigation * Restructure code a bit, fix IScroller handling * Init KeyboardNavigationTests_XY tests * Simplify XYFocus.GetNextFocusableElement usage * Minor fixes * Add more tests * Remove unused NuiKeyboardNavigationHandler * Finalizing * Fix tests * Add TODO12 * Make XYFocusOptions a class * Add TestServices.FocusableWindow and make KeyboardNavigationHandler lazy, as it can't be reused on multiple windows * Fix KeyboardNavigationHandler events handling, when focus was not actually changed * Add arrow key tests * Replace XYFocusKeyboardNavigationMode with more flexible XYFocusNavigationModes, integrate with KeyDeviceType input types * Make XY focus navigation less broken, when there is no starting focused control * Several Android TV compatibility improvements * Remap tizen Back button to Esc * Introduce internal XYFocusHelpers * Make ComboBox and AutoCompleteBox handle Key events only when it's needed * Make TextBox handle Key events only when it's needed * Ignore Alt+Down when XY navigation is enabled in CalendarDatePicker and SplitButton * Rename IsAllowedXYNavigationMode * Fix ButtonSpinner with XY navigation * Implement a very simple focus engagement for GridSplitter and Sliderpull/14530/head
committed by
GitHub
43 changed files with 2153 additions and 119 deletions
@ -0,0 +1,50 @@ |
|||
<UserControl xmlns="https://github.com/avaloniaui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
|||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
|||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
|||
xmlns:generic="clr-namespace:System.Collections.Generic;assembly=netstandard" |
|||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
|||
x:Class="ControlCatalog.Pages.FocusPage"> |
|||
<TabControl> |
|||
<TabItem Header="XY Focus"> |
|||
<StackPanel x:Name="TabRoot" XYFocus.NavigationModes="{Binding #KeyboardNavigation.SelectedItem}"> |
|||
<StackPanel Orientation="Horizontal" Spacing="10"> |
|||
<TextBlock Text="Navigation: " /> |
|||
<ComboBox x:Name="KeyboardNavigation" SelectedIndex="0"> |
|||
<ComboBox.ItemsSource> |
|||
<generic:List x:TypeArguments="XYFocusNavigationModes"> |
|||
<XYFocusNavigationModes>Enabled</XYFocusNavigationModes> |
|||
<XYFocusNavigationModes>Disabled</XYFocusNavigationModes> |
|||
</generic:List> |
|||
</ComboBox.ItemsSource> |
|||
</ComboBox> |
|||
<ComboBox x:Name="NavigationStrategy" SelectedIndex="0"> |
|||
<ComboBox.ItemsSource> |
|||
<generic:List x:TypeArguments="XYFocusNavigationStrategy"> |
|||
<XYFocusNavigationStrategy>Projection</XYFocusNavigationStrategy> |
|||
<XYFocusNavigationStrategy>NavigationDirectionDistance</XYFocusNavigationStrategy> |
|||
<XYFocusNavigationStrategy>RectilinearDistance</XYFocusNavigationStrategy> |
|||
</generic:List> |
|||
</ComboBox.ItemsSource> |
|||
</ComboBox> |
|||
</StackPanel> |
|||
|
|||
<Canvas HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Width="500"> |
|||
<Canvas.Styles> |
|||
<Style Selector="Button"> |
|||
<Setter Property="XYFocus.UpNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" /> |
|||
<Setter Property="XYFocus.DownNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" /> |
|||
<Setter Property="XYFocus.LeftNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" /> |
|||
<Setter Property="XYFocus.RightNavigationStrategy" Value="{Binding #NavigationStrategy.SelectedItem}" /> |
|||
</Style> |
|||
</Canvas.Styles> |
|||
|
|||
<Button Canvas.Top="0" Canvas.Left="50" Width="150" Height="150">A</Button> |
|||
<Button Canvas.Top="150" Canvas.Left="400" Width="50" Height="50">C</Button> |
|||
<Button Canvas.Top="200" Canvas.Left="0" Width="50" Height="50">B</Button> |
|||
<Button Canvas.Top="300" Canvas.Left="100" Width="50" Height="50">D</Button> |
|||
</Canvas> |
|||
</StackPanel> |
|||
</TabItem> |
|||
</TabControl> |
|||
</UserControl> |
|||
@ -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); |
|||
} |
|||
} |
|||
|
|||
@ -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; } |
|||
} |
|||
@ -1,30 +0,0 @@ |
|||
namespace Avalonia.Input.Navigation |
|||
{ |
|||
/// <summary>
|
|||
/// Provides extension methods relating to control focus.
|
|||
/// </summary>
|
|||
internal static class FocusExtensions |
|||
{ |
|||
/// <summary>
|
|||
/// Checks if the specified element can be focused.
|
|||
/// </summary>
|
|||
/// <param name="e">The element.</param>
|
|||
/// <returns>True if the element can be focused.</returns>
|
|||
public static bool CanFocus(this IInputElement e) |
|||
{ |
|||
var visible = (e as Visual)?.IsVisible ?? true; |
|||
return e.Focusable && e.IsEffectivelyEnabled && visible; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Checks if descendants of the specified element can be focused.
|
|||
/// </summary>
|
|||
/// <param name="e">The element.</param>
|
|||
/// <returns>True if descendants of the element can be focused.</returns>
|
|||
public static bool CanFocusDescendants(this IInputElement e) |
|||
{ |
|||
var visible = (e as Visual)?.IsVisible ?? true; |
|||
return e.IsEffectivelyEnabled && visible; |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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<XYFocusParams> 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<IInternalScroller>(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; |
|||
} |
|||
} |
|||
|
|||
@ -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<XYFocusParams> _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<XYFocusParams> 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<XYFocusParams> 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<XYFocusParams> 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<XYFocusParams> 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; |
|||
} |
|||
} |
|||
@ -0,0 +1,108 @@ |
|||
using Avalonia.Input; |
|||
|
|||
namespace Avalonia.Input; |
|||
|
|||
public partial class XYFocus |
|||
{ |
|||
public static readonly AttachedProperty<InputElement> DownProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, InputElement>("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<InputElement> LeftProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, InputElement>("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<InputElement> RightProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, InputElement>("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<InputElement> UpProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, InputElement>("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<XYFocusNavigationStrategy> DownNavigationStrategyProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, XYFocusNavigationStrategy>( |
|||
"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<XYFocusNavigationStrategy> UpNavigationStrategyProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, XYFocusNavigationStrategy>( |
|||
"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<XYFocusNavigationStrategy> LeftNavigationStrategyProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, XYFocusNavigationStrategy>( |
|||
"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<XYFocusNavigationStrategy> RightNavigationStrategyProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, XYFocusNavigationStrategy>( |
|||
"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<XYFocusNavigationModes> NavigationModesProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, XYFocusNavigationModes>( |
|||
"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<bool> IsFocusEngagementEnabledProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, InputElement, bool>("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<bool> IsFocusEngagedProperty = |
|||
AvaloniaProperty.RegisterAttached<XYFocus, Visual, bool>("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<Visual>((s, args) => |
|||
{ |
|||
// if ()
|
|||
}); |
|||
} |
|||
} |
|||
@ -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<Vector> 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); |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
using System; |
|||
|
|||
namespace Avalonia.Input; |
|||
|
|||
/// <summary>
|
|||
/// Specifies the 2D directional navigation behavior when using different key devices.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// See <see cref="KeyDeviceType"/>.
|
|||
/// </remarks>
|
|||
[Flags] |
|||
public enum XYFocusNavigationModes |
|||
{ |
|||
/// <summary>
|
|||
/// Any key device XY navigation is disabled.
|
|||
/// </summary>
|
|||
Disabled = 0, |
|||
|
|||
/// <summary>
|
|||
/// Keyboard arrow keys can be used for 2D directional navigation.
|
|||
/// </summary>
|
|||
Keyboard = 1, |
|||
|
|||
/// <summary>
|
|||
/// Gamepad controller DPad keys can be used for 2D directional navigation.
|
|||
/// </summary>
|
|||
Gamepad = 2, |
|||
|
|||
/// <summary>
|
|||
/// Remote controller DPad keys can be used for 2D directional navigation.
|
|||
/// </summary>
|
|||
Remote = 4, |
|||
|
|||
/// <summary>
|
|||
/// All key device XY navigation is disabled.
|
|||
/// </summary>
|
|||
Enabled = Gamepad | Remote | Keyboard |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
namespace Avalonia.Input; |
|||
|
|||
/// <summary>
|
|||
/// Specifies the disambiguation strategy used for navigating between multiple candidate targets using
|
|||
/// <see cref="XYFocus.DownNavigationStrategyProperty"/>, <see cref="XYFocus.LeftNavigationStrategyProperty"/>,
|
|||
/// <see cref="XYFocus.RightNavigationStrategyProperty"/>, and <see cref="XYFocus.UpNavigationStrategyProperty"/>.
|
|||
/// </summary>
|
|||
public enum XYFocusNavigationStrategy |
|||
{ |
|||
/// <summary>
|
|||
/// Indicates that navigation strategy is inherited from the element's ancestors. If all ancestors have a value of Auto, the fallback strategy is Projection.
|
|||
/// </summary>
|
|||
Auto, |
|||
|
|||
/// <summary>
|
|||
/// Indicates that focus moves to the first element encountered when projecting the edge of the currently focused element in the direction of navigation.
|
|||
/// </summary>
|
|||
Projection = 1, |
|||
|
|||
/// <summary>
|
|||
/// Indicates that focus moves to the element closest to the axis of the navigation direction.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// 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.
|
|||
/// </remarks>
|
|||
NavigationDirectionDistance = 2, |
|||
|
|||
/// <summary>
|
|||
/// Indicates that focus moves to the closest element based on the shortest 2D distance (Manhattan metric).
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// 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
|
|||
/// </remarks>
|
|||
RectilinearDistance = 3 |
|||
} |
|||
@ -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; } |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue