From 22eb8e6ec37da34756e1367272745d823e3dcd11 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Sun, 24 Aug 2025 08:19:21 +0000 Subject: [PATCH] Focus Traversal Api (#18647) * add focus traversal * add control focus search overrides * fix whitespace * rebased and updated api * update tests, make focusmanager constructor public * fix tests * fix whitespace issue * addressed comment * add more tests * remove redundant private focus state. fix tests * Update FocusManager.cs * fix constructors --------- Co-authored-by: Julien Lebosquain --- .../Input/FindNextElementOptions.cs | 17 + src/Avalonia.Base/Input/FocusHelpers.cs | 95 ++ src/Avalonia.Base/Input/FocusManager.cs | 899 +++++++++++++++++- src/Avalonia.Base/Input/InputElement.cs | 172 +++- .../Input/Navigation/TabNavigation.cs | 17 +- .../Input/Navigation/XYFocus.FindElements.cs | 2 +- .../Input/Navigation/XYFocus.Impl.cs | 16 +- src/Avalonia.Controls/Application.cs | 2 - src/Avalonia.Controls/TopLevel.cs | 3 +- .../Input/InputElement_Focus.cs | 251 +++++ .../Input/PointerOverTests.cs | 1 - .../AutoCompleteBoxTests.cs | 1 - .../ButtonTests.cs | 3 +- .../ContextMenuTests.cs | 1 - .../FlyoutTests.cs | 1 - .../HotKeyedControlsTests.cs | 1 - .../ItemsControlTests.cs | 1 - .../ListBoxTests.cs | 2 - .../MaskedTextBoxTests.cs | 1 - .../Primitives/PopupTests.cs | 1 - .../SelectingItemsControlTests_Multiple.cs | 1 - .../TabControlTests.cs | 2 - .../TextBoxTests.cs | 1 - .../TreeViewTests.cs | 1 - tests/Avalonia.LeakTests/ControlTests.cs | 1 - .../Data/BindingTests_Delay.cs | 2 +- tests/Avalonia.UnitTests/TestRoot.cs | 3 +- tests/Avalonia.UnitTests/TestServices.cs | 10 +- .../Avalonia.UnitTests/UnitTestApplication.cs | 1 - 29 files changed, 1434 insertions(+), 75 deletions(-) create mode 100644 src/Avalonia.Base/Input/FindNextElementOptions.cs create mode 100644 src/Avalonia.Base/Input/FocusHelpers.cs diff --git a/src/Avalonia.Base/Input/FindNextElementOptions.cs b/src/Avalonia.Base/Input/FindNextElementOptions.cs new file mode 100644 index 0000000000..e6062daf9b --- /dev/null +++ b/src/Avalonia.Base/Input/FindNextElementOptions.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Avalonia.Input +{ + public sealed class FindNextElementOptions + { + public InputElement? SearchRoot { get; init; } + public Rect ExclusionRect { get; init; } + public Rect? FocusHintRectangle { get; init; } + public XYFocusNavigationStrategy? NavigationStrategyOverride { get; init; } + public bool IgnoreOcclusivity { get; init; } + } +} diff --git a/src/Avalonia.Base/Input/FocusHelpers.cs b/src/Avalonia.Base/Input/FocusHelpers.cs new file mode 100644 index 0000000000..d1b6a11b04 --- /dev/null +++ b/src/Avalonia.Base/Input/FocusHelpers.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.VisualTree; + +namespace Avalonia.Input +{ + internal static class FocusHelpers + { + public static IEnumerable GetInputElementChildren(AvaloniaObject? parent) + { + // TODO: add control overrides to return custom focus list from control + if (parent is Visual visual) + { + return visual.VisualChildren.OfType(); + } + + return Array.Empty(); + } + + public static bool CanHaveFocusableChildren(AvaloniaObject? parent) + { + if (parent == null) + return false; + + var children = GetInputElementChildren(parent); + + bool hasFocusChildren = true; + + foreach (var child in children) + { + if (IsVisible(child)) + { + if (child.Focusable) + { + hasFocusChildren = true; + } + else if (CanHaveFocusableChildren(child as AvaloniaObject)) + { + hasFocusChildren = true; + } + } + + if (hasFocusChildren) + break; + } + + return hasFocusChildren; + } + + public static IInputElement? GetFocusParent(IInputElement? inputElement) + { + if (inputElement == null) + return null; + + if (inputElement is Visual visual) + { + var rootVisual = visual.VisualRoot; + if (inputElement != rootVisual) + return visual.Parent as IInputElement; + } + + return null; + } + + public static bool IsPotentialTabStop(IInputElement? element) + { + if (element is InputElement inputElement) + return inputElement.IsTabStop; + + return false; + } + + internal static bool IsVisible(IInputElement? element) + { + if(element is Visual visual) + return visual.IsEffectivelyVisible; + + return false; + } + + internal static bool IsFocusable(IInputElement? element) + { + return element?.Focusable ?? false; + } + + internal static bool CanHaveChildren(IInputElement? element) + { + // We don't currently have a flag to indicate a visual can have children, so we just return whether the element is a visual + return element is Visual; + } + } +} diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index ea3430ea20..0faf58b069 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -1,6 +1,10 @@ using System; +using System.Diagnostics; +using System.Linq; +using Avalonia.Input.Navigation; using Avalonia.Interactivity; using Avalonia.Metadata; +using Avalonia.Reactive; using Avalonia.VisualTree; namespace Avalonia.Input @@ -34,8 +38,22 @@ namespace Avalonia.Input RoutingStrategies.Tunnel); } + public FocusManager() + { + _contentRoot = null; + } + + public FocusManager(IInputElement contentRoot) + { + _contentRoot = contentRoot; + } + private IInputElement? Current => KeyboardDevice.Instance?.FocusedElement; + private XYFocus _xyFocus = new(); + private XYFocusOptions _xYFocusOptions = new XYFocusOptions(); + private IInputElement? _contentRoot; + /// /// Gets the currently focused . /// @@ -48,7 +66,7 @@ namespace Avalonia.Input /// The method by which focus was changed. /// Any key modifiers active at the time of focus. public bool Focus( - IInputElement? control, + IInputElement? control, NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) { @@ -69,7 +87,7 @@ namespace Avalonia.Input keyboardDevice.SetFocusedElement(control, method, keyModifiers); return true; } - else if (_focusRoot?.GetValue(FocusedElementProperty) is { } restore && + else if (_focusRoot?.GetValue(FocusedElementProperty) is { } restore && restore != Current && Focus(restore)) { @@ -144,21 +162,31 @@ namespace Avalonia.Input // Element might not be a visual, and not attached to the root. // But IFocusManager is always expected to be a FocusManager. return (FocusManager?)((element as Visual)?.VisualRoot as IInputRoot)?.FocusManager - // In our unit tests some elements might not have a root. Remove when we migrate to headless tests. + // In our unit tests some elements might not have a root. Remove when we migrate to headless tests. ?? (FocusManager?)AvaloniaLocator.Current.GetService(); } - internal bool TryMoveFocus(NavigationDirection direction) + /// + /// Attempts to change focus from the element with focus to the next focusable element in the specified direction. + /// + /// The direction to traverse (in tab order). + /// true if focus moved; otherwise, false. + public bool TryMoveFocus(NavigationDirection direction) { - if (GetFocusedElement() is {} focusedElement - && KeyboardNavigationHandler.GetNext(focusedElement, direction) is {} newElement) - { - return newElement.Focus(); - } + return FindAndSetNextFocus(direction, _xYFocusOptions); + } - return false; + /// + /// Attempts to change focus from the element with focus to the next focusable element in the specified direction, using the specified navigation options. + /// + /// The direction to traverse (in tab order). + /// The options to help identify the next element to receive focus with keyboard/controller/remote navigation. + /// true if focus moved; otherwise, false. + public bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions options) + { + return FindAndSetNextFocus(direction, ValidateAndCreateFocusOptions(direction, options)); } - + /// /// Checks if the specified element can be focused. /// @@ -233,7 +261,7 @@ namespace Avalonia.Input break; } - + element = element.VisualParent; } } @@ -245,5 +273,852 @@ namespace Avalonia.Input return v.IsAttachedToVisualTree && e.IsEffectivelyVisible; return true; } + + /// + /// Retrieves the first element that can receive focus. + /// + /// The first focusable element. + public IInputElement? FindFirstFocusableElement() + { + var root = (_contentRoot as Visual)?.GetSelfAndVisualDescendants().FirstOrDefault(x => x is IInputElement) as IInputElement; + if (root == null) + return null; + return GetFirstFocusableElementFromRoot(false); + } + + /// + /// Retrieves the first element that can receive focus based on the specified scope. + /// + /// The root element from which to search. + /// The first focusable element. + public static IInputElement? FindFirstFocusableElement(IInputElement searchScope) + { + return GetFirstFocusableElement(searchScope); + } + + /// + /// Retrieves the last element that can receive focus. + /// + /// The last focusable element. + public IInputElement? FindLastFocusableElement() + { + var root = (_contentRoot as Visual)?.GetSelfAndVisualDescendants().FirstOrDefault(x => x is IInputElement) as IInputElement; + if (root == null) + return null; + return GetFirstFocusableElementFromRoot(true); + } + + /// + /// Retrieves the last element that can receive focus based on the specified scope. + /// + /// The root element from which to search. + /// The last focusable object. + public static IInputElement? FindLastFocusableElement(IInputElement searchScope) + { + return GetFocusManager(searchScope)?.GetLastFocusableElement(searchScope); + } + + /// + /// Retrieves the element that should receive focus based on the specified navigation direction. + /// + /// + /// + public IInputElement? FindNextElement(NavigationDirection direction) + { + var xyOption = new XYFocusOptions() + { + UpdateManifold = false + }; + + return FindNextFocus(direction, xyOption); + } + + /// + /// Retrieves the element that should receive focus based on the specified navigation direction (cannot be used with tab navigation). + /// + /// The direction that focus moves from element to element within the app UI. + /// The options to help identify the next element to receive focus with the provided navigation. + /// The next element to receive focus. + public IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions options) + { + return FindNextFocus(direction, ValidateAndCreateFocusOptions(direction, options)); + } + + private static XYFocusOptions ValidateAndCreateFocusOptions(NavigationDirection direction, FindNextElementOptions options) + { + if (direction is not NavigationDirection.Up + and not NavigationDirection.Down + and not NavigationDirection.Left + and not NavigationDirection.Right) + { + throw new ArgumentOutOfRangeException(nameof(direction), + $"{direction} is not supported with FindNextElementOptions. Only Up, Down, Left and right are supported"); + } + + return new XYFocusOptions + { + UpdateManifold = false, + SearchRoot = options.SearchRoot, + ExclusionRect = options.ExclusionRect, + FocusHintRectangle = options.FocusHintRectangle, + NavigationStrategyOverride = options.NavigationStrategyOverride, + IgnoreOcclusivity = options.IgnoreOcclusivity + }; + } + + internal IInputElement? FindNextFocus(NavigationDirection direction, XYFocusOptions focusOptions, bool updateManifolds = true) + { + IInputElement? nextFocusedElement = null; + + var currentlyFocusedElement = Current; + + if (direction is NavigationDirection.Previous or NavigationDirection.Next || currentlyFocusedElement == null) + { + var isReverse = direction == NavigationDirection.Previous; + nextFocusedElement = ProcessTabStopInternal(isReverse, true); + } + else + { + if (currentlyFocusedElement is InputElement inputElement && + XYFocus.GetBoundsForRanking(inputElement, focusOptions.IgnoreClipping) is { } bounds) + { + focusOptions.FocusedElementBounds = bounds; + } + + nextFocusedElement = _xyFocus.GetNextFocusableElement(direction, + currentlyFocusedElement as InputElement, + null, + updateManifolds, + focusOptions); + } + + return nextFocusedElement; + } + + internal static IInputElement? GetFirstFocusableElementInternal(IInputElement searchStart, IInputElement? focusCandidate = null) + { + IInputElement? firstFocusableFromCallback = null; + var useFirstFocusableFromCallback = false; + if (searchStart is InputElement inputElement) + { + firstFocusableFromCallback = inputElement.GetFirstFocusableElementOverride(); + + if (firstFocusableFromCallback != null) + { + useFirstFocusableFromCallback = FocusHelpers.IsFocusable(firstFocusableFromCallback) || FocusHelpers.CanHaveFocusableChildren(firstFocusableFromCallback as AvaloniaObject); + } + } + + if (useFirstFocusableFromCallback) + { + if (focusCandidate == null || (GetTabIndex(firstFocusableFromCallback) < GetTabIndex(focusCandidate))) + { + focusCandidate = firstFocusableFromCallback; + } + } + else + { + var children = FocusHelpers.GetInputElementChildren(searchStart as AvaloniaObject); + + foreach (var child in children) + { + if (FocusHelpers.IsVisible(child)) + { + var hasFocusableChildren = FocusHelpers.CanHaveFocusableChildren(child as AvaloniaObject); + if (FocusHelpers.IsPotentialTabStop(child)) + { + if (focusCandidate == null && (FocusHelpers.IsFocusable(child) || hasFocusableChildren)) + { + focusCandidate = child; + } + + if (FocusHelpers.IsFocusable(child) || hasFocusableChildren) + { + if (focusCandidate == null || GetTabIndex(child) < GetTabIndex(focusCandidate)) + { + focusCandidate = child; + } + } + } + else if (hasFocusableChildren) + { + focusCandidate = GetFirstFocusableElementInternal(child, focusCandidate); + } + } + } + } + + return focusCandidate; + } + + internal static IInputElement? GetLastFocusableElementInternal(IInputElement searchStart, IInputElement? lastFocus = null) + { + IInputElement? lastFocusableFromCallback = null; + var useLastFocusableFromCallback = false; + if (searchStart is InputElement inputElement) + { + lastFocusableFromCallback = inputElement.GetLastFocusableElementOverride(); + + if (lastFocusableFromCallback != null) + { + useLastFocusableFromCallback = FocusHelpers.IsFocusable(lastFocusableFromCallback) || FocusHelpers.CanHaveFocusableChildren(lastFocusableFromCallback as AvaloniaObject); + } + } + + if (useLastFocusableFromCallback) + { + if (lastFocus == null || (GetTabIndex(lastFocusableFromCallback) > GetTabIndex(lastFocus))) + { + lastFocus = lastFocusableFromCallback; + } + } + else + { + var children = FocusHelpers.GetInputElementChildren(searchStart as AvaloniaObject); + + foreach (var child in children) + { + if (FocusHelpers.IsVisible(child)) + { + var hasFocusableChildren = FocusHelpers.CanHaveFocusableChildren(child as AvaloniaObject); + if (FocusHelpers.IsPotentialTabStop(child)) + { + if (lastFocus == null && (FocusHelpers.IsFocusable(child) || hasFocusableChildren)) + { + lastFocus = child; + } + + if (FocusHelpers.IsFocusable(child) || hasFocusableChildren) + { + if (lastFocus == null || GetTabIndex(child) >= GetTabIndex(lastFocus)) + { + lastFocus = child; + } + } + } + else if (hasFocusableChildren) + { + lastFocus = GetLastFocusableElementInternal(child, lastFocus); + } + } + } + } + + return lastFocus; + } + + private IInputElement? ProcessTabStopInternal(bool isReverse, bool queryOnly) + { + IInputElement? newTabStop = null; + + var defaultCandidateTabStop = GetTabStopCandidateElement(isReverse, queryOnly, out var didCycleFocusAtRootVisualScope); + + var isTabStopOverriden = InputElement.ProcessTabStop(_contentRoot, + Current, + defaultCandidateTabStop, + isReverse, + didCycleFocusAtRootVisualScope, + out var newTabStopFromCallback); + + if (isTabStopOverriden) + { + newTabStop = newTabStopFromCallback; + } + + if (!isTabStopOverriden && newTabStop == null && defaultCandidateTabStop != null) + { + newTabStop = defaultCandidateTabStop; + } + + return newTabStop; + } + + private IInputElement? GetTabStopCandidateElement(bool isReverse, bool queryOnly, out bool didCycleFocusAtRootVisualScope) + { + didCycleFocusAtRootVisualScope = false; + var currentFocus = Current; + IInputElement? newTabStop = null; + var root = this._contentRoot as IInputElement; + + if (root == null) + return null; + + bool internalCycleWorkaround = false; + + if (Current != null) + { + internalCycleWorkaround = CanProcessTabStop(isReverse); + } + + if (currentFocus == null) + { + if (!isReverse) + { + newTabStop = GetFirstFocusableElement(root, null); + } + else + { + newTabStop = GetLastFocusableElement(root, null); + } + + didCycleFocusAtRootVisualScope = true; + } + else if (!isReverse) + { + newTabStop = GetNextTabStop(); + + if (newTabStop == null && (internalCycleWorkaround || queryOnly)) + { + newTabStop = GetFirstFocusableElement(root, null); + + didCycleFocusAtRootVisualScope = true; + } + } + else + { + newTabStop = GetPreviousTabStop(); + + if (newTabStop == null && (internalCycleWorkaround || queryOnly)) + { + newTabStop = GetLastFocusableElement(root, null); + didCycleFocusAtRootVisualScope = true; + } + } + + return newTabStop; + } + + private IInputElement? GetNextTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false) + { + var focused = currentTabStop ?? Current; + if (focused == null || _contentRoot == null) + { + return null; + } + + IInputElement? currentCompare = focused; + IInputElement? newTabStop = (focused as InputElement)?.GetNextTabStopOverride(); + + if (newTabStop == null && !ignoreCurrentTabStop + && (FocusHelpers.IsVisible(focused) && (FocusHelpers.CanHaveFocusableChildren(focused as AvaloniaObject) || FocusHelpers.CanHaveChildren(focused)))) + { + newTabStop = GetFirstFocusableElement(focused, newTabStop); + } + + if (newTabStop == null) + { + var currentPassed = false; + var current = focused; + var parent = FocusHelpers.GetFocusParent(focused); + var parentIsRootVisual = parent == (_contentRoot as Visual)?.VisualRoot; + + while (parent != null && !parentIsRootVisual && newTabStop == null) + { + if (IsValidTabStopSearchCandidate(current) && current is InputElement c && KeyboardNavigation.GetTabNavigation(c) == KeyboardNavigationMode.Cycle) + { + if (current == GetParentTabStopElement(focused)) + { + newTabStop = GetFirstFocusableElement(focused, null); + } + else + { + newTabStop = GetFirstFocusableElement(current, current); + } + break; + } + + if (IsValidTabStopSearchCandidate(parent) && parent is InputElement p && KeyboardNavigation.GetTabNavigation(p) == KeyboardNavigationMode.Once) + { + current = parent; + parent = FocusHelpers.GetFocusParent(focused); + if (parent == null) + break; + } + else if (!IsValidTabStopSearchCandidate(parent)) + { + var parentElement = GetParentTabStopElement(parent); + if (parentElement == null) + { + parent = GetRootOfPopupSubTree(current) as IInputElement; + + if (parent != null) + { + newTabStop = GetNextOrPreviousTabStopInternal(parent, current, newTabStop, true, ref currentPassed, ref currentCompare); + + if (newTabStop != null && !FocusHelpers.IsFocusable(newTabStop)) + { + newTabStop = GetFirstFocusableElement(newTabStop, null); + } + if (newTabStop == null) + { + newTabStop = GetFirstFocusableElement(parent, null); + } + break; + } + + parent = (_contentRoot as Visual)?.VisualRoot as IInputElement; + } + else if (parentElement is InputElement pIE && KeyboardNavigation.GetTabNavigation(pIE) == KeyboardNavigationMode.None) + { + current = pIE; + parent = FocusHelpers.GetFocusParent(current); + if (parent == null) + break; + } + else + { + parent = parentElement as IInputElement; + } + } + + newTabStop = GetNextOrPreviousTabStopInternal(parent, current, newTabStop, true, ref currentPassed, ref currentCompare); + + if (newTabStop != null && !FocusHelpers.IsFocusable(newTabStop) && FocusHelpers.CanHaveFocusableChildren(newTabStop as AvaloniaObject)) + { + newTabStop = GetFirstFocusableElement(newTabStop, null); + } + + if (newTabStop != null) + break; + + if (IsValidTabStopSearchCandidate(parent)) + { + current = parent; + } + + parent = FocusHelpers.GetFocusParent(parent); + currentPassed = false; + + parentIsRootVisual = parent == (_contentRoot as Visual)?.VisualRoot; + } + } + + return newTabStop; + } + + private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false) + { + var focused = currentTabStop ?? Current; + if (focused == null || _contentRoot == null) + { + return null; + } + IInputElement? newTabStop = (focused as InputElement)?.GetPreviousTabStopOverride(); + IInputElement? currentCompare = focused; + + if (newTabStop == null) + { + var currentPassed = false; + var current = focused; + var parent = FocusHelpers.GetFocusParent(focused); + var parentIsRootVisual = parent == (_contentRoot as Visual)?.VisualRoot; + + while (parent != null && !parentIsRootVisual && newTabStop == null) + { + if (IsValidTabStopSearchCandidate(current) && current is InputElement c && KeyboardNavigation.GetTabNavigation(c) == KeyboardNavigationMode.Cycle) + { + newTabStop = GetFirstFocusableElement(current, current); + break; + } + + if (IsValidTabStopSearchCandidate(parent) && parent is InputElement p && KeyboardNavigation.GetTabNavigation(p) == KeyboardNavigationMode.Once) + { + if (FocusHelpers.IsFocusable(parent)) + { + newTabStop = parent; + } + else + { + current = parent; + parent = FocusHelpers.GetFocusParent(focused); + if (parent == null) + break; + } + } + else if (!IsValidTabStopSearchCandidate(parent)) + { + var parentElement = GetParentTabStopElement(parent); + if (parentElement == null) + { + parent = GetRootOfPopupSubTree(current) as IInputElement; + + if (parent != null) + { + newTabStop = GetNextOrPreviousTabStopInternal(parent, current, newTabStop, false, ref currentPassed, ref currentCompare); + + if (newTabStop != null && !FocusHelpers.IsFocusable(newTabStop)) + { + newTabStop = GetLastFocusableElement(newTabStop, null); + } + if (newTabStop == null) + { + newTabStop = GetLastFocusableElement(parent, null); + } + break; + } + + parent = (_contentRoot as Visual)?.VisualRoot as IInputElement; + } + else if (parentElement is InputElement pIE && KeyboardNavigation.GetTabNavigation(pIE) == KeyboardNavigationMode.None) + { + if (FocusHelpers.IsFocusable(parent)) + { + newTabStop = parent; + } + else + { + current = parent; + parent = FocusHelpers.GetFocusParent(focused); + if (parent == null) + break; + } + } + else + { + parent = parentElement as IInputElement; + } + } + + newTabStop = GetNextOrPreviousTabStopInternal(parent, current, newTabStop, false, ref currentPassed, ref currentCompare); + + if (newTabStop == null && FocusHelpers.IsPotentialTabStop(parent) && FocusHelpers.IsFocusable(parent)) + { + if (parent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle) + { + newTabStop = GetLastFocusableElement(parent, null); + } + else + { + newTabStop = parent; + } + } + else + { + if (newTabStop != null && FocusHelpers.CanHaveFocusableChildren(newTabStop as AvaloniaObject)) + { + newTabStop = GetLastFocusableElement(newTabStop, null); + } + } + + if (newTabStop != null) + break; + + if (IsValidTabStopSearchCandidate(parent)) + { + current = parent; + } + + parent = FocusHelpers.GetFocusParent(parent); + currentPassed = false; + } + } + + return newTabStop; + } + + private IInputElement? GetNextOrPreviousTabStopInternal(IInputElement? parent, IInputElement? current, IInputElement? candidate, bool findNext, ref bool currentPassed, ref IInputElement? currentCompare) + { + var newTabStop = candidate; + IInputElement? childStop = null; + int compareIndexResult = 0; + bool compareCurrentForPreviousElement = false; + + if (IsValidTabStopSearchCandidate(current)) + { + currentCompare = current; + } + + if (parent != null) + { + bool foundCurrent = false; + foreach (var child in FocusHelpers.GetInputElementChildren(parent as AvaloniaObject)) + { + childStop = null; + compareCurrentForPreviousElement = false; + if (child == current) + { + foundCurrent = true; + currentPassed = true; + continue; + } + + if (FocusHelpers.IsVisible(child)) + { + if (child == current) + { + foundCurrent = true; + currentPassed = true; + continue; + } + + if (IsValidTabStopSearchCandidate(child)) + { + if (!FocusHelpers.IsPotentialTabStop(child)) + { + childStop = GetNextOrPreviousTabStopInternal(childStop, current, newTabStop, findNext, ref currentPassed, ref currentCompare); + compareCurrentForPreviousElement = true; + } + else + { + childStop = child; + } + } + else if (FocusHelpers.CanHaveFocusableChildren(child as AvaloniaObject)) + { + childStop = GetNextOrPreviousTabStopInternal(child, current, newTabStop, findNext, ref currentPassed, ref currentCompare); + compareCurrentForPreviousElement = true; + } + } + + if (childStop != null && (FocusHelpers.IsFocusable(childStop) || FocusHelpers.CanHaveFocusableChildren(childStop as AvaloniaObject))) + { + compareIndexResult = CompareTabIndex(childStop, currentCompare); + + if (findNext) + { + if (compareIndexResult > 0 || ((foundCurrent || currentPassed) && compareIndexResult == 0)) + { + if (newTabStop != null) + { + if (CompareTabIndex(childStop, newTabStop) < 0) + { + newTabStop = childStop; + } + } + else + { + newTabStop = childStop; + } + } + } + else + { + if (compareIndexResult < 0 || (((foundCurrent || currentPassed) || compareCurrentForPreviousElement) && compareIndexResult == 0)) + { + if (newTabStop != null) + { + if (CompareTabIndex(childStop, newTabStop) >= 0) + { + newTabStop = childStop; + } + } + else + { + newTabStop = childStop; + } + } + } + } + } + } + + return newTabStop; + } + + private static int CompareTabIndex(IInputElement? control1, IInputElement? control2) + { + return GetTabIndex(control1).CompareTo(GetTabIndex(control2)); + } + + private static int GetTabIndex(IInputElement? element) + { + if (element is InputElement inputElement) + return inputElement.TabIndex; + + return int.MaxValue; + } + + private bool CanProcessTabStop(bool isReverse) + { + bool isFocusOnFirst = false; + bool isFocusOnLast = false; + bool canProcessTab = true; + if (IsFocusedElementInPopup()) + { + return true; + } + + if (isReverse) + { + isFocusOnFirst = IsFocusOnFirstTabStop(); + } + else + { + isFocusOnLast = IsFocusOnLastTabStop(); + } + + if (isFocusOnFirst || isFocusOnLast) + { + canProcessTab = false; + } + + if (canProcessTab) + { + var edge = GetFirstFocusableElementFromRoot(!isReverse); + + if (edge != null) + { + var edgeParent = GetParentTabStopElement(edge); + if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(Current)) + { + canProcessTab = false; + } + } + else + { + canProcessTab = false; + } + } + else + { + if (isFocusOnLast || isFocusOnFirst) + { + if (Current is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle) + { + canProcessTab = true; + } + else + { + var focusedParent = GetParentTabStopElement(Current); + while (focusedParent != null) + { + if (focusedParent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle) + { + canProcessTab = true; + break; + } + + focusedParent = GetParentTabStopElement(focusedParent as IInputElement); + } + } + } + } + + return canProcessTab; + } + + private AvaloniaObject? GetParentTabStopElement(IInputElement? current) + { + if (current != null) + { + var parent = FocusHelpers.GetFocusParent(current); + + while (parent != null) + { + if (IsValidTabStopSearchCandidate(parent) && parent is InputElement element) + { + return element; + } + + parent = FocusHelpers.GetFocusParent(parent); + } + } + + return null; + } + + private bool IsValidTabStopSearchCandidate(IInputElement? element) + { + var isValid = FocusHelpers.IsPotentialTabStop(element); + + if (!isValid) + { + isValid = (element as InputElement)?.IsSet(KeyboardNavigation.TabNavigationProperty) ?? false; + } + + return isValid; + } + + private IInputElement? GetFirstFocusableElementFromRoot(bool isReverse) + { + var root = (_contentRoot as Visual)?.VisualRoot as IInputElement; + + if (root != null) + return !isReverse ? GetFirstFocusableElement(root, null) : GetLastFocusableElement(root, null); + + return null; + } + + private bool IsFocusOnLastTabStop() + { + if (Current == null || _contentRoot is not Visual visual) + return false; + var root = visual.VisualRoot as IInputElement; + + Debug.Assert(root != null); + + var lastFocus = GetLastFocusableElement(root, null); + + return lastFocus == Current; + } + + private bool IsFocusOnFirstTabStop() + { + if (Current == null || _contentRoot is not Visual visual) + return false; + var root = visual.VisualRoot as IInputElement; + + Debug.Assert(root != null); + + var firstFocus = GetFirstFocusableElement(root, null); + + return firstFocus == Current; + } + + private static IInputElement? GetFirstFocusableElement(IInputElement searchStart, IInputElement? firstFocus = null) + { + firstFocus = GetFirstFocusableElementInternal(searchStart, firstFocus); + + if (firstFocus != null && !firstFocus.Focusable && FocusHelpers.CanHaveFocusableChildren(firstFocus as AvaloniaObject)) + { + firstFocus = GetFirstFocusableElement(firstFocus, null); + } + + return firstFocus; + } + + private IInputElement? GetLastFocusableElement(IInputElement searchStart, IInputElement? lastFocus = null) + { + lastFocus = GetLastFocusableElementInternal(searchStart, lastFocus); + + if (lastFocus != null && !lastFocus.Focusable && FocusHelpers.CanHaveFocusableChildren(lastFocus as AvaloniaObject)) + { + lastFocus = GetLastFocusableElement(lastFocus, null); + } + + return lastFocus; + } + + private bool IsFocusedElementInPopup() => Current != null && GetRootOfPopupSubTree(Current) != null; + + private Visual? GetRootOfPopupSubTree(IInputElement? current) + { + //TODO Popup api + return null; + } + + private bool FindAndSetNextFocus(NavigationDirection direction, XYFocusOptions xYFocusOptions) + { + var focusChanged = false; + if (xYFocusOptions.UpdateManifoldsFromFocusHintRect && xYFocusOptions.FocusHintRectangle != null) + { + _xyFocus.SetManifoldsFromBounds(xYFocusOptions.FocusHintRectangle ?? default); + } + + if (FindNextFocus(direction, xYFocusOptions, false) is { } nextFocusedElement) + { + focusChanged = nextFocusedElement.Focus(); + + if (focusChanged && xYFocusOptions.UpdateManifold && nextFocusedElement is InputElement inputElement) + { + var bounds = xYFocusOptions.FocusHintRectangle ?? xYFocusOptions.FocusedElementBounds ?? default; + + _xyFocus.UpdateManifolds(direction, bounds, inputElement, xYFocusOptions.IgnoreClipping); + } + } + + return focusChanged; + + } } } diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index f00166d6b0..565e6afc1a 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -51,7 +51,7 @@ namespace Avalonia.Input AvaloniaProperty.RegisterDirect( nameof(IsKeyboardFocusWithin), o => o.IsKeyboardFocusWithin); - + /// /// Defines the property. /// @@ -129,7 +129,7 @@ namespace Avalonia.Input RoutedEvent.Register( nameof(TextInput), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); - + /// /// Defines the event. /// @@ -177,13 +177,13 @@ namespace Avalonia.Input RoutedEvent.Register( nameof(PointerReleased), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); - + /// /// Defines the routed event. /// public static readonly RoutedEvent PointerCaptureLostEvent = RoutedEvent.Register( - nameof(PointerCaptureLost), + nameof(PointerCaptureLost), RoutingStrategies.Direct); /// @@ -253,8 +253,8 @@ namespace Avalonia.Input PointerPressedEvent.AddClassHandler((x, e) => x.OnGesturePointerPressed(e), handledEventsToo: true); PointerReleasedEvent.AddClassHandler((x, e) => x.OnGesturePointerReleased(e), handledEventsToo: true); PointerCaptureLostEvent.AddClassHandler((x, e) => x.OnGesturePointerCaptureLost(e), handledEventsToo: true); - - + + // Access Key Handling AccessKeyHandler.AccessKeyEvent.AddClassHandler((x, e) => x.OnAccessKey(e)); } @@ -469,7 +469,7 @@ namespace Avalonia.Input public bool IsKeyboardFocusWithin { get => _isKeyboardFocusWithin; - internal set => SetAndRaise(IsKeyboardFocusWithinProperty, ref _isKeyboardFocusWithin, value); + internal set => SetAndRaise(IsKeyboardFocusWithinProperty, ref _isKeyboardFocusWithin, value); } /// @@ -517,7 +517,7 @@ namespace Avalonia.Input SetAndRaise(IsEffectivelyEnabledProperty, ref _isEffectivelyEnabled, value); PseudoClasses.Set(":disabled", !value); - if (!IsEffectivelyEnabled && FocusManager.GetFocusManager(this) is {} focusManager + if (!IsEffectivelyEnabled && FocusManager.GetFocusManager(this) is { } focusManager && Equals(focusManager.GetFocusedElement(), this)) { focusManager.ClearFocus(); @@ -554,7 +554,7 @@ namespace Avalonia.Input /// public bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) { - return FocusManager.GetFocusManager(this)?.Focus(this, method, keyModifiers) ?? false; + return FocusManager.GetFocusManager(this)?.Focus(this, method, keyModifiers) ?? false; } /// @@ -564,7 +564,7 @@ namespace Avalonia.Input if (IsFocused) { - FocusManager.GetFocusManager(this)?.ClearFocusOnElementRemoved(this, e.Parent); + FocusManager.GetFocusManager(e.Root as IInputElement)?.ClearFocusOnElementRemoved(this, e.Parent); } IsKeyboardFocusWithin = false; @@ -630,7 +630,7 @@ namespace Avalonia.Input /// /// Data about the event. protected virtual void OnLostFocus(RoutedEventArgs e) - { + { } /// @@ -746,6 +746,30 @@ namespace Avalonia.Input } } + /// + /// Called when FocusManager get the next TabStop to interact with the focused control. + /// + /// Next tab stop. + protected internal virtual InputElement? GetNextTabStopOverride() => null; + + /// + /// Called when FocusManager get the previous TabStop to interact with the focused control. + /// + /// Previous tab stop. + protected internal virtual InputElement? GetPreviousTabStopOverride() => null; + + /// + /// Called when FocusManager is looking for the first focusable element from the specified search scope. + /// + /// First focusable element if available. + protected internal virtual InputElement? GetFirstFocusableElementOverride() => null; + + /// + /// Called when FocusManager is looking for the last focusable element from the specified search scope. + /// + /// Last focusable element if available/>. + protected internal virtual InputElement? GetLastFocusableElementOverride() => null; + /// /// Invoked when an unhandled reaches an element in its /// route that is derived from this class. Implement this method to add class handling @@ -757,6 +781,127 @@ namespace Avalonia.Input } + internal static bool ProcessTabStop(IInputElement? contentRoot, + IInputElement? focusedElement, + IInputElement? candidateTabStopElement, + bool isReverse, + bool didCycleFocusAtRootVisual, + out IInputElement? newTabStop) + { + newTabStop = null; + bool isTabStopOverridden = false; + bool isCandidateTabStopOverridden = false; + IInputElement? currentFocusedTarget = focusedElement; + InputElement? focusedTargetAsIE = focusedElement as InputElement; + InputElement? candidateTargetAsIE = candidateTabStopElement as InputElement; + InputElement? newCandidateTargetAsIE = null; + IInputElement? newCandidateTabStop = null; + IInputElement? spNewTabStop = null; + + if (focusedTargetAsIE != null) + { + isTabStopOverridden = focusedTargetAsIE.ProcessTabStopInternal(candidateTabStopElement, isReverse, didCycleFocusAtRootVisual, out spNewTabStop); + } + + if (!isTabStopOverridden && candidateTargetAsIE != null) + { + isTabStopOverridden = candidateTargetAsIE.ProcessCandidateTabStopInternal(focusedElement, null, isReverse, out spNewTabStop); + } + else if (isTabStopOverridden && newTabStop != null) + { + newCandidateTargetAsIE = spNewTabStop as InputElement; + if (newCandidateTargetAsIE != null) + { + isCandidateTabStopOverridden = newCandidateTargetAsIE.ProcessCandidateTabStopInternal(focusedElement, spNewTabStop, isReverse, out newCandidateTabStop); + } + } + + if (isCandidateTabStopOverridden) + { + if (newCandidateTabStop != null) + { + newTabStop = newCandidateTabStop; + } + + isTabStopOverridden = true; + } + else if (isTabStopOverridden) + { + if (newTabStop != null) + { + newTabStop = spNewTabStop; + } + + isTabStopOverridden = true; + } + + return isTabStopOverridden; + } + + private bool ProcessTabStopInternal(IInputElement? candidateTabStopElement, + bool isReverse, + bool didCycleFocusAtRootVisual, + out IInputElement? newTabStop) + { + InputElement? current = this; + + newTabStop = null; + var candidateTabStopOverridden = false; + + while (current != null && !candidateTabStopOverridden) + { + candidateTabStopOverridden = current.ProcessTabStopOverride(this, + candidateTabStopElement, + isReverse, + didCycleFocusAtRootVisual, + ref newTabStop); + + current = (current as Visual)?.Parent as InputElement; + } + return candidateTabStopOverridden; + } + + private bool ProcessCandidateTabStopInternal(IInputElement? currentTabStop, + IInputElement? overridenCandidateTabStopElement, + bool isReverse, + out IInputElement? newTabStop) + { + InputElement? current = this; + + newTabStop = null; + var candidateTabStopOverridden = false; + + while (current != null && !candidateTabStopOverridden) + { + candidateTabStopOverridden = current.ProcessCandidateTabStopOverride(currentTabStop, + this, + overridenCandidateTabStopElement, + isReverse, + ref newTabStop); + + current = (current as Visual)?.Parent as InputElement; + } + return candidateTabStopOverridden; + } + + protected internal virtual bool ProcessTabStopOverride(IInputElement? focusedElement, + IInputElement? candidateTabStopElement, + bool isReverse, + bool didCycleFocusAtRootVisual, + ref IInputElement? newTabStop) + { + return false; + } + + protected internal virtual bool ProcessCandidateTabStopOverride(IInputElement? focusedElement, + IInputElement? candidateTabStopElement, + IInputElement? overridenCandidateTabStopElement, + bool isReverse, + ref IInputElement? newTabStop) + { + return false; + } + /// /// Invoked when an unhandled reaches an element in its /// route that is derived from this class. Implement this method to add class handling @@ -765,6 +910,7 @@ namespace Avalonia.Input /// Data about the event. protected virtual void OnPointerWheelChanged(PointerWheelEventArgs e) { + } /// @@ -884,7 +1030,7 @@ namespace Avalonia.Input // PERF-SENSITIVE: This is called on entire hierarchy and using foreach or LINQ // will cause extra allocations and overhead. - + var children = VisualChildren; // ReSharper disable once ForCanBeConvertedToForeach @@ -903,7 +1049,7 @@ namespace Avalonia.Input PseudoClasses.Set(":focus", isFocused.Value); PseudoClasses.Set(":focus-visible", _isFocusVisible); } - + if (isPointerOver.HasValue) { PseudoClasses.Set(":pointerover", isPointerOver.Value); diff --git a/src/Avalonia.Base/Input/Navigation/TabNavigation.cs b/src/Avalonia.Base/Input/Navigation/TabNavigation.cs index 3004a70bdc..a2a22aa060 100644 --- a/src/Avalonia.Base/Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Base/Input/Navigation/TabNavigation.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Avalonia.VisualTree; namespace Avalonia.Input.Navigation @@ -225,8 +226,8 @@ namespace Avalonia.Input.Navigation { if (e is Visual elementAsVisual) { - var children = elementAsVisual.VisualChildren; - var count = children.Count; + var children = FocusHelpers.GetInputElementChildren(elementAsVisual).ToArray(); + var count = children.Length; for (int i = 0; i < count; i++) { @@ -261,8 +262,8 @@ namespace Avalonia.Input.Navigation { if (e is Visual elementAsVisual) { - var children = elementAsVisual.VisualChildren; - var count = children.Count; + var children = FocusHelpers.GetInputElementChildren(elementAsVisual).ToArray(); + var count = children.Length; for (int i = count - 1; i >= 0; i--) { @@ -284,7 +285,7 @@ namespace Avalonia.Input.Navigation return null; } - private static IInputElement? GetFirstTabInGroup(IInputElement container) + internal static IInputElement? GetFirstTabInGroup(IInputElement container) { IInputElement? firstTabElement = null; int minIndexFirstTab = int.MinValue; @@ -372,7 +373,7 @@ namespace Avalonia.Input.Navigation { if (GetParent(e) is Visual parentAsVisual && e is Visual elementAsVisual) { - var children = parentAsVisual.VisualChildren; + var children = FocusHelpers.GetInputElementChildren(parentAsVisual).ToList(); var count = children.Count; var i = 0; @@ -576,7 +577,7 @@ namespace Avalonia.Input.Navigation { if (GetParent(e) is Visual parentAsVisual && e is Visual elementAsVisual) { - var children = parentAsVisual.VisualChildren; + var children = FocusHelpers.GetInputElementChildren(parentAsVisual).ToList(); var count = children.Count; IInputElement? prev = null; @@ -646,7 +647,7 @@ namespace Avalonia.Input.Navigation private static bool IsFocusScope(IInputElement e) => FocusManager.GetIsFocusScope(e) || GetParent(e) == null; private static bool IsGroup(IInputElement e) => GetKeyNavigationMode(e) != KeyboardNavigationMode.Continue; - private static bool IsTabStop(IInputElement e) + internal static bool IsTabStop(IInputElement e) { if (e is InputElement ie) return ie.Focusable && KeyboardNavigation.GetIsTabStop(ie) && ie.IsVisible && ie.IsEffectivelyEnabled; diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs index 9ee77f26b1..5cc0d1a564 100644 --- a/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.FindElements.cs @@ -118,7 +118,7 @@ public partial class XYFocus return !visibleBounds.Intersects(elementBounds); } - private static Rect? GetBoundsForRanking(InputElement element, bool ignoreClipping) + internal static Rect? GetBoundsForRanking(InputElement element, bool ignoreClipping) { if (element.GetTransformedBounds() is { } bounds) { diff --git a/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs b/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs index b2a79aa3b9..20267f4c0c 100644 --- a/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs +++ b/src/Avalonia.Base/Input/Navigation/XYFocus.Impl.cs @@ -23,21 +23,21 @@ public partial class XYFocus } - private XYFocusAlgorithms.XYFocusManifolds mManifolds = new(); + private XYFocusAlgorithms.XYFocusManifolds _manifolds = new(); private PooledList _pooledCandidates = new(); private static readonly XYFocus _instance = new(); internal XYFocusAlgorithms.XYFocusManifolds ResetManifolds() { - mManifolds.Reset(); - return mManifolds; + _manifolds.Reset(); + return _manifolds; } internal void SetManifoldsFromBounds(Rect bounds) { - mManifolds.VManifold = (bounds.Left, bounds.Right); - mManifolds.HManifold = (bounds.Top, bounds.Bottom); + _manifolds.VManifold = (bounds.Left, bounds.Right); + _manifolds.HManifold = (bounds.Top, bounds.Bottom); } internal void UpdateManifolds( @@ -47,7 +47,7 @@ public partial class XYFocus bool ignoreClipping) { var candidateBounds = GetBoundsForRanking(candidate, ignoreClipping)!.Value; - XYFocusAlgorithms.UpdateManifolds(direction, elementBounds, candidateBounds, mManifolds); + XYFocusAlgorithms.UpdateManifolds(direction, elementBounds, candidateBounds, _manifolds); } internal static InputElement? TryDirectionalFocus( @@ -261,7 +261,7 @@ public partial class XYFocus if (updateManifolds) { // Update the manifolds with the newly selected focus - XYFocusAlgorithms.UpdateManifolds(direction, bounds, param.Bounds, mManifolds); + XYFocusAlgorithms.UpdateManifolds(direction, bounds, param.Bounds, _manifolds); } break; @@ -346,7 +346,7 @@ public partial class XYFocus XYFocusAlgorithms.ShouldCandidateBeConsideredForRanking(bounds, candidateBounds, maxRootBoundsDistance, direction, exclusionBounds, ignoreCone)) { - candidate.Score = XYFocusAlgorithms.GetScoreProjection(direction, bounds, candidateBounds, mManifolds, maxRootBoundsDistance); + candidate.Score = XYFocusAlgorithms.GetScoreProjection(direction, bounds, candidateBounds, _manifolds, maxRootBoundsDistance); } else if (mode == XYFocusNavigationStrategy.NavigationDirectionDistance || mode == XYFocusNavigationStrategy.RectilinearDistance) diff --git a/src/Avalonia.Controls/Application.cs b/src/Avalonia.Controls/Application.cs index 1af3f20c2e..0bad04bb23 100644 --- a/src/Avalonia.Controls/Application.cs +++ b/src/Avalonia.Controls/Application.cs @@ -265,7 +265,6 @@ namespace Avalonia public virtual void RegisterServices() { AvaloniaSynchronizationContext.InstallIfNeeded(); - var focusManager = new FocusManager(); InputManager = new InputManager(); if (PlatformSettings is { } settings) @@ -279,7 +278,6 @@ namespace Avalonia .Bind().ToConstant(this) .Bind().ToConstant(this) .Bind().ToConstant(this) - .Bind().ToConstant(focusManager) .Bind().ToConstant(InputManager) .Bind< IToolTipService>().ToConstant(new ToolTipService(InputManager)) .Bind().ToTransient() diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 7ef1907117..d85ec55960 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -590,7 +590,7 @@ namespace Avalonia.Controls public IClipboard? Clipboard => PlatformImpl?.TryGetFeature(); /// - public IFocusManager? FocusManager => AvaloniaLocator.Current.GetService(); + public IFocusManager? FocusManager => _focusManager ??= new FocusManager(this); /// public IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService(); @@ -665,6 +665,7 @@ namespace Avalonia.Controls } private IDisposable? _insetsPaddings; + private FocusManager? _focusManager; private void InvalidateChildInsetsPadding() { diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index 69cc3dbc22..9a08be7d37 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -702,6 +702,257 @@ namespace Avalonia.Base.UnitTests.Input Assert.Same(innerButton, focusManager.GetFocusedElement()); } + [Fact] + public void Can_Get_First_Focusable_Element() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var target4 = new Button { Focusable = true, Content = "4" }; + var container = new StackPanel + { + Children = + { + target1, + target2, + target3, + target4 + } + }; + var root = new TestRoot + { + Child = container + }; + + var firstFocusable = FocusManager.FindFirstFocusableElement(container); + + Assert.Equal(target1, firstFocusable); + + firstFocusable = (root.FocusManager as FocusManager)?.FindFirstFocusableElement(); + + Assert.Equal(target1, firstFocusable); + } + } + + [Fact] + public void Can_Get_Last_Focusable_Element() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var target4 = new Button { Focusable = true, Content = "4" }; + var container = new StackPanel + { + Children = + { + target1, + target2, + target3, + target4 + } + }; + var root = new TestRoot + { + Child = container + }; + + var lastFocusable = FocusManager.FindLastFocusableElement(container); + + Assert.Equal(target4, lastFocusable); + + lastFocusable = (root.FocusManager as FocusManager)?.FindLastFocusableElement(); + + Assert.Equal(target4, lastFocusable); + } + } + + [Fact] + public void Can_Get_Next_Element() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var target4 = new Button { Focusable = true, Content = "4" }; + var container = new StackPanel + { + Children = + { + target1, + target2, + target3, + target4 + } + }; + var root = new TestRoot + { + Child = container + }; + + var focusManager = FocusManager.GetFocusManager(container); + target1.Focus(); + + var next = focusManager.FindNextElement(NavigationDirection.Next); + + Assert.Equal(next, target2); + } + } + + [Fact] + public void Can_Get_Next_Element_With_Options() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var target4 = new Button { Focusable = true, Content = "4" }; + var target5 = new Button { Focusable = true, Content = "5" }; + var seachStack = new StackPanel() + { + Children = + { + target3, + target4 + } + }; + var container = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + Children = + { + target1, + target2, + seachStack, + target5 + } + }; + var root = new TestRoot + { + Child = container + }; + + root.InvalidateMeasure(); + root.ExecuteInitialLayoutPass(); + + var focusManager = FocusManager.GetFocusManager(container); + target1.Focus(); + + var options = new FindNextElementOptions() + { + SearchRoot = seachStack + }; + + // Search root is right of the current focus, should return the first focusable element in the search root + var next = focusManager.FindNextElement(NavigationDirection.Right, options); + + Assert.Equal(next, target3); + + target5.Focus(); + + // Search root is right of the current focus, should return the first focusable element in the search root + next = focusManager.FindNextElement(NavigationDirection.Left, options); + + Assert.Equal(next, target3); + + // Search root isn't to the right of the current focus, should return null + next = focusManager.FindNextElement(NavigationDirection.Right, options); + + Assert.Null(next); + } + } + + [Fact] + public void Focus_Should_Move_According_To_Direction() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var target4 = new Button { Focusable = true, Content = "4" }; + var container = new StackPanel + { + Children = + { + target1, + target2, + target3, + target4 + } + }; + var root = new TestRoot + { + Child = container + }; + + var focusManager = FocusManager.GetFocusManager(container); + + var hasMoved = focusManager.TryMoveFocus(NavigationDirection.Next); + + Assert.True(target1.IsFocused); + Assert.True(hasMoved); + + hasMoved = focusManager.TryMoveFocus(NavigationDirection.Previous); + + Assert.True(target4.IsFocused); + Assert.True(hasMoved); + } + } + + [Fact] + public void Focus_Should_Move_According_To_XY_Direction() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var target4 = new Button { Focusable = true, Content = "4" }; + var center = new Button + { + [XYFocus.LeftProperty] = target1, + [XYFocus.RightProperty] = target2, + [XYFocus.UpProperty] = target3, + [XYFocus.DownProperty] = target4, + }; + var container = new Canvas + { + Children = + { + target1, + target2, + target3, + target4, + center + } + }; + + var root = new TestRoot + { + Child = container + }; + + var focusManager = FocusManager.GetFocusManager(container); + + center.Focus(); + + var options = new FindNextElementOptions() + { + SearchRoot = container + }; + + var hasMoved = focusManager.TryMoveFocus(NavigationDirection.Up, options); + Assert.True(target3.IsFocused); + Assert.True(hasMoved); + } + } + private class TestFocusScope : Panel, IFocusScope { } diff --git a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs index 0c1e790bac..e2789f9d2e 100644 --- a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs @@ -24,7 +24,6 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices( inputManager: new InputManager(), - focusManager: new FocusManager(), renderInterface: new HeadlessPlatformRenderInterface())); var renderer = new Mock(); diff --git a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs index 870c1363ca..271d38f6a7 100644 --- a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs @@ -1261,7 +1261,6 @@ namespace Avalonia.Controls.UnitTests } private static TestServices FocusServices => TestServices.MockThreadingInterface.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), diff --git a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs index e0c84fca0d..45bd69c18a 100644 --- a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs @@ -334,13 +334,12 @@ namespace Avalonia.Controls.UnitTests }) }, }; - kd.SetFocusedElement(target, NavigationMethod.Unspecified, KeyModifiers.None); - root.ApplyTemplate(); root.Presenter.UpdateChild(); target.ApplyTemplate(); target.Presenter.UpdateChild(); + kd.SetFocusedElement(target, NavigationMethod.Unspecified, KeyModifiers.None); Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); diff --git a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs index e655c32f8e..b487818be6 100644 --- a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs @@ -672,7 +672,6 @@ namespace Avalonia.Controls.UnitTests windowImpl.Setup(x => x.TryGetFeature(It.Is(t => t == typeof(IScreenImpl)))).Returns(screenImpl.Object); var services = TestServices.StyledWindow.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), inputManager: new InputManager(), windowImpl: windowImpl.Object, diff --git a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs index c15c8ced20..f8061a9409 100644 --- a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs +++ b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs @@ -647,7 +647,6 @@ namespace Avalonia.Controls.UnitTests return UnitTestApplication.Start(TestServices.StyledWindow.With(windowingPlatform: new MockWindowingPlatform(null, x => UseOverlayPopups ? null : MockWindowingPlatform.CreatePopupMock(x).Object), - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice())); } diff --git a/tests/Avalonia.Controls.UnitTests/HotKeyedControlsTests.cs b/tests/Avalonia.Controls.UnitTests/HotKeyedControlsTests.cs index 9494fd0aab..378a28ba86 100644 --- a/tests/Avalonia.Controls.UnitTests/HotKeyedControlsTests.cs +++ b/tests/Avalonia.Controls.UnitTests/HotKeyedControlsTests.cs @@ -90,7 +90,6 @@ namespace Avalonia.Controls.UnitTests windowingPlatform: new MockWindowingPlatform( null, window => MockWindowingPlatform.CreatePopupMock(window).Object), - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice())); } diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 423c141d07..2ebac5b3a1 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -1233,7 +1233,6 @@ namespace Avalonia.Controls.UnitTests { return UnitTestApplication.Start( TestServices.MockThreadingInterface.With( - focusManager: new FocusManager(), fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index 93279f57f5..ceef06a213 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -1102,7 +1102,6 @@ namespace Avalonia.Controls.UnitTests public void Tab_Navigation_Should_Move_To_First_Item_When_No_Anchor_Element_Selected() { var services = TestServices.StyledWindow.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice()); using var app = UnitTestApplication.Start(services); @@ -1146,7 +1145,6 @@ namespace Avalonia.Controls.UnitTests public void Tab_Navigation_Should_Move_To_Anchor_Element() { var services = TestServices.StyledWindow.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice()); using var app = UnitTestApplication.Start(services); diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs index 25b0780f8b..cfb8838123 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs @@ -921,7 +921,6 @@ namespace Avalonia.Controls.UnitTests } private static TestServices FocusServices => TestServices.MockThreadingInterface.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index 1a9ce7c655..fd327395aa 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -1347,7 +1347,6 @@ namespace Avalonia.Controls.UnitTests.Primitives { return UnitTestApplication.Start(TestServices.StyledWindow.With( windowingPlatform: CreateMockWindowingPlatform(), - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler())); } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 5e8c9daeb2..c208c70b1d 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -1346,7 +1346,6 @@ namespace Avalonia.Controls.UnitTests.Primitives { return UnitTestApplication.Start( TestServices.MockThreadingInterface.With( - focusManager: new FocusManager(), fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index f600d3aaba..b2ed3dcbb6 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -509,7 +509,6 @@ namespace Avalonia.Controls.UnitTests public void Tab_Navigation_Should_Move_To_First_TabItem_When_No_Anchor_Element_Selected() { var services = TestServices.StyledWindow.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice()); using var app = UnitTestApplication.Start(services); @@ -558,7 +557,6 @@ namespace Avalonia.Controls.UnitTests public void Tab_Navigation_Should_Move_To_Anchor_TabItem() { var services = TestServices.StyledWindow.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice()); using var app = UnitTestApplication.Start(services); diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 13af9533d6..cc1edc67ab 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -2130,7 +2130,6 @@ namespace Avalonia.Controls.UnitTests } private static TestServices FocusServices => TestServices.MockThreadingInterface.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index e7517f147d..0033f836f1 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -1836,7 +1836,6 @@ namespace Avalonia.Controls.UnitTests { return UnitTestApplication.Start( TestServices.MockThreadingInterface.With( - focusManager: new FocusManager(), fontManagerImpl: new HeadlessFontManagerStub(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index c83c92b1e2..c720e4d2f8 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -1027,7 +1027,6 @@ namespace Avalonia.LeakTests { Disposable.Create(Cleanup), UnitTestApplication.Start(TestServices.StyledWindow.With( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), inputManager: new InputManager())) }; diff --git a/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs index ec9149a86e..d225ff2b73 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs @@ -25,7 +25,7 @@ public class BindingTests_Delay : ScopedTestBase, IDisposable public BindingTests_Delay() { _dispatcher = new ManualTimerDispatcher(); - _app = UnitTestApplication.Start(new(dispatcherImpl: _dispatcher, focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice())); + _app = UnitTestApplication.Start(new(dispatcherImpl: _dispatcher, keyboardDevice: () => new KeyboardDevice())); _source = new BindingTests.Source { Foo = InitialFooValue }; _target = new TextBox { DataContext = _source }; diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index 788f2b3dae..49baeb87c7 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -15,6 +15,7 @@ namespace Avalonia.UnitTests public class TestRoot : Decorator, IFocusScope, ILayoutRoot, IInputRoot, IRenderRoot, IStyleHost, ILogicalRoot { private readonly NameScope _nameScope = new NameScope(); + private FocusManager? _focusManager; public TestRoot() { @@ -63,7 +64,7 @@ namespace Avalonia.UnitTests IHitTester IRenderRoot.HitTester => HitTester; public IKeyboardNavigationHandler KeyboardNavigationHandler => null; - public IFocusManager FocusManager => AvaloniaLocator.Current.GetService(); + public IFocusManager FocusManager => _focusManager ??= new FocusManager(this); public IPlatformSettings PlatformSettings => AvaloniaLocator.Current.GetService(); public IInputElement PointerOverElement { get; set; } diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index ea7990a226..fc10bdaade 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -41,7 +41,6 @@ namespace Avalonia.UnitTests windowingPlatform: new MockWindowingPlatform()); public static readonly TestServices RealFocus = new TestServices( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), @@ -51,7 +50,6 @@ namespace Avalonia.UnitTests textShaperImpl: new HeadlessTextShaperStub()); public static readonly TestServices FocusableWindow = new TestServices( - focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), keyboardNavigation: () => new KeyboardNavigationHandler(), inputManager: new InputManager(), @@ -73,7 +71,6 @@ namespace Avalonia.UnitTests public TestServices( IAssetLoader assetLoader = null, - IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, Func keyboardNavigation = null, @@ -90,7 +87,6 @@ namespace Avalonia.UnitTests IWindowingPlatform windowingPlatform = null) { AssetLoader = assetLoader; - FocusManager = focusManager; InputManager = inputManager; KeyboardDevice = keyboardDevice; KeyboardNavigation = keyboardNavigation; @@ -109,7 +105,6 @@ namespace Avalonia.UnitTests internal TestServices( IGlobalClock globalClock, IAssetLoader assetLoader = null, - IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, Func keyboardNavigation = null, @@ -125,7 +120,7 @@ namespace Avalonia.UnitTests IWindowImpl windowImpl = null, IWindowingPlatform windowingPlatform = null, IAccessKeyHandler accessKeyHandler = null - ) : this(assetLoader, focusManager, inputManager, keyboardDevice, + ) : this(assetLoader, inputManager, keyboardDevice, keyboardNavigation, mouseDevice, platform, renderInterface, renderLoop, standardCursorFactory, theme, dispatcherImpl, fontManagerImpl, textShaperImpl, windowImpl, windowingPlatform) @@ -136,7 +131,6 @@ namespace Avalonia.UnitTests public IAssetLoader AssetLoader { get; } public IInputManager InputManager { get; } - public IFocusManager FocusManager { get; } internal IGlobalClock GlobalClock { get; set; } internal IAccessKeyHandler AccessKeyHandler { get; } public Func KeyboardDevice { get; } @@ -154,7 +148,6 @@ namespace Avalonia.UnitTests internal TestServices With( IAssetLoader assetLoader = null, - IFocusManager focusManager = null, IInputManager inputManager = null, Func keyboardDevice = null, Func keyboardNavigation = null, @@ -176,7 +169,6 @@ namespace Avalonia.UnitTests return new TestServices( globalClock ?? GlobalClock, assetLoader: assetLoader ?? AssetLoader, - focusManager: focusManager ?? FocusManager, inputManager: inputManager ?? InputManager, keyboardDevice: keyboardDevice ?? KeyboardDevice, keyboardNavigation: keyboardNavigation ?? KeyboardNavigation, diff --git a/tests/Avalonia.UnitTests/UnitTestApplication.cs b/tests/Avalonia.UnitTests/UnitTestApplication.cs index 047dc630f7..722f97b78b 100644 --- a/tests/Avalonia.UnitTests/UnitTestApplication.cs +++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs @@ -63,7 +63,6 @@ namespace Avalonia.UnitTests { AvaloniaLocator.CurrentMutable .Bind().ToConstant(Services.AssetLoader) - .Bind().ToConstant(Services.FocusManager) .Bind().ToConstant(Services.GlobalClock) .BindToSelf(this) .Bind().ToConstant(Services.InputManager)