diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a6d42706..696cd9a5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2221,6 +2221,12 @@ public sealed class GameWindow : IDisposable chatRoot.Width = contentW; chatRoot.Height = contentH; chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; + // The LayoutDesc root is 800px wide because it shares the display + // coordinate space with auxiliary/floaty chat elements. The docked + // gmMainChatUI content is the left 490px. Rebase its immediate children + // after that intentional crop so subsequent frame resizing grows the + // transcript/input across the whole mounted interior. + chatRoot.RebaseChildLayoutBaselines(); chatRoot.Draggable = false; chatRoot.Resizable = false; chatFrame.AddChild(chatRoot); _uiHost.Root.AddChild(chatFrame); diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 02b6faf2..adba08e1 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -232,6 +232,7 @@ public sealed class ChatWindowController float oldTop = bar.Top; bar.Top = 0f; // pull up to the panel top (resize-bar reclaim) bar.Height = bar.Height + oldTop; + bar.ResetAnchorCapture(); bar.Model = c.Transcript.Scroll; bar.SpriteResolve = resolve; bar.TrackSprite = TrackSprite; @@ -308,6 +309,7 @@ public sealed class ChatWindowController // just LEFT of the scrollbar column — shift it one button-width left. if (track is not null) maxMinEl.Left = track.Left - maxMinEl.Width; + maxMinEl.ResetAnchorCapture(); maxMinEl.OnClick = c.ToggleMaximize; } diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index f9c33a72..c293a66b 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -151,9 +151,10 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful _resolve = resolve; ClickThrough = false; // buttons are interactive — opt OUT of click-through - foreach (uint stateId in info.States.Keys) - if (stateId != UiStateInfo.DirectStateId) - _availableStates.Add(stateId); + // Visual transitions can select only states with an actual button face. + // Retail layouts commonly declare an empty Normal_pressed descriptor while + // supplying art only for Normal/Highlight. Treating that property-only state + // as drawable briefly blanks the button during mouse-down. foreach (string stateName in info.StateMedia.Keys) if (UiButtonStateMachine.TryStateId(stateName, out uint stateId)) _availableStates.Add(stateId); diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index ca7eb3d1..f02e9d71 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -483,11 +483,41 @@ public abstract class UiElement Left = x; Top = y; Width = w; Height = h; } - /// Forget the captured anchor margins so the next - /// re-captures them from the CURRENT rect. Call after manually repositioning/resizing - /// an anchored element at runtime (e.g. reflowing the chat input when the channel - /// button width changes) so the new rect becomes the anchor baseline. - internal void ResetAnchorCapture() => _anchorCaptured = false; + /// + /// Make the current geometry the new layout baseline after an intentional + /// controller/runtime change. Compatibility anchors recapture their margins on + /// the next layout pass; imported raw-edge policies rebase immediately against + /// the current parent rect. + /// + internal void ResetAnchorCapture() + { + _anchorCaptured = false; + if (LayoutPolicy is null || Parent is null) return; + + LayoutPolicy.Rebase( + UiPixelRect.FromPositionAndSize( + (int)Left, + (int)Top, + (int)Width, + (int)Height), + UiPixelRect.FromPositionAndSize( + 0, + 0, + (int)Parent.Width, + (int)Parent.Height)); + } + + /// + /// Rebase each immediate child after a host deliberately changes this element's + /// design extent. This is used when a production DAT root contains auxiliary + /// space that is cropped by its retail window mount; descendants must reflow + /// from the mounted content rect, not from the uncropped LayoutDesc display box. + /// + internal void RebaseChildLayoutBaselines() + { + foreach (var child in _children) + child.ResetAnchorCapture(); + } /// Walk up to the owning (the top of the tree), or null /// if this element is not attached. Lets a widget reach focus/capture services — e.g. diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index 5700abdd..0dd71605 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -1,5 +1,8 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.App.Tests.UI.Layout; @@ -10,6 +13,8 @@ namespace AcDream.App.Tests.UI.Layout; /// public class ChatLayoutConformanceTests { + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + private static ElementInfo? Find(ElementInfo n, uint id) { if (n.Id == id) return n; @@ -58,4 +63,58 @@ public class ChatLayoutConformanceTests Assert.True(input.OneLine); Assert.Equal(0x10000016u, input.DatElementId); } + + [Fact] + public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth() + { + var infos = FixtureLoader.LoadChatInfos(); + var layout = LayoutImporter.Build(infos, NoTex, null); + var controller = ChatWindowController.Bind( + infos, + layout, + new ChatVM(new ChatLog()), + () => NullCommandBus.Instance, + null, + null, + NoTex); + Assert.NotNull(controller); + + var root = controller!.Root; + root.Width = 490; + root.Height = 100; + root.RebaseChildLayoutBaselines(); + root.Width = 615; + + var transcriptPanel = layout.FindElement(0x10000010u)!; + var inputBar = layout.FindElement(0x10000013u)!; + transcriptPanel.ApplyAnchor(root.Width, root.Height); + inputBar.ApplyAnchor(root.Width, root.Height); + + Assert.Equal(615f, transcriptPanel.Width); + Assert.Equal(615f, inputBar.Width); + } + + [Fact] + public void ChatFixture_ChannelCaptionWidth_SurvivesImportedLayoutPass() + { + var infos = FixtureLoader.LoadChatInfos(); + var layout = LayoutImporter.Build(infos, NoTex, null); + var controller = ChatWindowController.Bind( + infos, + layout, + new ChatVM(new ChatLog()), + () => NullCommandBus.Instance, + null, + null, + NoTex); + Assert.NotNull(controller); + + controller!.Menu.OnSelect!.Invoke(ChatChannelKind.General); + float fittedWidth = controller.Menu.Width; + controller.Menu.ApplyAnchor(controller.Menu.Parent!.Width, controller.Menu.Parent.Height); + + Assert.True(fittedWidth > 46f); + Assert.Equal(fittedWidth, controller.Menu.Width); + Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left); + } } diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 1c761b65..88fadd5a 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -86,6 +86,22 @@ public class UiButtonTests Assert.Equal("LockedUI", b.ActiveState); } + [Fact] + public void PropertyOnlyPressedState_PreservesDrawableNormalFace() + { + var info = ButtonInfo("Normal", "Highlight"); + info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo + { + Id = UiButtonStateMachine.NormalPressed, + Name = "Normal_pressed", + }; + var b = CreateButton(info); + + b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5)); + + Assert.Equal("Normal", b.ActiveState); + } + [Fact] public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick() { diff --git a/tests/AcDream.App.Tests/UI/UiLayoutPolicyTests.cs b/tests/AcDream.App.Tests/UI/UiLayoutPolicyTests.cs index 908fde17..a00241a3 100644 --- a/tests/AcDream.App.Tests/UI/UiLayoutPolicyTests.cs +++ b/tests/AcDream.App.Tests/UI/UiLayoutPolicyTests.cs @@ -129,4 +129,50 @@ public sealed class UiLayoutPolicyTests Assert.Equal(400f, element.Width); Assert.Equal(200f, element.Height); } + + [Fact] + public void ResetAnchorCapture_RebasesImportedPolicyAfterControllerResize() + { + var parent = new UiPanel { Width = 490, Height = 17 }; + var child = new UiPanel + { + Left = 0, + Top = 0, + Width = 46, + Height = 17, + LayoutPolicy = new UiLayoutPolicy( + 1, 1, 2, 1, + UiPixelRect.FromPositionAndSize(0, 0, 46, 17), + UiPixelRect.FromPositionAndSize(0, 0, 490, 17)), + }; + parent.AddChild(child); + child.Width = 72; + + child.ResetAnchorCapture(); + child.ApplyAnchor(parent.Width, parent.Height); + + Assert.Equal(72f, child.Width); + } + + [Fact] + public void RebaseChildLayoutBaselines_CroppedChatRegionThenGrowsWithFrame() + { + var root = new UiPanel { Width = 490, Height = 100 }; + var transcriptPanel = new UiPanel + { + Width = 490, + Height = 83, + LayoutPolicy = new UiLayoutPolicy( + 1, 1, 1, 1, + UiPixelRect.FromPositionAndSize(0, 0, 490, 83), + UiPixelRect.FromPositionAndSize(0, 0, 800, 100)), + }; + root.AddChild(transcriptPanel); + + root.RebaseChildLayoutBaselines(); + root.Width = 615; + transcriptPanel.ApplyAnchor(root.Width, root.Height); + + Assert.Equal(615f, transcriptPanel.Width); + } }