feat(ui): unify retained window mounts

This commit is contained in:
Erik 2026-07-10 22:22:25 +02:00
parent 6e9e10367f
commit a8e9503d2e
20 changed files with 823 additions and 366 deletions

View file

@ -1511,8 +1511,8 @@ public class CharacterStatControllerTests
Assert.NotNull(scrollbar.Model);
Assert.NotNull(scrollbar.SpriteResolve);
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
Assert.Equal(0x06004C6Cu, scrollbar.UpSprite);
Assert.Equal(0x06004C69u, scrollbar.DownSprite);
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
}
// ── Helpers ──────────────────────────────────────────────────────────────

View file

@ -64,6 +64,32 @@ public class ChatLayoutConformanceTests
Assert.Equal(0x10000016u, input.DatElementId);
}
[Fact]
public void ChatFixture_ScrollbarImportsInheritedMediaRoles()
{
var layout = FixtureLoader.LoadChat();
var scrollbar = Assert.IsType<UiScrollbar>(layout.FindElement(0x10000012u));
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
}
[Fact]
public void ChatFixture_WindowRootCarriesRetailHeightConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x1000000Eu)!;
Assert.True(window.TryGetEffectiveInteger(0x3Eu, out int minHeight));
Assert.True(window.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
Assert.Equal(100, minHeight);
Assert.Equal(360, maxHeight);
}
[Fact]
public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth()
{
@ -117,4 +143,121 @@ public class ChatLayoutConformanceTests
Assert.Equal(fittedWidth, controller.Menu.Width);
Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left);
}
[Fact]
public void ChatMaximize_ResizesOuterFrameByHalfParent_AndRestores()
{
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)!;
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Left = 12f,
Top = 390f,
ContentWidth = 490f,
ContentHeight = controller.Root.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
});
controller.AttachWindow(handle);
var maxMin = Assert.IsType<UiButton>(layout.FindElement(0x1000046Fu));
maxMin.OnClick!();
Assert.True(controller.IsMaximized);
Assert.Equal(90f, handle.Top);
Assert.Equal(370f, handle.Height); // DAT max 360 + 2*5px shared chrome
Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId);
controller.Root.ApplyAnchor(handle.Width, handle.Height);
Assert.Equal(360f, controller.Root.Height);
maxMin.OnClick!();
Assert.False(controller.IsMaximized);
Assert.Equal(390f, handle.Top);
Assert.Equal(110f, handle.Height);
Assert.Equal(RetailUiStateIds.Minimized, maxMin.ActiveRetailStateId);
}
[Fact]
public void MountedChatScrollbar_ButtonsAndThumbDragDriveTranscriptModel()
{
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)!;
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Left = 10f,
Top = 440f,
ContentWidth = 490f,
ContentHeight = controller.Root.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
});
controller.AttachWindow(handle);
UiScrollbar bar = controller.Scrollbar;
UiScrollable scroll = controller.Transcript.Scroll;
scroll.LineHeight = 16;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(350, scroll.ScrollY);
var screen = bar.ScreenPosition;
int centerX = (int)(screen.X + bar.Width / 2f);
// Top/decrement button.
int upY = (int)(screen.Y + 8f);
root.OnMouseDown(UiMouseButton.Left, centerX, upY);
root.OnMouseUp(UiMouseButton.Left, centerX, upY);
Assert.Equal(334, scroll.ScrollY);
// Re-layout must retain the position chosen through the scrollbar.
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(334, scroll.ScrollY);
// Drag the thumb toward the top of the track.
float trackTop = 16f;
float trackLength = bar.Height - 32f;
var (thumbY, thumbHeight) = UiScrollbar.ThumbRect(scroll, trackTop, trackLength);
int dragStartY = (int)(screen.Y + thumbY + thumbHeight / 2f);
int dragEndY = (int)(screen.Y + trackTop + thumbHeight / 2f);
root.OnMouseDown(UiMouseButton.Left, centerX, dragStartY);
root.OnMouseMove(centerX, dragEndY);
root.OnMouseUp(UiMouseButton.Left, centerX, dragEndY);
Assert.True(scroll.ScrollY < 334);
int draggedPosition = scroll.ScrollY;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(draggedPosition, scroll.ScrollY);
}
}

