From c19d0dc6c7f9f6fb436ead8cc1d43bf5bef309ae Mon Sep 17 00:00:00 2001 From: David Smith Date: Wed, 7 Oct 2026 11:23:40 +0200 Subject: [PATCH] fix(ui): the overlay layers follow the interface root's size Entering the world restores the window layout, which sizes the interface root and records the screen size without telling the overlay host. The next draw then saw no change and never resized the overlay layers, so they stayed at the pre-game 800x600 and a plugin canvas was clipped outside that rectangle. It only came right when a world overlay with something to draw pushed its camera viewport into the shared rectangle, or the window was resized again. The host now follows its interface root: the runtime calls FollowRoot every frame, and the world overlays no longer write the shared rectangle. They still size their own elements from the camera. Claude-Session: https://claude.ai/code/session_01SY57mHJLZ6djpWZ5nP37Tw --- .../ProjectileDebugOverlayController.cs | 6 +-- src/AcDream.App/UI/Layout/UiOverlayHost.cs | 26 +++++++----- .../UI/Layout/WorldLabelOverlayController.cs | 6 +-- src/AcDream.App/UI/RetailUiRuntime.cs | 8 ++-- .../UI/Layout/PluginCanvasElementTests.cs | 4 +- .../UI/Layout/UiOverlayHostTests.cs | 40 +++++++++++++++++-- 6 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs b/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs index 859f3d615..da6d09799 100644 --- a/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs +++ b/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs @@ -9,7 +9,6 @@ internal sealed class ProjectileDebugOverlayController private static readonly Vector4 ClearColor = new(0f, 1f, 0f, 0.95f); private static readonly Vector4 BlockedColor = new(1f, 0f, 0f, 0.95f); - private readonly UiOverlayHost _host; private readonly UiPanel _root; private readonly Func> _samples; private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> @@ -17,12 +16,10 @@ internal sealed class ProjectileDebugOverlayController private readonly List _markers = []; private ProjectileDebugOverlayController( - UiOverlayHost host, UiPanel root, Func> samples, Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) { - _host = host; _root = root; _samples = samples; _camera = camera; @@ -42,7 +39,7 @@ internal static ProjectileDebugOverlayController Mount( ArgumentNullException.ThrowIfNull(samples); ArgumentNullException.ThrowIfNull(camera); UiPanel root = host.AddLayer("PluginProjectileDebugOverlay"); - return new ProjectileDebugOverlayController(host, root, samples, camera); + return new ProjectileDebugOverlayController(root, samples, camera); } internal void Tick() @@ -58,7 +55,6 @@ internal void Tick() } EnsureMarkerCount(samples.Count); - _host.SetViewport(camera.Viewport); int visible = 0; for (int index = 0; index < samples.Count; index++) { diff --git a/src/AcDream.App/UI/Layout/UiOverlayHost.cs b/src/AcDream.App/UI/Layout/UiOverlayHost.cs index 6393477fe..431ae6749 100644 --- a/src/AcDream.App/UI/Layout/UiOverlayHost.cs +++ b/src/AcDream.App/UI/Layout/UiOverlayHost.cs @@ -114,17 +114,20 @@ private static void MakeSubtreeClickThrough(UiElement element) /// Children are clipped to their parent's rectangle by default, so an /// overlay whose rectangle is still zero-sized shows nothing at all. That is /// why the host seeds its rectangle from the interface root at mount and -/// re-applies it to the root and every layer on -/// . +/// re-applies it to the root and every layer on . +/// The interface root is the one source of that rectangle: a world overlay +/// projects into its camera's viewport but never resizes the layers. /// internal sealed class UiOverlayHost { + private readonly UiRoot _host; private readonly UiOverlayLayer _root; private readonly List _layers = []; private Vector2 _viewport; - private UiOverlayHost(UiOverlayLayer root, Vector2 viewport) + private UiOverlayHost(UiRoot host, UiOverlayLayer root, Vector2 viewport) { + _host = host; _root = root; _viewport = viewport; } @@ -144,7 +147,7 @@ internal static UiOverlayHost Mount(UiRoot host) ZOrder = UiOverlayZOrder.SharedHostRoot, }; host.AddChild(root); - var overlayHost = new UiOverlayHost(root, new Vector2(host.Width, host.Height)); + var overlayHost = new UiOverlayHost(host, root, new Vector2(host.Width, host.Height)); overlayHost.ApplyViewport(); return overlayHost; } @@ -166,14 +169,17 @@ internal UiOverlayLayer AddLayer(string name) } /// - /// Points the host at the rectangle the world is being drawn into. Every - /// layer covers the same rectangle: an overlay is by definition the whole - /// screen, and one shared value keeps two owners from clipping each other. + /// Brings the root and every layer to the interface root's current size. + /// The interface calls it every frame, after sizing its root to the + /// screen: the root can also be resized elsewhere (restoring the layout + /// on entering the world does), so the host follows the root rather than + /// waiting to be told of a change. /// - internal void SetViewport(Vector2 viewport) + internal void FollowRoot() { - if (_viewport == viewport) return; - _viewport = viewport; + var size = new Vector2(_host.Width, _host.Height); + if (_viewport == size) return; + _viewport = size; ApplyViewport(); } diff --git a/src/AcDream.App/UI/Layout/WorldLabelOverlayController.cs b/src/AcDream.App/UI/Layout/WorldLabelOverlayController.cs index 48d093078..f1d4a333a 100644 --- a/src/AcDream.App/UI/Layout/WorldLabelOverlayController.cs +++ b/src/AcDream.App/UI/Layout/WorldLabelOverlayController.cs @@ -223,7 +223,6 @@ protected override void OnDraw(UiRenderContext ctx) /// internal sealed class WorldLabelOverlayController { - private readonly UiOverlayHost _host; private readonly UiOverlayLayer _layer; private readonly WorldLabelLayerElement _element; private readonly UiDatFont _font; @@ -234,7 +233,6 @@ internal sealed class WorldLabelOverlayController private List _back = []; private WorldLabelOverlayController( - UiOverlayHost host, UiOverlayLayer layer, WorldLabelLayerElement element, UiDatFont font, @@ -242,7 +240,6 @@ private WorldLabelOverlayController( Func anchor, Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) { - _host = host; _layer = layer; _element = element; _font = font; @@ -277,7 +274,7 @@ internal static WorldLabelOverlayController Mount( }; layer.AddChild(element); return new WorldLabelOverlayController( - host, layer, element, font, labels, anchor, camera); + layer, element, font, labels, anchor, camera); } internal void Tick() @@ -293,7 +290,6 @@ internal void Tick() return; } - _host.SetViewport(camera.Viewport); _element.Width = camera.Viewport.X; _element.Height = camera.Viewport.Y; WorldLabelLayout.Place( diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 5f9abbd68..bd12a537e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -777,10 +777,12 @@ public void Draw(System.Numerics.Vector2 screenSize) _persistence?.SetGameplayActive(gameplay); if (gameplay) _persistence?.ReflowToScreen(); _lastScreenSize = screenSize; - // The overlay band covers the whole screen; a canvas anchored to - // a right or bottom edge is placed against this rectangle. - _overlayHost?.SetViewport(screenSize); } + // The overlay band covers the whole screen; a canvas anchored to a + // right or bottom edge is placed against this rectangle. Followed + // every frame, not only on a change seen above: restoring the layout + // resizes the root without passing through here. + _overlayHost?.FollowRoot(); Host.Draw(screenSize); } diff --git a/tests/AcDream.App.Tests/UI/Layout/PluginCanvasElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/PluginCanvasElementTests.cs index da9371b41..68675a462 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PluginCanvasElementTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PluginCanvasElementTests.cs @@ -465,7 +465,9 @@ public void ACanvasIsPlacedByAnchorPlusOffsetAndFollowsTheViewport() registration.Anchor = PluginCanvasAnchor.BottomRight; registration.Offset = new PluginPoint(-10, -10); - harness.Host.SetViewport(new Vector2(1024f, 768f)); + harness.Root.Width = 1024f; + harness.Root.Height = 768f; + harness.Host.FollowRoot(); harness.Frame(); Assert.Equal((814f, 658f), (element.Left, element.Top)); } diff --git a/tests/AcDream.App.Tests/UI/Layout/UiOverlayHostTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiOverlayHostTests.cs index 9bb00951b..64ca0a963 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiOverlayHostTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiOverlayHostTests.cs @@ -1,7 +1,9 @@ +using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.Plugin.Abstractions; namespace AcDream.App.Tests.UI.Layout; @@ -151,14 +153,20 @@ public void TheRootTakesItsRectangleFromTheInterfaceRootAtMount() } [Fact] - public void ANewViewportReachesTheRootAndEveryLayerIncludingOnesAddedLater() + public void ANewRootSizeReachesTheRootAndEveryLayerIncludingOnesAddedLater() { - UiOverlayHost host = UiOverlayHost.Mount(Root()); + UiRoot interfaceRoot = Root(); + UiOverlayHost host = UiOverlayHost.Mount(interfaceRoot); UiOverlayLayer first = host.AddLayer("First"); - host.SetViewport(new Vector2(1920f, 1080f)); + // Resized behind the host's back, the way restoring the layout on + // entering the world resizes it: the host is never told the size. + interfaceRoot.Width = 1920f; + interfaceRoot.Height = 1080f; + host.FollowRoot(); UiOverlayLayer second = host.AddLayer("Second"); + Assert.Equal(new Vector2(1920f, 1080f), host.Viewport); foreach (UiElement element in new UiElement[] { host.Root, first, second }) { Assert.Equal(0f, element.Left); @@ -168,6 +176,32 @@ public void ANewViewportReachesTheRootAndEveryLayerIncludingOnesAddedLater() } } + [Fact] + public void AWorldOverlayDrawingIntoADifferentViewportLeavesTheLayersAtTheScreenSize() + { + var interfaceRoot = new UiRoot { Width = 1920f, Height = 1080f }; + UiOverlayHost host = UiOverlayHost.Mount(interfaceRoot); + UiOverlayLayer canvases = host.AddLayer("Canvases"); + var samples = new List + { + new(new Vector3(0f, 0f, -10f), true, 0.4f), + }; + ProjectileDebugOverlayController overlay = ProjectileDebugOverlayController.Mount( + host, + () => samples, + () => (Matrix4x4.Identity, + Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 4f / 3f, 0.1f, 100f), + new Vector2(800f, 600f))); + + overlay.Tick(); + + foreach (UiElement element in new UiElement[] { host.Root, canvases }) + { + Assert.Equal(1920f, element.Width); + Assert.Equal(1080f, element.Height); + } + } + [Fact] public void ALayerStartsHiddenClickThroughAnchorlessAndWithoutChrome() {