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);
+ }
}