From c456998ce8130497e3eaf92d94892f2ad9bbb819 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Sun, 22 Feb 2026 22:38:30 +0500 Subject: [PATCH] Make sure that TopLevel is no longer the actual root of the visual tree. This is needed for our future changes. --- src/Avalonia.Base/Input/FocusManager.cs | 7 ++++--- src/Avalonia.Base/Input/IInputRoot.cs | 5 +++++ src/Avalonia.Base/Input/KeyboardDevice.cs | 2 +- .../VisualTree/IHostedVisualTreeRoot.cs | 2 +- .../EmbeddableControlRootAutomationPeer.cs | 4 ++-- .../Peers/WindowBaseAutomationPeer.cs | 2 +- .../PresentationSource.Input.cs | 2 ++ .../PresentationSource/PresentationSource.cs | 4 +++- src/Avalonia.Controls/ToolTipService.cs | 2 +- src/Avalonia.Controls/TopLevel.cs | 8 +++++-- src/Avalonia.Controls/TopLevelHost.cs | 21 +++++++++++++++++++ src/Avalonia.X11/X11Window.cs | 4 ++-- .../CompositorInvalidationClippingTests.cs | 8 ++++--- .../FlyoutTests.cs | 4 ++-- .../Primitives/PopupRootTests.cs | 4 ++-- .../Primitives/PopupTests.cs | 2 +- .../ToolTipTests.cs | 6 +++--- .../TopLevelTests.cs | 5 ++--- tests/Avalonia.RenderTests/TestRenderRoot.cs | 2 +- tests/Avalonia.UnitTests/TestRoot.cs | 1 + 20 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 src/Avalonia.Controls/TopLevelHost.cs diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index 675fa9952f..e3bc8d5dbd 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -13,7 +13,8 @@ namespace Avalonia.Input /// Manages focus for the application. /// [PrivateApi] - public class FocusManager : IFocusManager + public class + FocusManager : IFocusManager { /// /// Private attached property for storing the currently focused element in a focus scope. @@ -246,10 +247,10 @@ namespace Avalonia.Input if (scope is not Visual v) return null; - var root = v.VisualRoot as Visual; + var root = v.PresentationSource?.InputRoot.FocusRoot as Visual; while (root is IHostedVisualTreeRoot hosted && - hosted.Host?.VisualRoot is Visual parentRoot) + hosted.Host?.PresentationSource?.InputRoot.FocusRoot is {} parentRoot) { root = parentRoot; } diff --git a/src/Avalonia.Base/Input/IInputRoot.cs b/src/Avalonia.Base/Input/IInputRoot.cs index c98a7a4e57..08c9ec5fad 100644 --- a/src/Avalonia.Base/Input/IInputRoot.cs +++ b/src/Avalonia.Base/Input/IInputRoot.cs @@ -26,5 +26,10 @@ namespace Avalonia.Input internal ITextInputMethodImpl? InputMethod { get; } internal InputElement RootElement { get; } + + // HACK: This is a temporary hack for "default focus" concept. + // If nothing is focused we send keyboard events to Window. Since for now we always + // control PresentationSource, we simply pass the TopLevel as a separate parameter there. + internal InputElement FocusRoot { get; } } } diff --git a/src/Avalonia.Base/Input/KeyboardDevice.cs b/src/Avalonia.Base/Input/KeyboardDevice.cs index 7711592557..3d9764528a 100644 --- a/src/Avalonia.Base/Input/KeyboardDevice.cs +++ b/src/Avalonia.Base/Input/KeyboardDevice.cs @@ -225,7 +225,7 @@ namespace Avalonia.Input if(e.Handled) return; - var element = FocusedElement ?? e.Root.RootElement; + var element = FocusedElement ?? e.Root.FocusRoot; if (e is RawKeyEventArgs keyInput) { diff --git a/src/Avalonia.Base/VisualTree/IHostedVisualTreeRoot.cs b/src/Avalonia.Base/VisualTree/IHostedVisualTreeRoot.cs index 9c33b1ffb9..e2a457bdc9 100644 --- a/src/Avalonia.Base/VisualTree/IHostedVisualTreeRoot.cs +++ b/src/Avalonia.Base/VisualTree/IHostedVisualTreeRoot.cs @@ -3,7 +3,7 @@ namespace Avalonia.VisualTree /// /// Interface for controls that are at the root of a hosted visual tree, such as popups. /// - public interface IHostedVisualTreeRoot + internal interface IHostedVisualTreeRoot { /// /// Gets the visual tree host. diff --git a/src/Avalonia.Controls/Automation/Peers/EmbeddableControlRootAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/EmbeddableControlRootAutomationPeer.cs index cbc45d113e..3f4e3e2215 100644 --- a/src/Avalonia.Controls/Automation/Peers/EmbeddableControlRootAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/EmbeddableControlRootAutomationPeer.cs @@ -58,8 +58,8 @@ namespace Avalonia.Controls.Automation.Peers { var oldFocus = _focus; var c = focus as Control; - - _focus = c?.VisualRoot == Owner ? c : null; + + _focus = Owner.IsVisualAncestorOf(c) ? c : null; if (_focus != oldFocus) { diff --git a/src/Avalonia.Controls/Automation/Peers/WindowBaseAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/WindowBaseAutomationPeer.cs index ceb695422d..0e12ebd285 100644 --- a/src/Avalonia.Controls/Automation/Peers/WindowBaseAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/WindowBaseAutomationPeer.cs @@ -70,7 +70,7 @@ namespace Avalonia.Automation.Peers var oldFocus = _focus; var c = focus as Control; - _focus = c?.VisualRoot == Owner ? c : null; + _focus = Owner.IsVisualAncestorOf(c) ? c : null; if (_focus != oldFocus) { diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.Input.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.Input.cs index f153fe628b..3b48c7089d 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.Input.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.Input.cs @@ -57,4 +57,6 @@ internal partial class PresentationSource } return candidate; } + + public InputElement FocusRoot { get; } } \ No newline at end of file diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs index 0c400dff81..85d5e38c12 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs @@ -19,7 +19,8 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi internal FocusManager FocusManager { get; } = new(); - public PresentationSource(InputElement rootVisual, ITopLevelImpl platformImpl, + public PresentationSource(InputElement rootVisual, InputElement defaultFocusVisual, + ITopLevelImpl platformImpl, IAvaloniaDependencyResolver dependencyResolver, Func clientSizeProvider) { _clientSizeProvider = clientSizeProvider; @@ -41,6 +42,7 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi LayoutManager = CreateLayoutManager(); RootVisual = rootVisual; + FocusRoot = defaultFocusVisual; } // In WPF it's a Visual and it's nullable. For now we have it as non-nullable InputElement since diff --git a/src/Avalonia.Controls/ToolTipService.cs b/src/Avalonia.Controls/ToolTipService.cs index 1f09e1777a..6f95e69af8 100644 --- a/src/Avalonia.Controls/ToolTipService.cs +++ b/src/Avalonia.Controls/ToolTipService.cs @@ -38,7 +38,7 @@ namespace Avalonia.Controls if (e is RawPointerEventArgs pointerEvent) { bool isTooltipEvent = false; - if (_tipControl?.GetValue(ToolTip.ToolTipProperty) is { } currentTip && e.Root.RootElement == currentTip.PopupHost) + if (_tipControl?.GetValue(ToolTip.ToolTipProperty) is { } currentTip && e.Root == (currentTip.PopupHost as Visual)?.GetInputRoot()) { isTooltipEvent = true; _lastTipEventTime = pointerEvent.Timestamp; diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 2304ccb63c..5cf1a5b71a 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -189,8 +189,12 @@ namespace Avalonia.Controls PlatformImpl = impl ?? throw new InvalidOperationException( "Could not create window implementation: maybe no windowing subsystem was initialized?"); dependencyResolver ??= AvaloniaLocator.Current; - _source = new PresentationSource(this, impl, dependencyResolver, () => ClientSize); - _source.RootVisual = this; + + var hostVisual = new TopLevelHost(this); + ((ISetLogicalParent)hostVisual).SetParent(this); + + _source = new PresentationSource(hostVisual, this, + impl, dependencyResolver, () => ClientSize); _source.Renderer.SceneInvalidated += SceneInvalidated; _scaling = ValidateScaling(impl.RenderScaling); diff --git a/src/Avalonia.Controls/TopLevelHost.cs b/src/Avalonia.Controls/TopLevelHost.cs new file mode 100644 index 0000000000..86ea95473c --- /dev/null +++ b/src/Avalonia.Controls/TopLevelHost.cs @@ -0,0 +1,21 @@ +using Avalonia.Input; + +namespace Avalonia.Controls; + +/// +/// For now this is a stub class that is needed to prevent people from assuming that TopLevel sits at the root of the +/// visual tree. +/// In future 12.x releases it will serve more roles like hosting popups and CSD. +/// +internal class TopLevelHost : Control +{ + static TopLevelHost() + { + KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Cycle); + } + + public TopLevelHost(TopLevel tl) + { + VisualChildren.Add(tl); + } +} \ No newline at end of file diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 602381d7c9..26a3e7ef08 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -268,8 +268,8 @@ namespace Avalonia.X11 : Task.FromResult(null), () => GtkSystemDialog.TryCreate(this), // TODO: This will be incompatible with "root element is not a TopLevel" scenarios, - // we should probably have a separate API for this - () => Task.FromResult(InputRoot.RootElement is TopLevel tl + // HACK: this relies on focus root being TopLevel which currently is true + () => Task.FromResult(InputRoot.FocusRoot is TopLevel tl ? (IStorageProvider?)new ManagedStorageProvider(tl) : null) }); diff --git a/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationClippingTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationClippingTests.cs index 78a3d3e5bc..ccd023301f 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationClippingTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/CompositorInvalidationClippingTests.cs @@ -14,6 +14,8 @@ public class CompositorInvalidationClippingTests : CompositorTestsBase foreach (var child in visual.VisualChildren) count += CountVisuals(child); return count; } + + private const int TopLevelOverhead = 2; // TopLevel + TopLevelHost [Theory, // If canvas itself has no background, the second render won't draw any visuals at all, since @@ -22,9 +24,9 @@ public class CompositorInvalidationClippingTests : CompositorTestsBase InlineData(true, false, false, 1, 0), InlineData(false, true, false, 1, 0), // If canvas has background, the second render will draw only the canvas visual itself - InlineData(false, false, true, 5, 4), - InlineData(true, false, true,5, 4), - InlineData(false, true, true, 5, 4), + InlineData(false, false, true, 4 + TopLevelOverhead, 3 + TopLevelOverhead), + InlineData(true, false, true,4 + TopLevelOverhead, 3 + TopLevelOverhead), + InlineData(false, true, true, 4 + TopLevelOverhead, 3 + TopLevelOverhead), ] public void Do_Not_Re_Render_Unaffected_Visual_Trees(bool clipToBounds, bool clipGeometry, bool canvasHasContent, diff --git a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs index 20edbad1e5..43a6e4b42a 100644 --- a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs +++ b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs @@ -256,7 +256,7 @@ namespace Avalonia.Controls.UnitTests var hitTester = new Mock(); window.HitTesterOverride = hitTester.Object; hitTester.Setup(x => - x.HitTestFirst(new Point(90, 90), window, It.IsAny>())) + x.HitTestFirst(new Point(90, 90), (Visual)window.VisualRoot!, It.IsAny>())) .Returns(button); var e = CreatePointerPressedEventArgs(window, new Point(90, 90)); @@ -299,7 +299,7 @@ namespace Avalonia.Controls.UnitTests var hitTester = new Mock(); window.HitTesterOverride = hitTester.Object; hitTester.Setup(x => - x.HitTestFirst(new Point(90, 90), window, It.IsAny>())) + x.HitTestFirst(new Point(90, 90), (Visual)window.VisualRoot!, It.IsAny>())) .Returns(button); var e = CreatePointerPressedEventArgs(window, new Point(90, 90)); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs index 4d04c43491..a5133613bb 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs @@ -100,7 +100,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void PopupRoot_Should_Have_Null_VisualParent() + public void PopupRoot_Should_Have_TopLevelHost_VisualParent() { using (UnitTestApplication.Start(TestServices.StyledWindow)) { @@ -108,7 +108,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Open(); - Assert.Null(((Visual)target.Host!).GetVisualParent()); + Assert.IsType(((Visual)target.Host!).GetVisualParent()); } } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index cf2836da50..ca38a56166 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -607,7 +607,7 @@ namespace Avalonia.Controls.UnitTests.Primitives window.Content = border; hitTester.Setup(x => - x.HitTestFirst(new Point(10, 15), window, It.IsAny>())) + x.HitTestFirst(new Point(10, 15), (Visual)window.VisualRoot!, It.IsAny>())) .Returns(border); border.PointerPressed += (s, e) => diff --git a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs index 721876312c..8672013b81 100644 --- a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs @@ -26,7 +26,7 @@ namespace Avalonia.Controls.UnitTests var toolTip = control.GetValue(ToolTip.ToolTipProperty); Assert.NotNull(toolTip); Assert.IsType(toolTip.PopupHost); - Assert.Same(toolTip.VisualRoot, toolTip.PopupHost); + Assert.Same(TopLevel.GetTopLevel(toolTip), toolTip.PopupHost); } } @@ -574,7 +574,7 @@ namespace Avalonia.Controls.UnitTests point = new Point(id, int.MaxValue); } - hitTesterMock.Setup(m => m.HitTestFirst(point, window, It.IsAny>())) + hitTesterMock.Setup(m => m.HitTestFirst(point, It.IsAny(), It.IsAny>())) .Returns(control); var root = control?.GetInputRoot() ?? window.InputRoot; @@ -585,7 +585,7 @@ namespace Avalonia.Controls.UnitTests if (lastRoot != null && lastRoot != root) { - ((TopLevel)lastRoot.RootElement)?.PlatformImpl?.Input?.Invoke(new RawPointerEventArgs(s_mouseDevice, timestamp, + ((PresentationSource)lastRoot)?.PlatformImpl?.Input?.Invoke(new RawPointerEventArgs(s_mouseDevice, timestamp, lastRoot, RawPointerEventType.LeaveWindow, new Point(-1,-1), RawInputModifiers.None)); } diff --git a/tests/Avalonia.Controls.UnitTests/TopLevelTests.cs b/tests/Avalonia.Controls.UnitTests/TopLevelTests.cs index 9614ab8df9..02a880d166 100644 --- a/tests/Avalonia.Controls.UnitTests/TopLevelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TopLevelTests.cs @@ -222,7 +222,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Adding_Top_Level_As_Child_Should_Not_Exception() + public void Adding_Top_Level_As_Child_Should_Throw_Exception() { using (UnitTestApplication.Start(TestServices.StyledWindow)) { @@ -233,8 +233,7 @@ namespace Avalonia.Controls.UnitTests target.Template = CreateTemplate(); target.Content = child; target.ApplyTemplate(); - - target.Presenter!.ApplyTemplate(); + Assert.Throws(() => target.Presenter!.ApplyTemplate()); } } diff --git a/tests/Avalonia.RenderTests/TestRenderRoot.cs b/tests/Avalonia.RenderTests/TestRenderRoot.cs index 0c87ed0935..290d7dafbb 100644 --- a/tests/Avalonia.RenderTests/TestRenderRoot.cs +++ b/tests/Avalonia.RenderTests/TestRenderRoot.cs @@ -75,6 +75,6 @@ namespace Avalonia.Skia.RenderTests public IInputElement? PointerOverElement { get; set; } public ITextInputMethodImpl? InputMethod { get; } public InputElement RootElement => this; - + public InputElement FocusRoot => this; } } diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index 0237cdbdce..2424188140 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -80,6 +80,7 @@ namespace Avalonia.UnitTests public IInputElement? PointerOverElement { get; set; } public ITextInputMethodImpl? InputMethod { get; } public InputElement RootElement => this; + public InputElement FocusRoot => this; public bool ShowAccessKeys { get; set; }