View file

@ -0,0 +1,126 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailWindowFrameTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void ImportedChrome_MountsRootWithoutASecondFrame()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 220, Height = 90 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "vitals",
Chrome = RetailWindowChrome.Imported,
Left = 12,
Top = 18,
ResizeX = true,
ResizeY = false,
MinWidth = 40,
ContentClickThrough = false,
});
Assert.Same(content, handle.OuterFrame);
Assert.Same(content, handle.ContentRoot);
Assert.Same(root, content.Parent);
Assert.Equal((12f, 18f, 220f, 90f),
(content.Left, content.Top, content.Width, content.Height));
Assert.True(content.Draggable);
Assert.True(content.Resizable);
Assert.True(content.ResizeX);
Assert.False(content.ResizeY);
Assert.Equal(40f, content.MinWidth);
}
[Fact]
public void NineSlice_UsesCroppedContentAndDatHeightConstraints()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 800, Height = 100 };
var constraints = Constraints(minHeight: 100, maxHeight: 360);
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "chat",
Chrome = RetailWindowChrome.NineSlice,
Left = 10,
Top = 440,
ContentWidth = 490,
DatConstraintSource = constraints,
MinWidth = 200,
Opacity = 0.75f,
});
var frame = Assert.IsType<UiNineSlicePanel>(handle.OuterFrame);
Assert.Same(content, handle.ContentRoot);
Assert.Same(frame, content.Parent);
Assert.Equal((500f, 110f), (frame.Width, frame.Height));
Assert.Equal((5f, 5f, 490f, 100f),
(content.Left, content.Top, content.Width, content.Height));
Assert.Equal(200f, frame.MinWidth);
Assert.Equal(110f, frame.MinHeight);
Assert.Equal(370f, frame.MaxHeight);
Assert.Equal(0.75f, frame.Opacity);
}
[Fact]
public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 300, Height = 122 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "toolbar",
Chrome = RetailWindowChrome.CollapsibleNineSlice,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
ContentClickThrough = true,
});
var frame = Assert.IsType<UiCollapsibleFrame>(handle.OuterFrame);
Assert.False(frame.ResizeX);
Assert.True(frame.ResizeY);
Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges);
Assert.True(content.ClickThrough);
Assert.False(content.Draggable);
Assert.False(content.Resizable);
}
private static ElementInfo Constraints(int minHeight, int maxHeight)
{
var info = new ElementInfo();
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x3Eu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = minHeight,
};
direct.Properties.Values[0x3Cu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = maxHeight,
};
info.States[UiStateInfo.DirectStateId] = direct;
return info;
}
}

View file

@ -70,4 +70,25 @@ public class UiScrollableTests
s.ScrollByPage(1);
Assert.Equal(300, s.ScrollY);
}
[Fact]
public void SetExtents_PreservesEndButRetainsUserScrollback()
{
var s = new UiScrollable();
s.SetExtents(contentHeight: 400, viewHeight: 100, preserveEnd: true);
Assert.Equal(300, s.ScrollY);
// A scrollbar/wheel interaction moves off the end. Repeating layout with
// the same extents must not overwrite that user-selected position.
s.ScrollByLines(-1);
Assert.Equal(284, s.ScrollY);
s.SetExtents(contentHeight: 400, viewHeight: 100, preserveEnd: true);
Assert.Equal(284, s.ScrollY);
// Returning to the end restores auto-pin for newly appended content.
s.ScrollToEnd();
s.SetExtents(contentHeight: 500, viewHeight: 100, preserveEnd: true);
Assert.Equal(400, s.ScrollY);
}
}