From 059bd5f7b94fef990630e932ffdc616f47bec964 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Wed, 18 Mar 2026 16:00:53 +0100 Subject: [PATCH] Add FindNextElementOptions.FocusedElement (#20930) * Add FocusElement.FindNextElementOptions * Add unit tests for FindNextElementOptions.FocusedElement * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Input/FindNextElementOptions.cs | 42 +++++-- src/Avalonia.Base/Input/FocusManager.cs | 89 ++++++++------- src/Avalonia.Base/Input/IFocusManager.cs | 10 +- .../Input/InputElement_Focus.cs | 108 +++++++++++++++++- 4 files changed, 191 insertions(+), 58 deletions(-) diff --git a/src/Avalonia.Base/Input/FindNextElementOptions.cs b/src/Avalonia.Base/Input/FindNextElementOptions.cs index 72d83ec419..ee9c2e6fef 100644 --- a/src/Avalonia.Base/Input/FindNextElementOptions.cs +++ b/src/Avalonia.Base/Input/FindNextElementOptions.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Avalonia.Input +namespace Avalonia.Input { /// /// Provides options to customize the behavior when identifying the next element to focus @@ -12,14 +6,28 @@ namespace Avalonia.Input /// public sealed class FindNextElementOptions { + /// + /// Gets or sets the element that will be treated as the starting point of the search + /// for the next focusable element. This does not need to be the element that is + /// currently focused. If null, is used. + /// + public IInputElement? FocusedElement { get; init; } + /// /// Gets or sets the root within which the search for the next /// focusable element will be conducted. /// /// + /// /// This property defines the boundary for focus navigation operations. It determines the root element /// in the visual tree under which the focusable item search is performed. If not specified, the search /// will default to the current scope. + /// + /// + /// This option is only used with , , + /// , and . It is ignored for other + /// directions. + /// /// public InputElement? SearchRoot { get; init; } @@ -27,6 +35,11 @@ namespace Avalonia.Input /// Gets or sets the rectangular region within the visual hierarchy that will be excluded /// from consideration during focus navigation. /// + /// + /// This option is only used with , , + /// , and . It is ignored for other + /// directions. + /// public Rect ExclusionRect { get; init; } /// @@ -35,12 +48,22 @@ namespace Avalonia.Input /// which can be used as a preferred or prioritized target when navigating focus. /// It can be null if no specific hint region is provided. /// + /// + /// This option is only used with , , + /// , and . It is ignored for other + /// directions. + /// public Rect? FocusHintRectangle { get; init; } /// /// Specifies an optional override for the navigation strategy used in XY focus navigation. /// This property allows customizing the focus movement behavior when navigating between UI elements. /// + /// + /// This option is only used with , , + /// , and . It is ignored for other + /// directions. + /// public XYFocusNavigationStrategy? NavigationStrategyOverride { get; init; } /// @@ -49,6 +72,11 @@ namespace Avalonia.Input /// the navigation logic disregards obstructions that may block a potential /// focus target, allowing elements behind such obstructions to be considered. /// + /// + /// This option is only used with , , + /// , and . It is ignored for other + /// directions. + /// public bool IgnoreOcclusivity { get; init; } } } diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index dc62171f48..a273ff6d89 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -172,7 +172,7 @@ namespace Avalonia.Input ValidateDirection(direction); var focusOptions = ToFocusOptions(options, true); - var result = FindAndSetNextFocus(direction, focusOptions); + var result = FindAndSetNextFocus(options?.FocusedElement ?? Current, direction, focusOptions); _reusableFocusOptions = focusOptions; return result; } @@ -319,7 +319,7 @@ namespace Avalonia.Input ValidateDirection(direction); var focusOptions = ToFocusOptions(options, false); - var result = FindNextFocus(direction, focusOptions); + var result = FindNextFocus(options?.FocusedElement ?? Current, direction, focusOptions); _reusableFocusOptions = focusOptions; return result; } @@ -368,27 +368,29 @@ namespace Avalonia.Input return focusOptions; } - internal IInputElement? FindNextFocus(NavigationDirection direction, XYFocusOptions focusOptions, bool updateManifolds = true) + private IInputElement? FindNextFocus( + IInputElement? focusedElement, + NavigationDirection direction, + XYFocusOptions focusOptions, + bool updateManifolds = true) { - IInputElement? nextFocusedElement = null; + IInputElement? nextFocusedElement; - var currentlyFocusedElement = Current; - - if (direction is NavigationDirection.Previous or NavigationDirection.Next || currentlyFocusedElement == null) + if (direction is NavigationDirection.Previous or NavigationDirection.Next || focusedElement == null) { var isReverse = direction == NavigationDirection.Previous; - nextFocusedElement = ProcessTabStopInternal(isReverse, true); + nextFocusedElement = ProcessTabStopInternal(focusedElement, isReverse, true); } else { - if (currentlyFocusedElement is InputElement inputElement && + if (focusedElement is InputElement inputElement && XYFocus.GetBoundsForRanking(inputElement, focusOptions.IgnoreClipping) is { } bounds) { focusOptions.FocusedElementBounds = bounds; } nextFocusedElement = _xyFocus.GetNextFocusableElement(direction, - currentlyFocusedElement as InputElement, + focusedElement as InputElement, null, updateManifolds, focusOptions); @@ -509,14 +511,14 @@ namespace Avalonia.Input return lastFocus; } - private IInputElement? ProcessTabStopInternal(bool isReverse, bool queryOnly) + private IInputElement? ProcessTabStopInternal(IInputElement? focusedElement, bool isReverse, bool queryOnly) { IInputElement? newTabStop = null; - var defaultCandidateTabStop = GetTabStopCandidateElement(isReverse, queryOnly, out var didCycleFocusAtRootVisualScope); + var defaultCandidateTabStop = GetTabStopCandidateElement(focusedElement, isReverse, queryOnly, out var didCycleFocusAtRootVisualScope); var isTabStopOverriden = InputElement.ProcessTabStop(_contentRoot, - Current, + focusedElement, defaultCandidateTabStop, isReverse, didCycleFocusAtRootVisualScope, @@ -535,24 +537,27 @@ namespace Avalonia.Input return newTabStop; } - private IInputElement? GetTabStopCandidateElement(bool isReverse, bool queryOnly, out bool didCycleFocusAtRootVisualScope) + private IInputElement? GetTabStopCandidateElement( + IInputElement? focusedElement, + bool isReverse, + bool queryOnly, + out bool didCycleFocusAtRootVisualScope) { didCycleFocusAtRootVisualScope = false; - var currentFocus = Current; - IInputElement? newTabStop = null; - var root = this._contentRoot as IInputElement; + IInputElement? newTabStop; + var root = _contentRoot; if (root == null) return null; bool internalCycleWorkaround = false; - if (Current != null) + if (focusedElement != null) { - internalCycleWorkaround = CanProcessTabStop(isReverse); + internalCycleWorkaround = CanProcessTabStop(focusedElement, isReverse); } - if (currentFocus == null) + if (focusedElement == null) { if (!isReverse) { @@ -567,7 +572,7 @@ namespace Avalonia.Input } else if (!isReverse) { - newTabStop = GetNextTabStop(); + newTabStop = GetNextTabStop(focusedElement); if (newTabStop == null && (internalCycleWorkaround || queryOnly)) { @@ -578,7 +583,7 @@ namespace Avalonia.Input } else { - newTabStop = GetPreviousTabStop(); + newTabStop = GetPreviousTabStop(focusedElement); if (newTabStop == null && (internalCycleWorkaround || queryOnly)) { @@ -590,9 +595,9 @@ namespace Avalonia.Input return newTabStop; } - private IInputElement? GetNextTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false) + private IInputElement? GetNextTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false) { - var focused = currentTabStop ?? Current; + var focused = currentTabStop; if (focused == null || _contentRoot == null) { return null; @@ -698,9 +703,9 @@ namespace Avalonia.Input return newTabStop; } - private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop = null, bool ignoreCurrentTabStop = false) + private IInputElement? GetPreviousTabStop(IInputElement? currentTabStop, bool ignoreCurrentTabStop = false) { - var focused = currentTabStop ?? Current; + var focused = currentTabStop; if (focused == null || _contentRoot == null) { return null; @@ -930,23 +935,23 @@ namespace Avalonia.Input return int.MaxValue; } - private bool CanProcessTabStop(bool isReverse) + private bool CanProcessTabStop(IInputElement? focusedElement, bool isReverse) { bool isFocusOnFirst = false; bool isFocusOnLast = false; bool canProcessTab = true; - if (IsFocusedElementInPopup()) + if (IsFocusedElementInPopup(focusedElement)) { return true; } if (isReverse) { - isFocusOnFirst = IsFocusOnFirstTabStop(); + isFocusOnFirst = IsFocusOnFirstTabStop(focusedElement); } else { - isFocusOnLast = IsFocusOnLastTabStop(); + isFocusOnLast = IsFocusOnLastTabStop(focusedElement); } if (isFocusOnFirst || isFocusOnLast) @@ -961,7 +966,7 @@ namespace Avalonia.Input if (edge != null) { var edgeParent = GetParentTabStopElement(edge); - if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(Current)) + if (edgeParent is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Once && edgeParent == GetParentTabStopElement(focusedElement)) { canProcessTab = false; } @@ -975,13 +980,13 @@ namespace Avalonia.Input { if (isFocusOnLast || isFocusOnFirst) { - if (Current is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle) + if (focusedElement is InputElement inputElement && KeyboardNavigation.GetTabNavigation(inputElement) == KeyboardNavigationMode.Cycle) { canProcessTab = true; } else { - var focusedParent = GetParentTabStopElement(Current); + var focusedParent = GetParentTabStopElement(focusedElement); while (focusedParent != null) { if (focusedParent is InputElement iE && KeyboardNavigation.GetTabNavigation(iE) == KeyboardNavigationMode.Cycle) @@ -1041,9 +1046,9 @@ namespace Avalonia.Input return null; } - private bool IsFocusOnLastTabStop() + private bool IsFocusOnLastTabStop(IInputElement? focusedElement) { - if (Current == null || _contentRoot is not Visual visual) + if (focusedElement == null || _contentRoot is not Visual visual) return false; var root = visual.VisualRoot as IInputElement; @@ -1051,12 +1056,12 @@ namespace Avalonia.Input var lastFocus = GetLastFocusableElement(root, null); - return lastFocus == Current; + return lastFocus == focusedElement; } - private bool IsFocusOnFirstTabStop() + private bool IsFocusOnFirstTabStop(IInputElement? focusedElement) { - if (Current == null || _contentRoot is not Visual visual) + if (focusedElement == null || _contentRoot is not Visual visual) return false; var root = visual.VisualRoot as IInputElement; @@ -1064,7 +1069,7 @@ namespace Avalonia.Input var firstFocus = GetFirstFocusableElement(root, null); - return firstFocus == Current; + return firstFocus == focusedElement; } private static IInputElement? GetFirstFocusableElement(IInputElement searchStart, IInputElement? firstFocus = null) @@ -1091,7 +1096,7 @@ namespace Avalonia.Input return lastFocus; } - private bool IsFocusedElementInPopup() => Current != null && GetRootOfPopupSubTree(Current) != null; + private bool IsFocusedElementInPopup(IInputElement? focusedElement) => focusedElement != null && GetRootOfPopupSubTree(focusedElement) != null; private Visual? GetRootOfPopupSubTree(IInputElement? current) { @@ -1099,7 +1104,7 @@ namespace Avalonia.Input return null; } - private bool FindAndSetNextFocus(NavigationDirection direction, XYFocusOptions xYFocusOptions) + private bool FindAndSetNextFocus(IInputElement? focusedElement, NavigationDirection direction, XYFocusOptions xYFocusOptions) { var focusChanged = false; if (xYFocusOptions.UpdateManifoldsFromFocusHintRect && xYFocusOptions.FocusHintRectangle != null) @@ -1107,7 +1112,7 @@ namespace Avalonia.Input _xyFocus.SetManifoldsFromBounds(xYFocusOptions.FocusHintRectangle ?? default); } - if (FindNextFocus(direction, xYFocusOptions, false) is { } nextFocusedElement) + if (FindNextFocus(focusedElement, direction, xYFocusOptions, false) is { } nextFocusedElement) { focusChanged = nextFocusedElement.Focus(); diff --git a/src/Avalonia.Base/Input/IFocusManager.cs b/src/Avalonia.Base/Input/IFocusManager.cs index 9bd1fb4239..d9e8d36f8b 100644 --- a/src/Avalonia.Base/Input/IFocusManager.cs +++ b/src/Avalonia.Base/Input/IFocusManager.cs @@ -41,10 +41,7 @@ namespace Avalonia.Input /// , , /// and . /// - /// - /// The options to help identify the next element to receive focus. - /// They only apply to directional navigation. - /// + /// The options to help identify the next element to receive focus. /// true if focus moved; otherwise, false. bool TryMoveFocus(NavigationDirection direction, FindNextElementOptions? options = null); @@ -69,10 +66,7 @@ namespace Avalonia.Input /// , , /// and . /// - /// - /// The options to help identify the next element to receive focus. - /// They only apply to directional navigation. - /// + /// The options to help identify the next element to receive focus. /// The next element to receive focus, if any. IInputElement? FindNextElement(NavigationDirection direction, FindNextElementOptions? options = null); } diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index cdb4588fff..a09eccc1a3 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -806,7 +806,43 @@ namespace Avalonia.Base.UnitTests.Input } [Fact] - public void Can_Get_Next_Element_With_Options() + public void Can_Get_Next_Element_With_FocusedElement_Option() + { + 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); + Assert.NotNull(focusManager); + Assert.Null(focusManager.GetFocusedElement()); + + var next = focusManager.FindNextElement( + NavigationDirection.Next, + new FindNextElementOptions { FocusedElement = target1 }); + + Assert.Equal(next, target2); + } + } + + [Fact] + public void Can_Get_Directional_Next_Element_With_Options() { using (UnitTestApplication.Start(TestServices.RealFocus)) { @@ -870,6 +906,76 @@ namespace Avalonia.Base.UnitTests.Input } } + [Fact] + public void Can_Get_Directional_Next_Element_With_FocusedElement_Option() + { + 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 searchStack = new StackPanel() + { + Children = + { + target3, + target4 + } + }; + var container = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + Children = + { + target1, + target2, + searchStack, + target5 + } + }; + var root = new TestRoot + { + Child = container + }; + + root.InvalidateMeasure(); + root.ExecuteInitialLayoutPass(); + + var focusManager = FocusManager.GetFocusManager(container); + Assert.NotNull(focusManager); + Assert.Null(focusManager.GetFocusedElement()); + + // Search root is right of the specified focused element, should return the first focusable element in the search root + var next = focusManager.FindNextElement(NavigationDirection.Right, new FindNextElementOptions + { + SearchRoot = searchStack, + FocusedElement = target1 + }); + + Assert.Equal(next, target3); + + // Search root is left of the specified focused element, should return the first focusable element in the search root + next = focusManager.FindNextElement(NavigationDirection.Left, new FindNextElementOptions + { + SearchRoot = searchStack, + FocusedElement = target5 + }); + + Assert.Equal(next, target3); + + // Search root isn't to the right of the specified focused element, should return null + next = focusManager.FindNextElement(NavigationDirection.Right, new FindNextElementOptions + { + SearchRoot = searchStack, + FocusedElement = target5 + }); + + Assert.Null(next); + } + } + [Fact] public void Focus_Should_Move_According_To_Direction() {